@boyingliu01/xp-gate 0.8.2 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/xp-gate.js CHANGED
@@ -7,57 +7,96 @@ const { uninstall } = require('../lib/uninstall.js');
7
7
  const { doctor } = require('../lib/doctor.js');
8
8
  const { checkDeps } = require('../lib/detect-deps.js');
9
9
  const { migrate } = require('../lib/migrate.js');
10
+ const { handleBaseline } = require('../lib/baseline.js');
11
+
12
+ function handleUIReview() {
13
+ const { execSync } = require('child_process');
14
+ const uiReviewPath = path.join(__dirname, '..', 'lib', 'ui-review.ts');
15
+ try {
16
+ execSync(`npx -y tsx "${uiReviewPath}"`, { stdio: 'inherit' });
17
+ process.exit(0);
18
+ } catch (err) {
19
+ process.exit(1);
20
+ }
21
+ }
10
22
 
11
23
  const COMMANDS = {
12
24
  'init': {
13
25
  description: 'Initialize xp-gate (use --global for all projects)',
14
- fn: init,
26
+ run: subargs => init(subargs).then(code => process.exit(code)),
15
27
  usage: 'xp-gate init [--global]'
16
28
  },
17
29
  'setup-global': {
18
30
  description: 'Set up xp-gate globally for all git projects',
19
- fn: init,
31
+ run: () => init(['--global']).then(code => process.exit(code)),
20
32
  usage: 'xp-gate setup-global'
21
33
  },
22
34
  'install-skill': {
23
35
  description: 'Install a xp-gate skill from GitHub',
24
- fn: installSkill,
36
+ run: subargs => {
37
+ const name = subargs[0];
38
+ if (!name) {
39
+ console.error('Error: Skill name required');
40
+ console.error('Usage: xp-gate install-skill <name>[@<version>]');
41
+ process.exit(1);
42
+ }
43
+ const options = parseOptions(subargs.slice(1));
44
+ installSkill(name, options).then(code => process.exit(code));
45
+ },
25
46
  usage: 'xp-gate install-skill <name>[@<version>] [--offline] [--verbose] [--force]'
26
47
  },
27
48
  'update-skill': {
28
49
  description: 'Update installed skill(s)',
29
- fn: updateSkill,
50
+ run: subargs => {
51
+ const name = subargs[0];
52
+ const options = parseOptions(subargs.slice(1));
53
+ updateSkill(name, options).then(code => process.exit(code));
54
+ },
30
55
  usage: 'xp-gate update-skill [<name>] [--all] [--check]'
31
56
  },
32
57
  'uninstall-skill': {
33
58
  description: 'Uninstall a xp-gate skill',
34
- fn: uninstallSkill,
59
+ run: subargs => {
60
+ const name = subargs[0];
61
+ if (!name) {
62
+ console.error('Error: Skill name required');
63
+ console.error('Usage: xp-gate uninstall-skill <name>');
64
+ process.exit(1);
65
+ }
66
+ const options = parseOptions(subargs.slice(1));
67
+ uninstallSkill(name, options).then(code => process.exit(code));
68
+ },
35
69
  usage: 'xp-gate uninstall-skill <name> [--force]'
36
70
  },
37
71
  'uninstall': {
38
72
  description: 'Uninstall xp-gate (reverse of init)',
39
- fn: uninstall,
73
+ run: subargs => uninstall(subargs).then(code => process.exit(code)),
40
74
  usage: 'xp-gate uninstall [--dry-run] [--force] [--local|--global]'
41
75
  },
42
76
  'migrate': {
43
77
  description: 'Migrate from v0.4.x (GitHub Packages) to v0.5.x (public npm)',
44
- fn: migrate,
78
+ run: subargs => migrate(subargs).then(code => process.exit(code)),
45
79
  usage: 'xp-gate migrate [--dry-run]'
46
80
  },
47
81
  'doctor': {
48
82
  description: 'Diagnose xp-gate installation health',
49
- fn: doctor,
83
+ run: subargs => doctor(subargs).then(code => process.exit(code)),
50
84
  usage: 'xp-gate doctor [--fix]'
51
85
  },
52
86
  'ui-review': {
53
87
  description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
54
- fn: null,
88
+ run: () => handleUIReview(),
55
89
  usage: 'xp-gate ui-review'
56
90
  },
57
91
  'audit': {
58
92
  description: 'Gate audit logging (record, --tail, --stats)',
59
- fn: null,
93
+ run: subargs => handleAudit(subargs),
60
94
  usage: 'xp-gate audit [--tail [N]|--stats|record --gate-id X --gate-name Y ...]'
95
+ },
96
+ 'baseline': {
97
+ description: 'Manage lint baselines (create, show, reset, diff)',
98
+ run: subargs => handleBaseline(subargs).then(code => process.exit(code)),
99
+ usage: 'xp-gate baseline <create|show|reset|diff>'
61
100
  }
62
101
  };
63
102
 
@@ -92,151 +131,94 @@ function main() {
92
131
 
93
132
  const command = args[0];
94
133
  const subargs = args.slice(1);
134
+ const cmd = COMMANDS[command];
95
135
 
96
- if (command === 'init' || command === 'setup-global') {
97
- const initArgs = command === 'setup-global' ? ['--global'] : subargs;
98
- init(initArgs).then(code => process.exit(code));
136
+ if (!cmd) {
137
+ console.error(`Unknown command: ${command}`);
138
+ printHelp();
139
+ process.exit(1);
99
140
  return;
100
141
  }
101
142
 
102
- if (command === 'install-skill') {
103
- const name = subargs[0];
104
- if (!name) {
105
- console.error('Error: Skill name required');
106
- console.error('Usage: xp-gate install-skill <name>[@<version>]');
107
- process.exit(1);
143
+ cmd.run(subargs);
144
+ }
145
+
146
+ function handleAuditTail(args, auditPath) {
147
+ const tailIdx = args.indexOf('--tail');
148
+ const count = parseInt(args[tailIdx + 1] || '20', 10);
149
+ try {
150
+ const { readTailEntries } = require(auditPath);
151
+ const entries = readTailEntries(count);
152
+ if (entries.length === 0) {
153
+ console.log('No audit entries found.');
108
154
  return;
109
155
  }
110
- const options = parseOptions(subargs.slice(1));
111
- installSkill(name, options).then(code => process.exit(code));
112
- return;
156
+ printAuditTable(entries);
157
+ } catch (err) {
158
+ console.error('Error reading audit entries:', err.message);
159
+ process.exit(1);
113
160
  }
114
-
115
- if (command === 'update-skill') {
116
- const name = subargs[0];
117
- const options = parseOptions(subargs.slice(1));
118
- updateSkill(name, options).then(code => process.exit(code));
119
- return;
120
- }
121
-
122
- if (command === 'uninstall-skill') {
123
- const name = subargs[0];
124
- if (!name) {
125
- console.error('Error: Skill name required');
126
- console.error('Usage: xp-gate uninstall-skill <name>');
127
- process.exit(1);
161
+ }
162
+
163
+ function handleAuditStats(auditPath) {
164
+ try {
165
+ const { computeStats } = require(auditPath);
166
+ const stats = computeStats();
167
+ if (stats.length === 0) {
168
+ console.log('No audit data found.');
128
169
  return;
129
170
  }
130
- const options = parseOptions(subargs.slice(1));
131
- uninstallSkill(name, options).then(code => process.exit(code));
132
- return;
133
- }
134
-
135
- if (command === 'uninstall') {
136
- uninstall(subargs).then(code => process.exit(code));
137
- return;
138
- }
139
-
140
- if (command === 'migrate') {
141
- migrate(subargs).then(code => process.exit(code));
142
- return;
143
- }
144
-
145
- if (command === 'doctor') {
146
- doctor(subargs).then(code => process.exit(code));
147
- return;
171
+ printStatsTable(stats);
172
+ } catch (err) {
173
+ console.error('Error computing stats:', err.message);
174
+ process.exit(1);
148
175
  }
176
+ }
149
177
 
150
- if (command === 'ui-review') {
151
- const { execSync } = require('child_process');
152
- const path = require('path');
153
- const uiReviewPath = path.join(__dirname, '..', 'lib', 'ui-review.ts');
154
- try {
155
- execSync(`npx -y tsx "${uiReviewPath}"`, { stdio: 'inherit' });
156
- process.exit(0);
157
- } catch (err) {
158
- process.exit(1);
178
+ function handleAuditRecord(args, auditPath) {
179
+ try {
180
+ const { appendAuditEntry } = require(auditPath);
181
+ const rest = args.slice(1);
182
+ const opts = {};
183
+ for (let i = 0; i < rest.length; i++) {
184
+ if (rest[i].startsWith('--') && i + 1 < rest.length) {
185
+ opts[rest[i].slice(2)] = rest[i + 1];
186
+ i++;
187
+ }
159
188
  }
189
+ const entry = {
190
+ timestamp: new Date().toISOString(),
191
+ gate_id: opts['gate-id'] || 'unknown',
192
+ gate_name: opts['gate-name'] || 'unknown',
193
+ passed: opts['passed'] === 'true',
194
+ issues_found: parseInt(opts['issues-found'] || '0', 10),
195
+ duration_ms: parseInt(opts['duration-ms'] || '0', 10),
196
+ trigger: opts['trigger'] || 'manual',
197
+ repo_path: process.cwd(),
198
+ commit_hash: opts['commit-hash'] || 'HEAD',
199
+ };
200
+ appendAuditEntry(entry);
201
+ } catch (err) {
202
+ console.error('Error recording audit entry:', err.message);
203
+ process.exit(1);
160
204
  }
161
-
162
- if (command === 'audit') {
163
- handleAudit(subargs);
164
- return;
165
- }
166
-
167
- console.error(`Unknown command: ${command}`);
168
- printHelp();
169
- process.exit(1);
170
205
  }
171
206
 
172
207
  function handleAudit(args) {
173
- const path = require('path');
174
208
  const auditPath = path.join(__dirname, '..', 'lib', 'gate-audit.ts');
175
209
 
176
- // --tail [N]
177
- const tailIdx = args.indexOf('--tail');
178
- if (tailIdx !== -1) {
179
- const count = parseInt(args[tailIdx + 1] || '20', 10);
180
- try {
181
- const { readTailEntries } = require(auditPath);
182
- const entries = readTailEntries(count);
183
- if (entries.length === 0) {
184
- console.log('No audit entries found.');
185
- return;
186
- }
187
- printAuditTable(entries);
188
- } catch (err) {
189
- console.error('Error reading audit entries:', err.message);
190
- process.exit(1);
191
- }
210
+ if (args.includes('--tail')) {
211
+ handleAuditTail(args, auditPath);
192
212
  return;
193
213
  }
194
214
 
195
- // --stats
196
215
  if (args.includes('--stats')) {
197
- try {
198
- const { computeStats } = require(auditPath);
199
- const stats = computeStats();
200
- if (stats.length === 0) {
201
- console.log('No audit data found.');
202
- return;
203
- }
204
- printStatsTable(stats);
205
- } catch (err) {
206
- console.error('Error computing stats:', err.message);
207
- process.exit(1);
208
- }
216
+ handleAuditStats(auditPath);
209
217
  return;
210
218
  }
211
219
 
212
- // record --gate-id X --gate-name Y --passed true/false ...
213
220
  if (args[0] === 'record') {
214
- try {
215
- const { appendAuditEntry } = require(auditPath);
216
- const rest = args.slice(1);
217
- const opts = {};
218
- for (let i = 0; i < rest.length; i++) {
219
- if (rest[i].startsWith('--') && i + 1 < rest.length) {
220
- opts[rest[i].slice(2)] = rest[i + 1];
221
- i++;
222
- }
223
- }
224
- const entry = {
225
- timestamp: new Date().toISOString(),
226
- gate_id: opts['gate-id'] || 'unknown',
227
- gate_name: opts['gate-name'] || 'unknown',
228
- passed: opts['passed'] === 'true',
229
- issues_found: parseInt(opts['issues-found'] || '0', 10),
230
- duration_ms: parseInt(opts['duration-ms'] || '0', 10),
231
- trigger: opts['trigger'] || 'manual',
232
- repo_path: process.cwd(),
233
- commit_hash: opts['commit-hash'] || 'HEAD',
234
- };
235
- appendAuditEntry(entry);
236
- } catch (err) {
237
- console.error('Error recording audit entry:', err.message);
238
- process.exit(1);
239
- }
221
+ handleAuditRecord(args, auditPath);
240
222
  return;
241
223
  }
242
224
 
package/hooks/pre-commit CHANGED
@@ -32,14 +32,15 @@ source "$ADAPTER_DIR/adapter-common.sh" 2>/dev/null || {
32
32
  # Trap: ensure quality report generated on ANY exit (pass or fail).
33
33
  # Using a guard prevents recursion when 'exit' is called from inside the trap.
34
34
  _QUALITY_REPORT_DONE=0
35
- _quality_report_on_exit() {
35
+ _quality_report_on_exit() {
36
+ local _saved_exit=$?
36
37
  if [ "$_QUALITY_REPORT_DONE" = "1" ]; then
37
- exit
38
+ exit $_saved_exit
38
39
  return
39
40
  fi
40
41
  _QUALITY_REPORT_DONE=1
41
- generate_quality_report
42
- exit
42
+ command -v generate_quality_report >/dev/null 2>&1 && generate_quality_report 2>/dev/null || true
43
+ exit $_saved_exit
43
44
  }
44
45
  trap '_quality_report_on_exit' EXIT
45
46
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @test REQ-152 Baseline CLI handler
3
+ * @intent Verify baseline create/show/reset/diff commands
4
+ * @covers AC-152
5
+ */
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const { handleBaseline, showBaseline } = require('../baseline.js');
11
+
12
+ describe('baseline.js - CLI handler', () => {
13
+ let tmpDir;
14
+ const originalCwd = process.cwd;
15
+
16
+ afterAll(() => {
17
+ process.cwd = originalCwd;
18
+ });
19
+
20
+ beforeEach(() => {
21
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'baseline-cli-test-'));
22
+ process.cwd = () => tmpDir;
23
+ });
24
+
25
+ describe('handleBaseline - help', () => {
26
+ it('prints usage when no subcommand given', async () => {
27
+ const logs = [];
28
+ const origLog = console.log;
29
+ console.log = (...args) => logs.push(args.join(' '));
30
+
31
+ const code = await handleBaseline([]);
32
+ expect(code).toBe(0);
33
+ expect(logs.some(l => l.includes('Usage'))).toBe(true);
34
+
35
+ console.log = origLog;
36
+ });
37
+
38
+ it('prints usage for --help', async () => {
39
+ const logs = [];
40
+ const origLog = console.log;
41
+ console.log = (...args) => logs.push(args.join(' '));
42
+
43
+ const code = await handleBaseline(['--help']);
44
+ expect(code).toBe(0);
45
+ expect(logs.some(l => l.includes('Usage'))).toBe(true);
46
+
47
+ console.log = origLog;
48
+ });
49
+ });
50
+
51
+ describe('handleBaseline - unknown subcommand', () => {
52
+ it('returns error for unknown subcommand', async () => {
53
+ const logs = [];
54
+ const errs = [];
55
+ const origLog = console.log;
56
+ const origErr = console.error;
57
+ console.log = (...args) => logs.push(args.join(' '));
58
+ console.error = (...args) => errs.push(args.join(' '));
59
+
60
+ const code = await handleBaseline(['unknown-cmd']);
61
+ expect(code).toBe(1);
62
+ expect(errs.some(l => l.includes('Unknown baseline subcommand'))).toBe(true);
63
+
64
+ console.log = origLog;
65
+ console.error = origErr;
66
+ });
67
+ });
68
+
69
+ describe('showBaseline - no baseline file', () => {
70
+ it('shows message when no baseline exists', () => {
71
+ const logs = [];
72
+ const origLog = console.log;
73
+ console.log = (...args) => logs.push(args.join(' '));
74
+
75
+ const code = showBaseline();
76
+ expect(code).toBe(1);
77
+ expect(logs.some(l => l.includes('No baseline'))).toBe(true);
78
+
79
+ console.log = origLog;
80
+ });
81
+ });
82
+
83
+ describe('showBaseline - with baseline file', () => {
84
+ it('shows summary when baseline exists', () => {
85
+ const baselinePath = path.join(tmpDir, '.xp-gate', 'lint-baseline.json');
86
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
87
+ fs.writeFileSync(baselinePath, JSON.stringify({
88
+ 'src/app.ts': {
89
+ eslint: { warnings: 3, errors: 1 },
90
+ totalWarnings: 3,
91
+ lastAnalyzed: '2026-06-01T12:00:00.000Z',
92
+ },
93
+ }));
94
+
95
+ const logs = [];
96
+ const origLog = console.log;
97
+ console.log = (...args) => logs.push(args.join(' '));
98
+
99
+ const code = showBaseline();
100
+ expect(code).toBe(0);
101
+ expect(logs.some(l => l.includes('3'))).toBe(true);
102
+ expect(logs.some(l => l.includes('ESLint'))).toBe(true);
103
+
104
+ console.log = origLog;
105
+ });
106
+
107
+ it('shows empty message for baseline with no entries', () => {
108
+ const baselinePath = path.join(tmpDir, '.xp-gate', 'lint-baseline.json');
109
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
110
+ fs.writeFileSync(baselinePath, '{}');
111
+
112
+ const logs = [];
113
+ const origLog = console.log;
114
+ console.log = (...args) => logs.push(args.join(' '));
115
+
116
+ const code = showBaseline();
117
+ expect(code).toBe(0);
118
+ expect(logs.some(l => l.includes('empty'))).toBe(true);
119
+
120
+ console.log = origLog;
121
+ });
122
+ });
123
+ });
@@ -193,6 +193,26 @@ describe('migrate', () => {
193
193
 
194
194
  // === Print summary ===
195
195
 
196
+ // === Phase isolation: removing npmrc should not affect cache check ===
197
+
198
+ it('phases are isolated: npmrc cleanup and cache check operate independently', async () => {
199
+ // Only npmrc has github packages lines, no cache dir
200
+ fs.writeFileSync(npmrcPath(), [
201
+ '//npm.pkg.github.com/:_authToken=ghp_abc123',
202
+ 'registry=https://registry.npmjs.org/'
203
+ ].join('\n') + '\n');
204
+
205
+ const { migrate } = require('../migrate');
206
+ const result = await migrate([]);
207
+
208
+ expect(result).toBe(0);
209
+ // npmrc should be cleaned
210
+ const content = fs.readFileSync(npmrcPath(), 'utf8');
211
+ expect(content).not.toContain('npm.pkg.github.com');
212
+ // Should report no old cache (dir doesn't exist)
213
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No old cache'));
214
+ });
215
+
196
216
  it('prints full summary of actions taken', async () => {
197
217
  fs.writeFileSync(npmrcPath(), [
198
218
  '//npm.pkg.github.com/:_authToken=ghp_abc123',
@@ -577,6 +577,42 @@ describe('uninstall', () => {
577
577
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('adapter-common.sh'));
578
578
  });
579
579
 
580
+ // === Execution loop: mixed item types plan ===
581
+
582
+ it('executes mixed-type plan (file + dir + gitconfig items) correctly', async () => {
583
+ setupGlobalInstall();
584
+ const expectedHooksPath = globalHooksDir();
585
+ mockExecGlobalHooksPath(expectedHooksPath);
586
+ const { uninstall } = require('../uninstall');
587
+
588
+ const result = await uninstall([]);
589
+
590
+ expect(result).toBe(0);
591
+
592
+ // Gitconfig type unset core.hooksPath
593
+ expect(execSpy).toHaveBeenCalledWith(
594
+ expect.stringContaining('git config --global --unset core.hooksPath'),
595
+ expect.any(Object)
596
+ );
597
+ // Dir items removed: global hooks, adapters, template
598
+ expect(fs.existsSync(globalHooksDir())).toBe(false);
599
+ expect(fs.existsSync(globalAdaptersDir())).toBe(false);
600
+ expect(fs.existsSync(templateDir())).toBe(false);
601
+ });
602
+
603
+ it('skips non-existent file items in execution loop gracefully', async () => {
604
+ setupLocalInstall();
605
+ // Delete one file before uninstall
606
+ fs.unlinkSync(path.join(projectHooksDir(), 'pre-commit'));
607
+ mockExecSuccess();
608
+ const { uninstall } = require('../uninstall');
609
+
610
+ const result = await uninstall([]);
611
+ expect(result).toBe(0);
612
+ // Should not crash — remaining items still processed
613
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
614
+ });
615
+
580
616
  it('saves rollback snapshot before destructive operations', async () => {
581
617
  setupLocalInstall();
582
618
  mockExecSuccess();
@@ -263,6 +263,17 @@ describe('update-skill', () => {
263
263
  expect(errorSpy).toHaveBeenCalledWith('Error: foo is not installed');
264
264
  });
265
265
 
266
+ // === Mode handler isolation: check/all/single operate independently ===
267
+
268
+ it('check and all modes handle empty config without crashing (edge cases)', async () => {
269
+ // No config file at all — getConfig returns {}
270
+ const { updateSkill } = require('../update-skill');
271
+ expect(await updateSkill(null, { check: true })).toBe(0);
272
+ expect(await updateSkill(null, { all: true })).toBe(0);
273
+ expect(await updateSkill('anything')).toBe(1);
274
+ expect(errorSpy).toHaveBeenCalledWith('Error: anything is not installed');
275
+ });
276
+
266
277
  it('getConfig parses valid config (single-name path succeeds)', async () => {
267
278
  writeConfig({ foo: { version: '1.0.0' } });
268
279
  const { updateSkill } = require('../update-skill');