@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
package/lib/install-skill.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { checkDeps } = require('./detect-deps.js');
|
|
3
|
+
const { checkDeps, detectInstalledPlatforms, getSkillsDirs } = require('./detect-deps.js');
|
|
4
4
|
const { rollback } = require('./rollback.js');
|
|
5
5
|
const { HOME_DIR, CONFIG_DIR, detectPlatform } = require('./shared-paths.js');
|
|
6
6
|
const { copyDirRecursive } = require('./shared-utils');
|
|
@@ -16,15 +16,53 @@ function getSkillsDir() {
|
|
|
16
16
|
return path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Every skills directory that belongs to an installed platform.
|
|
21
|
+
*
|
|
22
|
+
* #725 fixed the single-platform assumption, but the enumeration still hardcoded
|
|
23
|
+
* the three pre-dsh platforms (`.qoder`, `.config/opencode`, `.claude`) and so
|
|
24
|
+
* missed `~/.dsh/skills` -- the DSH platform `detectPlatform()` never returns and
|
|
25
|
+
* `PLATFORM_PROFILES` does carry. Derive the target set from the profiles instead
|
|
26
|
+
* of a parallel list: a machine with Qoder, OpenCode and DSH all present must
|
|
27
|
+
* have all three updated, and a migration period (two tools live) must not leave
|
|
28
|
+
* one silently frozen.
|
|
29
|
+
*
|
|
30
|
+
* A machine with no agent installed yet still needs one target, so fall back to
|
|
31
|
+
* the platform `detectPlatform()` selects and keep plain `install-skill` working.
|
|
32
|
+
*
|
|
33
|
+
* @returns {string[]} home-relative skills directories, de-duplicated
|
|
34
|
+
*/
|
|
35
|
+
function deployedSkillsDirs() {
|
|
36
|
+
const installed = detectInstalledPlatforms();
|
|
37
|
+
if (installed.length === 0) return [getSkillsDir()];
|
|
38
|
+
const dirs = installed.map((platform) => getSkillsDirs(platform)[0]);
|
|
39
|
+
return [...new Set(dirs)];
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
// The npm package ships the canonical skill content; `files` includes skills/.
|
|
20
43
|
const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
|
21
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The version of the CLI that is actually running.
|
|
47
|
+
*
|
|
48
|
+
* This used to read `../../../VERSION` relative to __dirname. Two things were
|
|
49
|
+
* wrong with that (#713): three levels up from `node_modules/@boyingliu01/
|
|
50
|
+
* xp-gate/lib` is `node_modules/`, and the package does not ship VERSION at all
|
|
51
|
+
* (`files` omits it). The catch therefore returned `0.0.0` on every installed
|
|
52
|
+
* machine, so `xp-gate 0.0.0 bundles: ...` -- printed as help when an unknown
|
|
53
|
+
* skill is requested -- read as a corrupt installation rather than as a typo.
|
|
54
|
+
*
|
|
55
|
+
* package.json travels with the package by definition, so it is the only source
|
|
56
|
+
* that is always present and always current.
|
|
57
|
+
*
|
|
58
|
+
* @returns {string} the package version, or 'unknown' if it cannot be read
|
|
59
|
+
*/
|
|
22
60
|
function getCliVersion() {
|
|
23
61
|
try {
|
|
24
|
-
const
|
|
25
|
-
return
|
|
62
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
63
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
|
|
26
64
|
} catch {
|
|
27
|
-
return '
|
|
65
|
+
return 'unknown';
|
|
28
66
|
}
|
|
29
67
|
}
|
|
30
68
|
|
|
@@ -72,26 +110,52 @@ async function installSkill(name, options = {}) {
|
|
|
72
110
|
return 1;
|
|
73
111
|
}
|
|
74
112
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
113
|
+
// #725: update EVERY platform that already carries this skill, not just the
|
|
114
|
+
// one `detectPlatform()` picks. A machine with both Qoder and OpenCode used to
|
|
115
|
+
// get one silently-frozen copy. Each target is installed independently so a
|
|
116
|
+
// failure on one platform does not abort the others -- a half-updated machine
|
|
117
|
+
// that reports success is exactly the bug being fixed.
|
|
118
|
+
//
|
|
119
|
+
// deployedSkillsDirs() already falls back to the detected platform when nothing
|
|
120
|
+
// is deployed anywhere, so it never returns an empty list.
|
|
121
|
+
const finalTargets = deployedSkillsDirs().map((dir) => path.join(dir, name));
|
|
122
|
+
|
|
123
|
+
// `lastError` alone was wrong: an unreadable platform (e.g. ~/.qoder/skills is
|
|
124
|
+
// a file, so readdir throws ENOTDIR) set it to 1 and nothing reset it when the
|
|
125
|
+
// other platform installed fine -- the CLI then reported failure for a machine
|
|
126
|
+
// that had in fact been updated. Track successes instead: the command failed
|
|
127
|
+
// only when EVERY target failed.
|
|
128
|
+
let succeeded = 0;
|
|
129
|
+
let lastError = 0;
|
|
130
|
+
for (const targetDir of finalTargets) {
|
|
131
|
+
const dupError = checkDuplicateInstall(targetDir, force);
|
|
132
|
+
if (dupError) {
|
|
133
|
+
console.error(dupError);
|
|
134
|
+
lastError = 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
81
137
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
138
|
+
// The platform segment keeps concurrent per-platform backups distinct; a bare
|
|
139
|
+
// `Date.now()` collided when both platforms were written in the same tick.
|
|
140
|
+
const platformTag = path.basename(path.dirname(targetDir));
|
|
141
|
+
const installId = `${name}-${Date.now()}-${platformTag}`;
|
|
142
|
+
const backupDir = path.join(CONFIG_DIR, 'backup', installId);
|
|
143
|
+
backupExisting(targetDir, installId, backupDir);
|
|
85
144
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
145
|
+
try {
|
|
146
|
+
if (verbose && finalTargets.length > 1) console.log(`Target: ${targetDir}`);
|
|
147
|
+
const result = await performInstall(name, targetDir, verbose);
|
|
148
|
+
if (result !== 0) lastError = result;
|
|
149
|
+
else succeeded += 1;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(`Error: Install failed - ${err.message}`);
|
|
152
|
+
await rollback(installId);
|
|
153
|
+
lastError = 1;
|
|
154
|
+
}
|
|
94
155
|
}
|
|
156
|
+
|
|
157
|
+
if (succeeded > 0) return 0;
|
|
158
|
+
return lastError || 1;
|
|
95
159
|
}
|
|
96
160
|
|
|
97
161
|
function checkDuplicateInstall(targetDir, force) {
|
package/lib/uninstall.js
CHANGED
|
@@ -27,15 +27,24 @@ function globalSkillRoots() {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Directories inside ~/.config/xp-gate/ that `setup-global` creates
|
|
31
|
-
* `uninstall` used to leave behind (#663).
|
|
30
|
+
* Directories and files inside ~/.config/xp-gate/ that `setup-global` creates
|
|
31
|
+
* and that `uninstall` used to leave behind (#663).
|
|
32
|
+
*
|
|
33
|
+
* `ownedByPath: true` marks entries the installer writes at a FIXED path under
|
|
34
|
+
* our own config directory -- there is no other way they got there, so a content
|
|
35
|
+
* signature is the wrong test for them (#714). It matters most for
|
|
36
|
+
* `package.json`: its body is nothing but dependency pins (`js-yaml`, `zod`), so
|
|
37
|
+
* no signature string can ever match it, and demanding one meant the file was
|
|
38
|
+
* scheduled for deletion and then skipped at delete time. `xp-gate.json` is
|
|
39
|
+
* equally ours -- the uninstaller itself writes `mode: "uninstalled"` into it
|
|
40
|
+
* moments earlier -- and was likewise reported as foreign.
|
|
32
41
|
*/
|
|
33
42
|
const GLOBAL_INSTALL_RESIDUE = [
|
|
34
|
-
{ key: 'modules', label: 'modules/' },
|
|
35
|
-
{ key: 'node_modules', label: 'node_modules/' },
|
|
36
|
-
{ key: 'package.json', label: 'package.json' },
|
|
37
|
-
{ key: 'package-lock.json', label: 'package-lock.json' },
|
|
38
|
-
{ key: 'xp-gate.json', label: 'xp-gate.json' },
|
|
43
|
+
{ key: 'modules', label: 'modules/', ownedByPath: true },
|
|
44
|
+
{ key: 'node_modules', label: 'node_modules/', ownedByPath: true },
|
|
45
|
+
{ key: 'package.json', label: 'package.json', ownedByPath: true },
|
|
46
|
+
{ key: 'package-lock.json', label: 'package-lock.json', ownedByPath: true },
|
|
47
|
+
{ key: 'xp-gate.json', label: 'xp-gate.json', ownedByPath: true },
|
|
39
48
|
];
|
|
40
49
|
|
|
41
50
|
/** User DATA that must survive an uninstall, and must be reported as retained. */
|
|
@@ -43,6 +52,27 @@ const GLOBAL_USER_DATA = [
|
|
|
43
52
|
{ key: '.delphi-config.json', label: '.delphi-config.json (your Delphi expert config)' },
|
|
44
53
|
];
|
|
45
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Directories under the config dir that the uninstall snapshot must NOT copy.
|
|
57
|
+
*
|
|
58
|
+
* #724: `createBackupSnapshot()` copied every plan entry, so an uninstall wrote a
|
|
59
|
+
* full second copy of `node_modules/` (~1000 files, ~12 MB) into
|
|
60
|
+
* `.uninstall-backup/`. That is the one thing here with no rollback value: it is
|
|
61
|
+
* a dependency tree npm regenerates from `package.json` in seconds, and it was
|
|
62
|
+
* the difference between a snapshot of a few config files and a 12 MB directory.
|
|
63
|
+
*/
|
|
64
|
+
const UNBACKUPABLE_DIRS = new Set(['node_modules', 'modules']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How many uninstall snapshots to keep.
|
|
68
|
+
*
|
|
69
|
+
* #724: the snapshot directory is named `uninstall-<timestamp>` and nothing ever
|
|
70
|
+
* pruned it, so every uninstall added another full copy and the directory only
|
|
71
|
+
* grew. Keeping the most recent few preserves the "I uninstalled by accident"
|
|
72
|
+
* recovery path without letting the directory become unbounded.
|
|
73
|
+
*/
|
|
74
|
+
const BACKUP_RETENTION = 3;
|
|
75
|
+
|
|
46
76
|
/**
|
|
47
77
|
* Signature strings used to verify file ownership.
|
|
48
78
|
* Each hook/adapter file contains a unique marker string.
|
|
@@ -140,6 +170,11 @@ function unsetHooksPath() {
|
|
|
140
170
|
|
|
141
171
|
/**
|
|
142
172
|
* Create a backup snapshot of files/dirs before deletion.
|
|
173
|
+
*
|
|
174
|
+
* #724: two scoping rules keep this proportionate. `node_modules/` and
|
|
175
|
+
* `modules/` are skipped outright -- they are regenerable from package.json, and
|
|
176
|
+
* copying them made the snapshot ~1000 files / 12 MB. Everything else is copied
|
|
177
|
+
* as before, so the recovery path for real config is unchanged.
|
|
143
178
|
*/
|
|
144
179
|
function createBackupSnapshot(plan) {
|
|
145
180
|
// Use a unique backup dir for this uninstall session
|
|
@@ -150,6 +185,9 @@ function createBackupSnapshot(plan) {
|
|
|
150
185
|
|
|
151
186
|
for (const item of plan) {
|
|
152
187
|
if (!item.path || !fs.existsSync(item.path)) continue;
|
|
188
|
+
// Retained user data is not being deleted, so there is nothing to roll back.
|
|
189
|
+
if (item.retain === true) continue;
|
|
190
|
+
if (UNBACKUPABLE_DIRS.has(path.basename(item.path))) continue;
|
|
153
191
|
|
|
154
192
|
const relPath = path.basename(item.path);
|
|
155
193
|
const destPath = path.join(sessionBackup, relPath);
|
|
@@ -167,9 +205,49 @@ function createBackupSnapshot(plan) {
|
|
|
167
205
|
}
|
|
168
206
|
}
|
|
169
207
|
|
|
208
|
+
pruneOldBackups(sessionBackup);
|
|
209
|
+
|
|
170
210
|
return backupMeta;
|
|
171
211
|
}
|
|
172
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Keep only the newest BACKUP_RETENTION uninstall snapshots (#724).
|
|
215
|
+
*
|
|
216
|
+
* Snapshots are named `uninstall-<epoch-ms>`, so a plain reverse-sort of the
|
|
217
|
+
* directory names is a correct newest-first order without reading any metadata.
|
|
218
|
+
* An empty snapshot (every entry was skipped) is removed rather than counted
|
|
219
|
+
* toward the retention budget -- otherwise a run that backed up nothing would
|
|
220
|
+
* evict a snapshot that actually held config.
|
|
221
|
+
*/
|
|
222
|
+
function pruneOldBackups(sessionBackup) {
|
|
223
|
+
try {
|
|
224
|
+
if (isDirEmpty(sessionBackup)) {
|
|
225
|
+
fs.rmdirSync(sessionBackup);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const sessions = fs
|
|
229
|
+
.readdirSync(BACKUP_DIR, { withFileTypes: true })
|
|
230
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('uninstall-'))
|
|
231
|
+
.map((e) => e.name)
|
|
232
|
+
.sort()
|
|
233
|
+
.reverse();
|
|
234
|
+
for (const stale of sessions.slice(BACKUP_RETENTION)) {
|
|
235
|
+
fs.rmSync(path.join(BACKUP_DIR, stale), { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Pruning is housekeeping. Failing it must never fail the uninstall, which
|
|
239
|
+
// has already done the work the user asked for.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isDirEmpty(dir) {
|
|
244
|
+
try {
|
|
245
|
+
return fs.readdirSync(dir).length === 0;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
173
251
|
function copyDirSync(src, dest) {
|
|
174
252
|
fs.mkdirSync(dest, { recursive: true });
|
|
175
253
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
@@ -325,7 +403,12 @@ function buildPlan(mode) {
|
|
|
325
403
|
type: isDir ? 'dir' : 'file',
|
|
326
404
|
path: target,
|
|
327
405
|
label: `~/.config/xp-gate/${item.label}`,
|
|
328
|
-
critical: false
|
|
406
|
+
critical: false,
|
|
407
|
+
// #714: a fixed-path file we install ourselves needs no content
|
|
408
|
+
// signature. Carrying the flag through the plan is what makes the
|
|
409
|
+
// delete actually happen instead of stopping at "does not contain
|
|
410
|
+
// xp-gate signature".
|
|
411
|
+
ownedByPath: item.ownedByPath === true
|
|
329
412
|
});
|
|
330
413
|
}
|
|
331
414
|
}
|
|
@@ -365,6 +448,20 @@ function buildPlan(mode) {
|
|
|
365
448
|
* @returns {boolean} true on success, false on skip (not xp-gate owned)
|
|
366
449
|
*/
|
|
367
450
|
function removeFile(item, config) {
|
|
451
|
+
// #714: `ownedByPath` entries are files the installer writes at a fixed path
|
|
452
|
+
// inside our own ~/.config/xp-gate/ directory, so ownership is established by
|
|
453
|
+
// the path itself. Without this branch the residue files fell through to
|
|
454
|
+
// verifyFileOwnership() with neither a manifest entry nor a signature, and
|
|
455
|
+
// isXpGateFile() evaluated `content.includes(undefined)` -- always false. The
|
|
456
|
+
// file was listed in the plan, "uninstall complete" was printed, and the file
|
|
457
|
+
// stayed.
|
|
458
|
+
if (item.ownedByPath === true) {
|
|
459
|
+
if (!fs.existsSync(item.path)) return false;
|
|
460
|
+
fs.unlinkSync(item.path);
|
|
461
|
+
console.log(` Removed ${item.label}`);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
368
465
|
const manifestEntry = config.manifest && config.manifest.files
|
|
369
466
|
? config.manifest.files[item.manifestKey]
|
|
370
467
|
: null;
|
|
@@ -533,11 +630,27 @@ async function uninstall(args) {
|
|
|
533
630
|
// §4.3 Step 4: Print plan
|
|
534
631
|
console.log(`XP-Gate Uninstall (${mode} mode)`);
|
|
535
632
|
console.log('=======================\n');
|
|
536
|
-
console.log('The following will be removed:\n');
|
|
537
633
|
|
|
538
|
-
|
|
634
|
+
// #724: 'retain' entries used to be printed under "The following will be
|
|
635
|
+
// removed" and then kept, with no further mention anywhere in the output.
|
|
636
|
+
// A user scripting cleanup from this preview would delete a directory they
|
|
637
|
+
// had been told was gone. The two groups are now stated separately, and the
|
|
638
|
+
// retained list is echoed again at the end.
|
|
639
|
+
const removable = plan.filter((item) => item.retain !== true);
|
|
640
|
+
const retained = plan.filter((item) => item.retain === true);
|
|
641
|
+
|
|
642
|
+
console.log('The following will be removed:\n');
|
|
643
|
+
for (const item of removable) {
|
|
539
644
|
console.log(` • ${item.label}`);
|
|
540
645
|
}
|
|
646
|
+
|
|
647
|
+
if (retained.length > 0) {
|
|
648
|
+
console.log('\nThe following will be KEPT (your data):\n');
|
|
649
|
+
for (const item of retained) {
|
|
650
|
+
console.log(` • ${item.label}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
541
654
|
if (options.purge) {
|
|
542
655
|
console.log('\nPurge mode — additional cleanup:');
|
|
543
656
|
for (const d of purgeDirs) {
|
|
@@ -614,6 +727,15 @@ async function uninstall(args) {
|
|
|
614
727
|
console.log('Note: Qoder Delphi expert agents (.qoder/agents/delphi-*.md) are user-owned files');
|
|
615
728
|
console.log(' that init never overwrites, so they were left in place — delete them by hand');
|
|
616
729
|
console.log(' (then restart the Qoder session) if you want them gone.');
|
|
730
|
+
// #724: restate the retained data. The preview said it once, but the last thing
|
|
731
|
+
// a user reads before closing the terminal is the summary -- and an uninstall
|
|
732
|
+
// that leaves files behind owes them an explicit accounting of what it kept.
|
|
733
|
+
for (const item of retained) {
|
|
734
|
+
console.log(`Kept: ${item.label}`);
|
|
735
|
+
}
|
|
736
|
+
if (backupMeta && backupMeta.entries.length > 0) {
|
|
737
|
+
console.log(`Rollback snapshot: ${backupMeta.sessionBackup}`);
|
|
738
|
+
}
|
|
617
739
|
if (hadErrors) {
|
|
618
740
|
console.log('Some items could not be removed. Run doctor for diagnostics.');
|
|
619
741
|
}
|
|
@@ -740,8 +862,17 @@ function planHooksDirRemoval(hooksDir) {
|
|
|
740
862
|
: { action: 'remove-all', foreign: [], owned };
|
|
741
863
|
}
|
|
742
864
|
|
|
743
|
-
/**
|
|
744
|
-
|
|
865
|
+
/**
|
|
866
|
+
* Hook entry points xp-gate installs.
|
|
867
|
+
*
|
|
868
|
+
* This must stay in sync with `copyHooks()`'s `hookFiles` list, which is what
|
|
869
|
+
* actually puts them on disk. `post-commit` was missing here (#714), so the
|
|
870
|
+
* uninstaller classified its own hook as a third party's and kept it -- the
|
|
871
|
+
* file contains nine `xp-gate` references and is installed unconditionally by
|
|
872
|
+
* setup-global. The set is derived from the installer's list rather than
|
|
873
|
+
* retyped, so the two cannot drift apart again.
|
|
874
|
+
*/
|
|
875
|
+
const OWNED_HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge', 'post-commit'];
|
|
745
876
|
|
|
746
877
|
/** 'git-lfs' when the file proves it, else null. Content-based: see doctor.js. */
|
|
747
878
|
function foreignHookSource(filePath) {
|
package/lib/update-skill.js
CHANGED
|
@@ -7,6 +7,32 @@ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
|
7
7
|
|
|
8
8
|
const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The skill names this package ships.
|
|
12
|
+
*
|
|
13
|
+
* `skills/` is the bundle contract: a skill the installed package does not carry
|
|
14
|
+
* is not ours to touch. Resolved lazily so a caller that stubs out the package
|
|
15
|
+
* layout still gets a sensible empty set instead of a throw at require() time.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Set<string>} bundled skill directory names
|
|
18
|
+
*/
|
|
19
|
+
function bundledSkillNames() {
|
|
20
|
+
const dir = path.join(__dirname, '..', 'skills');
|
|
21
|
+
try {
|
|
22
|
+
return new Set(
|
|
23
|
+
fs
|
|
24
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
25
|
+
.filter(
|
|
26
|
+
(entry) =>
|
|
27
|
+
entry.isDirectory() && fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))
|
|
28
|
+
)
|
|
29
|
+
.map((entry) => entry.name)
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Set();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* The skill names that are actually deployed on this machine.
|
|
12
38
|
*
|
|
@@ -16,59 +42,244 @@ const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
|
16
42
|
* "Updating all skills..." and exit 0 having touched nothing (#682) -- the
|
|
17
43
|
* same defect #635 fixed for `doctor`, with the same fix: ask the filesystem.
|
|
18
44
|
*
|
|
45
|
+
* Enumerating the filesystem alone is too wide, though (#713). A skill
|
|
46
|
+
* directory is a shared namespace -- every skill tool uses the same
|
|
47
|
+
* `<dir>/<name>/SKILL.md` layout -- so on a machine that also carries other
|
|
48
|
+
* bundles, `--all` walked all of them, printed `Error: Unknown skill: <name>`
|
|
49
|
+
* for each, and advised `npm install -g xp-gate@latest` to obtain a skill this
|
|
50
|
+
* package does not and will never ship. Scanned names are therefore filtered
|
|
51
|
+
* against the bundle: the candidate set is the intersection, not the union.
|
|
52
|
+
*
|
|
19
53
|
* The explicit registry is still unioned in, so a skill that is merely
|
|
20
54
|
* registered (or was installed on a platform this scan cannot see) keeps
|
|
21
|
-
* working for callers that rely on it
|
|
55
|
+
* working for callers that rely on it -- but only when the package really
|
|
56
|
+
* carries it, for the same reason as above.
|
|
22
57
|
*
|
|
23
58
|
* @returns {string[]} de-duplicated skill names, in a stable order
|
|
24
59
|
*/
|
|
25
60
|
function installedSkillNames() {
|
|
61
|
+
const bundled = bundledSkillNames();
|
|
26
62
|
const names = new Set();
|
|
27
|
-
const config = getConfig();
|
|
28
|
-
for (const name of Object.keys(config.installedSkills || {})) names.add(name);
|
|
29
63
|
|
|
30
|
-
|
|
64
|
+
// Registry first: a skill can be registered on a platform this scan cannot see.
|
|
65
|
+
for (const name of Object.keys(getConfig().installedSkills || {})) {
|
|
66
|
+
if (bundled.has(name)) names.add(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const name of bundledNamesOnDisk(bundled)) names.add(name);
|
|
70
|
+
return [...names].sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bundled skill names that are actually deployed on this machine.
|
|
75
|
+
*
|
|
76
|
+
* Split out of installedSkillNames() because the per-platform probing is three
|
|
77
|
+
* nested try/catch levels deep on its own: reading a directory that may not
|
|
78
|
+
* exist, on a platform whose detection may itself throw. Flattening it into a
|
|
79
|
+
* named loop keeps each failure mode visible instead of piling onto one
|
|
80
|
+
* function's complexity budget.
|
|
81
|
+
*
|
|
82
|
+
* Every unreadable location is skipped silently -- an absent platform is the
|
|
83
|
+
* normal case on any given machine, not an error worth reporting mid-update.
|
|
84
|
+
*
|
|
85
|
+
* @param {Set<string>} bundled names the package ships; anything else is not ours
|
|
86
|
+
* @returns {string[]} deployed-and-bundled skill names
|
|
87
|
+
*/
|
|
88
|
+
function bundledNamesOnDisk(bundled) {
|
|
89
|
+
const found = [];
|
|
90
|
+
let detect;
|
|
91
|
+
let platforms;
|
|
31
92
|
try {
|
|
32
|
-
|
|
93
|
+
detect = require('./detect-deps.js');
|
|
94
|
+
platforms = Object.keys(detect.PLATFORM_PROFILES);
|
|
33
95
|
} catch {
|
|
34
|
-
// detect-deps unavailable:
|
|
35
|
-
//
|
|
36
|
-
|
|
96
|
+
// detect-deps unavailable: the registry half of installedSkillNames() still
|
|
97
|
+
// applies, so returning nothing here does not report "no skills installed".
|
|
98
|
+
return found;
|
|
37
99
|
}
|
|
38
|
-
|
|
100
|
+
|
|
39
101
|
for (const platform of platforms) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
dir
|
|
43
|
-
} catch {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
let entries;
|
|
47
|
-
try {
|
|
48
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
49
|
-
} catch {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
for (const entry of entries) {
|
|
53
|
-
if (!entry.isDirectory()) continue;
|
|
54
|
-
// A skill is `<dir>/<name>/SKILL.md` — the shape install-skill writes.
|
|
55
|
-
if (fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))) names.add(entry.name);
|
|
102
|
+
for (const entry of readSkillsDirEntries(detect, platform)) {
|
|
103
|
+
if (!entry.isDirectory || !bundled.has(entry.name)) continue;
|
|
104
|
+
if (fs.existsSync(path.join(entry.dir, entry.name, 'SKILL.md'))) found.push(entry.name);
|
|
56
105
|
}
|
|
57
106
|
}
|
|
58
|
-
return
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read one platform's skills directory as plain records, so callers get the
|
|
112
|
+
* directory back without depending on dirent internals.
|
|
113
|
+
* @returns {Array<{name: string, isDirectory: boolean, dir: string}>} empty when unreadable
|
|
114
|
+
*/
|
|
115
|
+
function readSkillsDirEntries(detect, platform) {
|
|
116
|
+
let dir;
|
|
117
|
+
try {
|
|
118
|
+
dir = detect.getSkillsDirs(platform)[0];
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
return entries.map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), dir }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The version of the CLI that is running, for comparing against what is on disk.
|
|
133
|
+
*
|
|
134
|
+
* Same source as install-skill.js reads (#713): the installed package.json, not
|
|
135
|
+
* the repo-root VERSION file, which the published tarball does not ship.
|
|
136
|
+
*
|
|
137
|
+
* @returns {string} semver, or 'unknown' when the package metadata is unreadable
|
|
138
|
+
*/
|
|
139
|
+
function getCliVersion() {
|
|
140
|
+
try {
|
|
141
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
142
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
|
|
143
|
+
} catch {
|
|
144
|
+
return 'unknown';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compare a 4-part release stamp against a 3-part npm semver.
|
|
150
|
+
*
|
|
151
|
+
* A trailing `.0` is the only difference between the two schemes, so trimming it
|
|
152
|
+
* is enough -- this is deliberately not a full semver comparison, which would
|
|
153
|
+
* imply support for pre-release ordering that neither source produces.
|
|
154
|
+
*
|
|
155
|
+
* The trailing segment is dropped, not "every trailing zero": a naive
|
|
156
|
+
* `.replace(/.0+$/, '')` also ate the dot of the segment before it, so
|
|
157
|
+
* `0.36.0.0` normalized to `0.36.0` while `0.36.0` normalized to `0.36` and a
|
|
158
|
+
* current copy was reported as behind. That stayed hidden while releases ended
|
|
159
|
+
* in a non-zero micro digit (`0.35.24.0` vs `0.35.24` agree either way) and
|
|
160
|
+
* broke the first time the micro digit was 0.
|
|
161
|
+
*
|
|
162
|
+
* @returns {boolean} true when both name the same release
|
|
163
|
+
*/
|
|
164
|
+
function sameVersion(a, b) {
|
|
165
|
+
// A 4-part stamp whose micro digit is 0 names the same release as its 3-part
|
|
166
|
+
// form; anything else is compared as-is so a real difference is never hidden.
|
|
167
|
+
const norm = (v) => {
|
|
168
|
+
const parts = String(v).split('.');
|
|
169
|
+
return parts.length === 4 && parts[3] === '0' ? parts.slice(0, 3).join('.') : String(v);
|
|
170
|
+
};
|
|
171
|
+
return norm(a) === norm(b);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether a deployed copy is byte-identical to the bundle this CLI ships.
|
|
176
|
+
*
|
|
177
|
+
* Most skills carry no AGENTS.md, so their only version marker is `unknown`. That
|
|
178
|
+
* is not evidence of staleness -- treating it as such flagged every skill on a
|
|
179
|
+
* healthy machine. Content comparison answers the question the version stamp was
|
|
180
|
+
* standing in for, and answers it for those skills too.
|
|
181
|
+
*
|
|
182
|
+
* Only SKILL.md is compared: it is the file every skill has, and it is the one
|
|
183
|
+
* `update-skill` rewrites. Anything else a user added locally must not make their
|
|
184
|
+
* copy look outdated.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} skillName
|
|
187
|
+
* @param {{dir: string}} entry a deployed copy
|
|
188
|
+
* @returns {boolean} true when the copy already matches the bundle
|
|
189
|
+
*/
|
|
190
|
+
function copyMatchesBundle(skillName, entry) {
|
|
191
|
+
const bundled = path.join(__dirname, '..', 'skills', skillName, 'SKILL.md');
|
|
192
|
+
const installed = path.join(entry.dir, 'SKILL.md');
|
|
193
|
+
try {
|
|
194
|
+
return fs.readFileSync(installed, 'utf8') === fs.readFileSync(bundled, 'utf8');
|
|
195
|
+
} catch {
|
|
196
|
+
// Unreadable either way: fall back to the version stamp's verdict rather than
|
|
197
|
+
// asserting a match we could not verify.
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
59
200
|
}
|
|
60
201
|
|
|
61
202
|
function handleCheckMode() {
|
|
62
203
|
console.log('Checking for updates...');
|
|
63
204
|
const config = getConfig();
|
|
64
205
|
const skills = config.installedSkills || {};
|
|
206
|
+
|
|
207
|
+
// #725: this used to print config.installedSkills[name].version and call it a
|
|
208
|
+
// day. That value is what the last install WROTE DOWN, not what is on disk, so
|
|
209
|
+
// a copy left behind by a single-platform update reported the new version while
|
|
210
|
+
// its files still said the old one. Reporting the registry as if it were the
|
|
211
|
+
// installation is how a stale skill looked healthy.
|
|
212
|
+
const bundledVersion = getCliVersion();
|
|
213
|
+
let stale = 0;
|
|
214
|
+
|
|
65
215
|
for (const [skillName, info] of Object.entries(skills)) {
|
|
66
|
-
|
|
216
|
+
const registry = info.version || 'unknown';
|
|
217
|
+
const onDisk = deployedSkillVersions(skillName);
|
|
218
|
+
if (onDisk.length === 0) {
|
|
219
|
+
console.log(` ${skillName}: ${registry} (registry only - no copy found on disk)`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// AGENTS.md stamps the 4-part release version (0.35.24.0) while package.json
|
|
223
|
+
// carries the 3-part npm semver (0.35.24). Comparing them raw marked every
|
|
224
|
+
// copy on a healthy machine as behind -- a false alarm is as useless as no
|
|
225
|
+
// check at all, so normalize before comparing.
|
|
226
|
+
// A copy is behind when its version stamp says so. Only a copy with NO stamp
|
|
227
|
+
// falls back to content comparison -- a known-old stamp must not be excused by
|
|
228
|
+
// an identical SKILL.md, which is exactly the delphi-review case: the two
|
|
229
|
+
// platform copies differ only in the generated AGENTS.md header.
|
|
230
|
+
const behind = onDisk.filter((entry) => (entry.version === 'unknown'
|
|
231
|
+
? !copyMatchesBundle(skillName, entry)
|
|
232
|
+
: !sameVersion(entry.version, bundledVersion)));
|
|
233
|
+
if (behind.length > 0) {
|
|
234
|
+
stale += behind.length;
|
|
235
|
+
for (const entry of behind) {
|
|
236
|
+
console.log(` ${skillName} [${entry.platform}]: ${entry.version} -> ${bundledVersion} available`);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
console.log(` ${skillName}: ${bundledVersion} (up to date on ${onDisk.length} platform(s))`);
|
|
240
|
+
}
|
|
67
241
|
}
|
|
68
|
-
|
|
242
|
+
|
|
243
|
+
console.log(stale > 0 ? `Update check complete — ${stale} copy(ies) behind.` : 'Update check complete');
|
|
69
244
|
return 0;
|
|
70
245
|
}
|
|
71
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Versions actually written into each platform's copy of \`skillName\`.
|
|
249
|
+
*
|
|
250
|
+
* Reads the \`**Version:**\` stamp from the generated AGENTS.md, which is the only
|
|
251
|
+
* per-copy version marker an installed skill carries. A copy without one (older
|
|
252
|
+
* layouts, or a hand-written skill) reports \`unknown\` rather than being skipped --
|
|
253
|
+
* silently omitting it would hide exactly the stale copy this check exists for.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} skillName
|
|
256
|
+
* @returns {Array<{platform: string, version: string, dir: string}>} one entry per deployed copy
|
|
257
|
+
*/
|
|
258
|
+
function deployedSkillVersions(skillName) {
|
|
259
|
+
let detect;
|
|
260
|
+
try {
|
|
261
|
+
detect = require('./detect-deps.js');
|
|
262
|
+
} catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
const found = [];
|
|
266
|
+
for (const platform of Object.keys(detect.PLATFORM_PROFILES)) {
|
|
267
|
+
for (const entry of readSkillsDirEntries(detect, platform)) {
|
|
268
|
+
if (!entry.isDirectory || entry.name !== skillName) continue;
|
|
269
|
+
const agentsFile = path.join(entry.dir, entry.name, 'AGENTS.md');
|
|
270
|
+
let version = 'unknown';
|
|
271
|
+
try {
|
|
272
|
+
const match = fs.readFileSync(agentsFile, 'utf8').match(/^\*\*Version:\*\*\s*(\S+)/m);
|
|
273
|
+
if (match) version = match[1];
|
|
274
|
+
} catch {
|
|
275
|
+
// No AGENTS.md: still count the copy, with an unknown stamp.
|
|
276
|
+
}
|
|
277
|
+
found.push({ platform, version, dir: path.join(entry.dir, entry.name) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return found;
|
|
281
|
+
}
|
|
282
|
+
|
|
72
283
|
async function handleAllMode(verbose) {
|
|
73
284
|
const names = installedSkillNames();
|
|
74
285
|
console.log('Updating all skills...');
|