@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.
- package/adapter-common.sh +78 -36
- package/adapters/csharp.sh +245 -0
- package/adapters/gate-3.sh +1 -1
- package/adapters/gate-4.sh +1 -1
- package/adapters/gate-9.sh +37 -2
- package/adapters/go.sh +6 -11
- package/adapters/iac.sh +45 -5
- package/adapters/python.sh +6 -11
- package/bin/xp-gate.js +18 -11
- package/gate-10.sh +18 -3
- package/gate-13-compiler-strictness.sh +181 -0
- package/gate-3.sh +1 -1
- package/gate-4.sh +1 -1
- package/gate-9.sh +37 -2
- package/gates/common.ts +9 -2
- package/hooks/adapter-common.sh +78 -36
- package/hooks/gate-10.sh +18 -3
- package/hooks/gate-13-compiler-strictness.sh +181 -0
- package/hooks/gate-3.sh +1 -1
- package/hooks/gate-4.sh +1 -1
- package/hooks/gate-9.sh +37 -2
- package/hooks/gate-registry.sh +161 -0
- package/hooks/lib/test-failure.sh +29 -4
- package/hooks/pre-commit +122 -3
- package/lib/__tests__/agent-runtime-journal.test.js +42 -18
- package/lib/__tests__/check-single-file.test.js +55 -4
- package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
- package/lib/__tests__/doctor-delphi.test.js +178 -3
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
- package/lib/__tests__/install-skill-full-dir.test.js +45 -2
- package/lib/__tests__/install-skill.test.js +191 -23
- package/lib/__tests__/mirror-sync.test.js +23 -5
- package/lib/__tests__/uninstall-global-residue.test.js +394 -9
- package/lib/__tests__/update-skill-flags.test.js +78 -0
- package/lib/__tests__/update-skill.test.js +334 -57
- package/lib/detect-deps.js +24 -0
- package/lib/doctor.js +80 -12
- package/lib/install-skill.js +85 -21
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/uninstall.js +143 -12
- package/lib/update-skill.js +239 -28
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +7 -4
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/index.test.ts +25 -0
- package/plugins/dsh/src/index.ts +7 -4
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +4 -4
- package/principles/adapters/csharp.ts +309 -0
- package/principles/analyzer.ts +5 -0
- package/scripts/delphi-external-review.cjs +130 -101
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/sprint-flow/AGENTS.md +4 -4
- 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
|
+
});
|