@boyingliu01/xp-gate 0.35.23 → 0.36.0

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.
Files changed (71) hide show
  1. package/adapter-common.sh +78 -36
  2. package/adapters/csharp.sh +245 -0
  3. package/adapters/gate-3.sh +1 -1
  4. package/adapters/gate-4.sh +1 -1
  5. package/adapters/gate-9.sh +37 -2
  6. package/adapters/go.sh +6 -11
  7. package/adapters/iac.sh +45 -5
  8. package/adapters/python.sh +6 -11
  9. package/bin/xp-gate.js +18 -11
  10. package/gate-10.sh +18 -3
  11. package/gate-13-compiler-strictness.sh +181 -0
  12. package/gate-3.sh +1 -1
  13. package/gate-4.sh +1 -1
  14. package/gate-9.sh +37 -2
  15. package/gates/common.ts +9 -2
  16. package/hooks/adapter-common.sh +78 -36
  17. package/hooks/gate-10.sh +18 -3
  18. package/hooks/gate-13-compiler-strictness.sh +181 -0
  19. package/hooks/gate-3.sh +1 -1
  20. package/hooks/gate-4.sh +1 -1
  21. package/hooks/gate-9.sh +37 -2
  22. package/hooks/gate-registry.sh +161 -0
  23. package/hooks/lib/test-failure.sh +29 -4
  24. package/hooks/pre-commit +122 -3
  25. package/lib/__tests__/agent-runtime-journal.test.js +42 -18
  26. package/lib/__tests__/check-single-file.test.js +55 -4
  27. package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
  28. package/lib/__tests__/doctor-delphi.test.js +178 -3
  29. package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
  30. package/lib/__tests__/install-skill-full-dir.test.js +45 -2
  31. package/lib/__tests__/install-skill.test.js +191 -23
  32. package/lib/__tests__/mirror-sync.test.js +23 -5
  33. package/lib/__tests__/uninstall-global-residue.test.js +394 -9
  34. package/lib/__tests__/update-skill-flags.test.js +78 -0
  35. package/lib/__tests__/update-skill.test.js +334 -57
  36. package/lib/detect-deps.js +24 -0
  37. package/lib/doctor.js +80 -12
  38. package/lib/install-skill.js +85 -21
  39. package/lib/mattpocock-license-verdict.json +1 -1
  40. package/lib/uninstall.js +143 -12
  41. package/lib/update-skill.js +239 -28
  42. package/mock-policy/AGENTS.md +4 -4
  43. package/mutation/AGENTS.md +4 -4
  44. package/package.json +1 -1
  45. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  46. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  47. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  48. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  49. package/plugins/dsh/lib/index.d.ts.map +1 -1
  50. package/plugins/dsh/lib/index.js +7 -4
  51. package/plugins/dsh/package.json +1 -1
  52. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  53. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  54. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  55. package/plugins/dsh/src/index.test.ts +25 -0
  56. package/plugins/dsh/src/index.ts +7 -4
  57. package/plugins/opencode/package.json +1 -1
  58. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  59. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  60. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  61. package/plugins/qoder/plugin.json +1 -1
  62. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  63. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  64. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  65. package/principles/AGENTS.md +4 -4
  66. package/principles/adapters/csharp.ts +309 -0
  67. package/principles/analyzer.ts +5 -0
  68. package/scripts/delphi-external-review.cjs +130 -101
  69. package/skills/delphi-review/AGENTS.md +4 -4
  70. package/skills/sprint-flow/AGENTS.md +4 -4
  71. package/skills/test-specification-alignment/AGENTS.md +4 -4
@@ -44,6 +44,21 @@ function makeHome() {
44
44
  return home;
45
45
  }
46
46
 
47
+ /**
48
+ * Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
49
+ * time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
50
+ * Shared by every describe block here: three copies of this loop tripped the
51
+ * clone detector, and they had to stay byte-identical to be correct.
52
+ */
53
+ function freshRequire(relPath) {
54
+ for (const key of Object.keys(require.cache)) {
55
+ if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
56
+ delete require.cache[key];
57
+ }
58
+ }
59
+ return require(relPath);
60
+ }
61
+
47
62
  /**
48
63
  * Reproduce the on-disk shape `xp-gate setup-global` leaves behind, including the
49
64
  * parts that were measured as residue in #663.
@@ -61,6 +76,40 @@ function seedGlobalInstall() {
61
76
  fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{}\n');
62
77
  }
63
78
 
79
+ /**
80
+ * A REAL setup-global layout at an arbitrary config dir.
81
+ *
82
+ * The three manifest-ish files carry the content `setup-global` actually writes,
83
+ * so a signature check has to succeed or fail on realistic input rather than on
84
+ * `{}`. Shared by every block below -- the per-describe copies were identical
85
+ * and tripped the clone detector.
86
+ *
87
+ * @param {string} configDir the ~/.config/xp-gate directory to populate
88
+ */
89
+ function seedRealisticLayout(configDir) {
90
+ for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
91
+ fs.mkdirSync(path.join(configDir, rel), { recursive: true });
92
+ fs.writeFileSync(path.join(configDir, rel, 'index.js'), 'module.exports = {};\n');
93
+ }
94
+ fs.writeFileSync(
95
+ path.join(configDir, 'package.json'),
96
+ JSON.stringify({ dependencies: { 'js-yaml': '^5.2.2', zod: '^4.4.3' } }, null, 2)
97
+ );
98
+ fs.writeFileSync(
99
+ path.join(configDir, 'package-lock.json'),
100
+ JSON.stringify({ name: 'xp-gate-global', lockfileVersion: 3, packages: {} }, null, 2)
101
+ );
102
+ // `mode: 'global'` is what uninstall() reads to decide there is anything to
103
+ // do; without it the run exits "No xp-gate installation found" and the test
104
+ // would pass while touching nothing.
105
+ fs.writeFileSync(
106
+ path.join(configDir, 'xp-gate.json'),
107
+ JSON.stringify({ lastInit: new Date().toISOString(), mode: 'global', manifest: { files: {} } }, null, 2)
108
+ );
109
+ // User data: must survive the uninstall, and must be reported as retained.
110
+ fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{ "active_profile": "default" }\n');
111
+ }
112
+
64
113
 
65
114
  describe('uninstall --global plans the whole installation, not just hooks (#663)', () => {
66
115
  let home;
@@ -84,15 +133,6 @@ describe('uninstall --global plans the whole installation, not just hooks (#663)
84
133
  * Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
85
134
  * time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
86
135
  */
87
- function freshRequire(relPath) {
88
- for (const key of Object.keys(require.cache)) {
89
- if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
90
- delete require.cache[key];
91
- }
92
- }
93
- return require(relPath);
94
- }
95
-
96
136
  it('AC-670-37: modules, node_modules and the manifest files are all in scope', () => {
97
137
  const { buildPlan } = freshRequire('../uninstall');
98
138
  expect(typeof buildPlan).toBe('function');
@@ -158,3 +198,348 @@ describe('uninstall --global plans the whole installation, not just hooks (#663)
158
198
  expect(names).not.toContain('edited-skill');
159
199
  });
160
200
  });
201
+
202
+ // -- #714: the residue must be DELETED, not merely scheduled --------------------
203
+ // #663 taught `buildPlan()` to list modules/, node_modules/, package.json,
204
+ // package-lock.json and xp-gate.json, so AC-670-37 passed -- but it only ever
205
+ // asserted those paths appear in the plan. On Windows the run still printed
206
+ //
207
+ // Warning: ~/.config/xp-gate/xp-gate.json does not contain xp-gate signature - skipping
208
+ //
209
+ // for all three files (the two directories were deleted, because a `dir` step
210
+ // recurses without an ownership check). The plan entries carried neither
211
+ // `signature` nor `manifestKey`, so removeFile() reached
212
+ // `content.includes(undefined)`, which is always false. Worst case: the
213
+ // uninstaller had just written `"mode": "uninstalled"` into xp-gate.json and
214
+ // then reported that file as "not xp-gate's".
215
+ //
216
+ // The lesson these tests encode: a residue test that only inspects the PLAN
217
+ // cannot see a broken DELETE. Every assertion below runs the real uninstall.
218
+ describe('uninstall --global actually removes the residue (#714)', () => {
219
+ let home;
220
+ const savedEnv = {};
221
+ const realArgv = process.argv;
222
+
223
+ beforeEach(() => {
224
+ home = makeHome();
225
+ savedEnv.HOME = process.env.HOME;
226
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
227
+ process.env.HOME = home;
228
+ process.env.USERPROFILE = home;
229
+ process.argv = ['node', 'xp-gate', 'uninstall', '--global', '--force'];
230
+ });
231
+
232
+ afterEach(() => {
233
+ process.argv = realArgv;
234
+ process.env.HOME = savedEnv.HOME;
235
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
236
+ fs.rmSync(home, { recursive: true, force: true });
237
+ });
238
+
239
+ function configDir() {
240
+ return path.join(home, '.config', 'xp-gate');
241
+ }
242
+
243
+ /**
244
+ * Seed a REAL setup-global layout: the three manifest-ish files carry the
245
+ * content `setup-global` writes, so a signature check has to succeed or fail
246
+ * on realistic input rather than on `{}`.
247
+ */
248
+ function seedRealisticInstall() {
249
+ seedRealisticLayout(configDir());
250
+ }
251
+
252
+ /**
253
+ * @test REQ-717 Uninstall completeness
254
+ * @intent A file step in the residue plan must be deleted, not skipped for lack of a signature
255
+ * @covers AC-717-01
256
+ */
257
+ it('AC-717-01: package.json, package-lock.json and xp-gate.json are gone after uninstall', async () => {
258
+ seedRealisticInstall();
259
+ const { uninstall } = freshRequire('../uninstall');
260
+ await uninstall(['--global', '--force']);
261
+
262
+ for (const file of ['package.json', 'package-lock.json']) {
263
+ expect(
264
+ fs.existsSync(path.join(configDir(), file)),
265
+ `${file} survived uninstall - it is xp-gate's own file, not user data`
266
+ ).toBe(false);
267
+ }
268
+
269
+ // xp-gate.json is deliberately retained: the state machine rewrites it with
270
+ // `mode: "uninstalled"` so a second `uninstall` is a clean no-op (AC-09).
271
+ // What #714 broke was not the retention but the CLAIM about it -- the run
272
+ // called this file "not xp-gate's" while rewriting it. AC-717-04 pins that.
273
+ const manifestFile = path.join(configDir(), 'xp-gate.json');
274
+ expect(fs.existsSync(manifestFile)).toBe(true);
275
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
276
+ expect(manifest.mode).toBe('uninstalled');
277
+ });
278
+
279
+ /**
280
+ * @test REQ-717 Uninstall completeness
281
+ * @intent The two residue directories must go too, so a later setup-global cannot reuse them
282
+ * @covers AC-717-02
283
+ */
284
+ it('AC-717-02: modules/ and node_modules/ are gone after uninstall', async () => {
285
+ seedRealisticInstall();
286
+ const { uninstall } = freshRequire('../uninstall');
287
+ await uninstall(['--global', '--force']);
288
+
289
+ expect(fs.existsSync(path.join(configDir(), 'modules'))).toBe(false);
290
+ expect(fs.existsSync(path.join(configDir(), 'node_modules'))).toBe(false);
291
+ expect(fs.existsSync(path.join(configDir(), 'adapters'))).toBe(false);
292
+ });
293
+
294
+ /**
295
+ * @test REQ-717 Uninstall completeness
296
+ * @intent User data must still survive the now-working deletion path
297
+ * @covers AC-717-03
298
+ */
299
+ it('AC-717-03: .delphi-config.json survives the completed uninstall', async () => {
300
+ seedRealisticInstall();
301
+ const { uninstall } = freshRequire('../uninstall');
302
+ await uninstall(['--global', '--force']);
303
+
304
+ expect(fs.existsSync(path.join(configDir(), '.delphi-config.json'))).toBe(true);
305
+ });
306
+
307
+ /**
308
+ * @test REQ-717 Uninstall completeness
309
+ * @intent The uninstall must not report success while leaving its own residue behind
310
+ * @covers AC-717-04
311
+ */
312
+ it('AC-717-04: uninstall does not warn that its own residue files are foreign', async () => {
313
+ seedRealisticInstall();
314
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
315
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
316
+ try {
317
+ const { uninstall } = freshRequire('../uninstall');
318
+ await uninstall(['--global', '--force']);
319
+
320
+ const said = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
321
+ for (const file of ['package.json', 'package-lock.json', 'xp-gate.json']) {
322
+ expect(
323
+ said.includes(`${file} does not contain xp-gate signature`),
324
+ `uninstall disowned ${file}: ${said}`
325
+ ).toBe(false);
326
+ }
327
+ } finally {
328
+ warnSpy.mockRestore();
329
+ logSpy.mockRestore();
330
+ }
331
+ });
332
+ });
333
+
334
+ describe('uninstall hooks directory ownership (#714)', () => {
335
+ let home;
336
+ const savedEnv = {};
337
+
338
+ beforeEach(() => {
339
+ home = makeHome();
340
+ savedEnv.HOME = process.env.HOME;
341
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
342
+ process.env.HOME = home;
343
+ process.env.USERPROFILE = home;
344
+ });
345
+
346
+ afterEach(() => {
347
+ process.env.HOME = savedEnv.HOME;
348
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
349
+ fs.rmSync(home, { recursive: true, force: true });
350
+ });
351
+
352
+ /**
353
+ * @test REQ-717 Hook ownership
354
+ * @intent post-commit is installed by copyHooks(), so it must count as xp-gate's own
355
+ * @covers AC-717-05
356
+ */
357
+ it('AC-717-05: post-commit is classified as xp-gate-owned, not foreign', () => {
358
+ const { planHooksDirRemoval } = freshRequire('../uninstall');
359
+ const hooksDir = path.join(process.env.HOME, '.config', 'xp-gate', 'hooks');
360
+ fs.mkdirSync(hooksDir, { recursive: true });
361
+ // copyHooks() installs exactly these; post-commit was the one it forgot.
362
+ for (const f of ['pre-commit', 'pre-push', 'post-merge', 'post-commit', 'hook-module-resolution.sh']) {
363
+ fs.writeFileSync(path.join(hooksDir, f), '#!/bin/sh\n');
364
+ }
365
+
366
+ const result = planHooksDirRemoval(hooksDir);
367
+ const foreignNames = (result.foreign || []).map((f) => f.name);
368
+ expect(foreignNames).not.toContain('post-commit');
369
+ });
370
+
371
+ /**
372
+ * @test REQ-717 Hook ownership
373
+ * @intent A genuinely foreign hook (git-lfs) must still be protected
374
+ * @covers AC-717-06
375
+ */
376
+ it('AC-717-06: a third-party hook is still classified as foreign and kept', () => {
377
+ const { planHooksDirRemoval } = freshRequire('../uninstall');
378
+ const hooksDir = path.join(process.env.HOME, '.config', 'xp-gate', 'hooks');
379
+ fs.mkdirSync(hooksDir, { recursive: true });
380
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n');
381
+ fs.writeFileSync(path.join(hooksDir, 'post-checkout'), '#!/bin/sh\n# git-lfs\n');
382
+
383
+ const result = planHooksDirRemoval(hooksDir);
384
+ const foreignNames = (result.foreign || []).map((f) => f.name);
385
+ expect(foreignNames).toContain('post-checkout');
386
+ expect(result.action).toBe('remove-owned-only');
387
+ });
388
+ });
389
+
390
+ describe('uninstall --global does not grow the config directory (#724)', () => {
391
+ let home;
392
+ const savedEnv = {};
393
+ const realArgv = process.argv;
394
+
395
+ beforeEach(() => {
396
+ home = makeHome();
397
+ savedEnv.HOME = process.env.HOME;
398
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
399
+ process.env.HOME = home;
400
+ process.env.USERPROFILE = home;
401
+ process.argv = ['node', 'xp-gate', 'uninstall', '--global', '--force'];
402
+ });
403
+
404
+ afterEach(() => {
405
+ process.argv = realArgv;
406
+ process.env.HOME = savedEnv.HOME;
407
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
408
+ fs.rmSync(home, { recursive: true, force: true });
409
+ });
410
+
411
+ function configDir() {
412
+ return path.join(home, '.config', 'xp-gate');
413
+ }
414
+
415
+ /**
416
+ * Same realistic layout as the #714 block. Delegates to the file-scope helper:
417
+ * two copies of this body tripped the clone detector, and they had to stay
418
+ * byte-identical to remain correct.
419
+ */
420
+ function seedInstall() {
421
+ seedRealisticLayout(configDir());
422
+ }
423
+
424
+ function countFiles(dir) {
425
+ if (!fs.existsSync(dir)) return 0;
426
+ let n = 0;
427
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
428
+ const p = path.join(dir, entry.name);
429
+ n += entry.isDirectory() ? countFiles(p) : 1;
430
+ }
431
+ return n;
432
+ }
433
+
434
+ function snapshotDirs() {
435
+ const base = path.join(configDir(), '.uninstall-backup');
436
+ if (!fs.existsSync(base)) return [];
437
+ return fs.readdirSync(base, { withFileTypes: true })
438
+ .filter((e) => e.isDirectory() && e.name.startsWith('uninstall-'))
439
+ .map((e) => e.name);
440
+ }
441
+
442
+ /**
443
+ * @test REQ-724 Uninstall leaves no growing residue
444
+ * @intent A dependency tree has no rollback value and must not be copied into the snapshot
445
+ * @covers AC-724-01
446
+ */
447
+ it('AC-724-01: the rollback snapshot does not copy node_modules/ or modules/', async () => {
448
+ seedInstall();
449
+ const { uninstall } = freshRequire('../uninstall');
450
+ await uninstall(['--global', '--force']);
451
+
452
+ const sessions = snapshotDirs();
453
+ expect(sessions.length, 'one snapshot per uninstall run').toBe(1);
454
+ const snap = path.join(configDir(), '.uninstall-backup', sessions[0]);
455
+
456
+ // The config is worth restoring; the dependency tree is not. Copying it was
457
+ // what made an uninstall write ~1000 files / 12 MB and leave the deleted
458
+ // directory LARGER than it found it.
459
+ expect(fs.existsSync(path.join(snap, 'node_modules')), 'node_modules must not be snapshotted').toBe(false);
460
+ expect(fs.existsSync(path.join(snap, 'modules')), 'modules must not be snapshotted').toBe(false);
461
+ expect(fs.existsSync(path.join(snap, 'xp-gate.json')), 'the manifest IS worth restoring').toBe(true);
462
+ });
463
+
464
+ /**
465
+ * @test REQ-724 Uninstall leaves no growing residue
466
+ * @intent Repeated uninstalls must not accumulate unbounded snapshots
467
+ * @covers AC-724-02
468
+ */
469
+ it('AC-724-02: snapshots are pruned to a bounded number', async () => {
470
+ const { uninstall } = freshRequire('../uninstall');
471
+ // Five separate setups/uninstalls in the same HOME -- the shape of a user
472
+ // re-running install/uninstall while debugging.
473
+ for (let i = 0; i < 5; i += 1) {
474
+ seedInstall();
475
+ await uninstall(['--global', '--force']);
476
+ }
477
+
478
+ const sessions = snapshotDirs();
479
+ expect(sessions.length, 'snapshots must not accumulate without bound').toBeLessThanOrEqual(3);
480
+
481
+ // Retention keeps the NEWEST snapshots. Names are `uninstall-<epoch-ms>`, so
482
+ // the survivors must be the highest-numbered ones -- a prune that kept the
483
+ // oldest would discard the recovery point the user is most likely to want.
484
+ const stamps = sessions.map((n) => Number(n.replace('uninstall-', '')));
485
+ const newest = Math.max(...stamps);
486
+ expect(
487
+ stamps.includes(newest),
488
+ 'the most recent snapshot must survive pruning'
489
+ ).toBe(true);
490
+ // Five runs, three retained: the two oldest are the ones evicted.
491
+ expect(sessions.length).toBe(3);
492
+ });
493
+
494
+ /**
495
+ * @test REQ-724 Uninstall leaves no growing residue
496
+ * @intent Retained user data must be advertised as retained, not listed for removal
497
+ * @covers AC-724-03
498
+ */
499
+ it('AC-724-03: the preview puts .delphi-config.json under KEPT, not "will be removed"', async () => {
500
+ seedInstall();
501
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
502
+ let said;
503
+ try {
504
+ // The spy must be installed BEFORE freshRequire. Dropping the module cache
505
+ // re-runs uninstall.js, and the module binds its own `console` reference at
506
+ // load time -- a spy taken afterwards captures nothing, so every assertion
507
+ // below would pass vacuously against an empty string.
508
+ const { uninstall } = freshRequire('../uninstall');
509
+ await uninstall(['--global', '--force']);
510
+ said = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
511
+ } finally {
512
+ logSpy.mockRestore();
513
+ }
514
+
515
+ // #724: the preview announced it would be removed and then kept it, with no
516
+ // mention anywhere. Anyone scripting cleanup from that preview would delete
517
+ // a directory they had been told was already gone.
518
+ const keptHeader = said.indexOf('KEPT');
519
+ expect(keptHeader, 'the output must name a KEPT group').toBeGreaterThan(-1);
520
+
521
+ const removedSection = said.slice(0, keptHeader);
522
+ expect(removedSection).not.toContain('.delphi-config.json');
523
+ expect(said.slice(keptHeader)).toContain('.delphi-config.json');
524
+ // And it is restated in the summary, which is the last thing users read.
525
+ expect(said).toMatch(/Kept: .*delphi-config\.json/);
526
+ });
527
+
528
+ /**
529
+ * @test REQ-724 Uninstall leaves no growing residue
530
+ * @intent The snapshot must not be written where deleting the config dir destroys it
531
+ * @covers AC-724-04
532
+ */
533
+ it('AC-724-04: the snapshot lives inside the config dir it backs up (documented tradeoff)', async () => {
534
+ seedInstall();
535
+ const { uninstall } = freshRequire('../uninstall');
536
+ await uninstall(['--global', '--force']);
537
+
538
+ // Pins the CURRENT location so a future move to an out-of-tree path is a
539
+ // deliberate, tested change rather than a silent behaviour shift. The
540
+ // summary now prints the path, which is what makes it discoverable.
541
+ const sessions = snapshotDirs();
542
+ expect(sessions.length).toBe(1);
543
+ expect(path.join(configDir(), '.uninstall-backup', sessions[0])).toContain('.uninstall-backup');
544
+ });
545
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @test REQ-725
3
+ * @intent `xp-gate update-skill --check` treated the flag itself as a skill name
4
+ * and failed with "--check is not installed". #347 fixed exactly this for
5
+ * --all and the sibling flag was left broken, so the regression is pinned
6
+ * here at the CLI boundary -- where the bug was -- rather than at the
7
+ * parser, which was never wrong.
8
+ * @covers AC-725-03
9
+ */
10
+ const { spawnSync } = require('child_process');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const BIN = path.join(__dirname, '..', '..', 'bin', 'xp-gate.js');
16
+
17
+ /** Run the real CLI against a throwaway HOME so no real install is touched. */
18
+ function runCli(args, home) {
19
+ const env = { ...process.env, HOME: home, USERPROFILE: home };
20
+ return spawnSync(process.execPath, [BIN, ...args], {
21
+ encoding: 'utf8',
22
+ env,
23
+ timeout: 120000,
24
+ });
25
+ }
26
+
27
+ describe('update-skill flag parsing (#725)', () => {
28
+ let home;
29
+
30
+ beforeEach(() => {
31
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-725-cli-'));
32
+ // An empty but present config dir keeps the command on the update path
33
+ // instead of bailing out with "not installed" for the wrong reason.
34
+ fs.mkdirSync(path.join(home, '.config', 'xp-gate'), { recursive: true });
35
+ fs.writeFileSync(
36
+ path.join(home, '.config', 'xp-gate', 'xp-gate.json'),
37
+ JSON.stringify({ installedSkills: {} })
38
+ );
39
+ });
40
+
41
+ afterEach(() => {
42
+ fs.rmSync(home, { recursive: true, force: true });
43
+ });
44
+
45
+ /**
46
+ * @covers AC-725-03
47
+ */
48
+ it('AC-725-03: --check is not mistaken for a skill name', () => {
49
+ const result = runCli(['update-skill', '--check'], home);
50
+ const said = `${result.stdout}\n${result.stderr}`;
51
+
52
+ // The bug printed exactly this. Anything mentioning it means the flag went
53
+ // through the name slot again.
54
+ expect(said, '--check must not be read as a skill name').not.toMatch(/--check is not installed/i);
55
+ // It should reach check mode instead: a listing pass, exit 0.
56
+ expect(result.status).toBe(0);
57
+ });
58
+
59
+ /**
60
+ * @covers AC-725-03
61
+ */
62
+ it('AC-725-03: --all --check keeps working (the combination #347 fixed)', () => {
63
+ const result = runCli(['update-skill', '--all', '--check'], home);
64
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/--all is not installed/i);
65
+ expect(result.status).toBe(0);
66
+ });
67
+
68
+ /**
69
+ * @covers AC-725-03
70
+ */
71
+ it('AC-725-03: a bare name still routes to the single-skill path', () => {
72
+ // A name that is not installed must be rejected BY NAME -- proof the name
73
+ // slot still works after the flag extraction.
74
+ const result = runCli(['update-skill', 'definitely-not-a-skill'], home);
75
+ const said = `${result.stdout}\n${result.stderr}`;
76
+ expect(said).toMatch(/definitely-not-a-skill/);
77
+ });
78
+ });