skillspub 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/catalog.js +352 -0
- package/dist/cli.js +1562 -0
- package/dist/core.js +31 -0
- package/dist/explain.js +307 -0
- package/dist/harnesses/claude.js +95 -0
- package/dist/harnesses/grok.js +746 -0
- package/dist/harnesses/pi.js +1380 -0
- package/dist/harnesses/registry.js +30 -0
- package/dist/harnesses/target.js +5 -0
- package/dist/harnesses/types.js +1 -0
- package/dist/inventory.js +1437 -0
- package/dist/npx-skills.js +273 -0
- package/dist/reconcile.js +1014 -0
- package/dist/shared.js +1411 -0
- package/dist/source-verification.js +297 -0
- package/dist/targets/shared.js +13 -0
- package/dist/tui.js +2373 -0
- package/dist/view.js +321 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1437 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { harnessAdapters } from "./harnesses/registry.js";
|
|
6
|
+
import { readNpxSkillsLock, } from "./npx-skills.js";
|
|
7
|
+
import { sharedTargetDefinition } from "./targets/shared.js";
|
|
8
|
+
function expandHome(value) {
|
|
9
|
+
return value.startsWith('~') ? path.join(os.homedir(), value.slice(1)) : value;
|
|
10
|
+
}
|
|
11
|
+
export function defaultTargetDefinitions() {
|
|
12
|
+
const [first, ...rest] = harnessAdapters().map((adapter) => adapter.targetDefinition());
|
|
13
|
+
return first ? [first, sharedTargetDefinition(), ...rest] : [sharedTargetDefinition()];
|
|
14
|
+
}
|
|
15
|
+
function targetFile(home) {
|
|
16
|
+
return path.join(home.configDir, 'targets.json');
|
|
17
|
+
}
|
|
18
|
+
function runtimeFile(home) {
|
|
19
|
+
return path.join(home.configDir, 'runtimes.json');
|
|
20
|
+
}
|
|
21
|
+
function isTargetKey(value) {
|
|
22
|
+
return Boolean(value) && value !== '.' && value !== '..' &&
|
|
23
|
+
!value.includes('/') && !value.includes('\\');
|
|
24
|
+
}
|
|
25
|
+
function resolveTarget(target, file) {
|
|
26
|
+
if (typeof target.key !== 'string' || !isTargetKey(target.key) ||
|
|
27
|
+
!['harness', 'shared', 'generic'].includes(target.kind) ||
|
|
28
|
+
typeof target.discoveryRoot !== 'string' || !target.discoveryRoot ||
|
|
29
|
+
typeof target.parkingRoot !== 'string' || !target.parkingRoot ||
|
|
30
|
+
typeof target.projectPath !== 'string' || path.isAbsolute(target.projectPath) ||
|
|
31
|
+
target.projectPath.split(path.sep).includes('..') ||
|
|
32
|
+
(target.lockFile !== undefined && typeof target.lockFile !== 'string'))
|
|
33
|
+
throw new Error(`invalid Skill Target entry in ${file}`);
|
|
34
|
+
const discoveryRoot = expandHome(target.discoveryRoot);
|
|
35
|
+
const parkingRoot = expandHome(target.parkingRoot);
|
|
36
|
+
const relative = path.relative(discoveryRoot, parkingRoot);
|
|
37
|
+
if (!relative || (!relative.startsWith('..') && !path.isAbsolute(relative)))
|
|
38
|
+
throw new Error(`parking root must be outside discovery root for Skill Target ${target.key}`);
|
|
39
|
+
return {
|
|
40
|
+
...target,
|
|
41
|
+
discoveryRoot,
|
|
42
|
+
parkingRoot,
|
|
43
|
+
lockFile: target.lockFile
|
|
44
|
+
? expandHome(target.lockFile)
|
|
45
|
+
: target.kind === 'shared'
|
|
46
|
+
? path.join(path.dirname(discoveryRoot), '.skill-lock.json')
|
|
47
|
+
: undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function assertUniqueTargets(targets, _file) {
|
|
51
|
+
const keys = new Set();
|
|
52
|
+
const roots = new Set();
|
|
53
|
+
for (const target of targets) {
|
|
54
|
+
if (keys.has(target.key))
|
|
55
|
+
throw new Error(`duplicate Skill Target key: ${target.key}`);
|
|
56
|
+
keys.add(target.key);
|
|
57
|
+
const root = rootIdentity(target.discoveryRoot);
|
|
58
|
+
if (roots.has(root))
|
|
59
|
+
throw new Error(`ambiguous Skill Target discovery root: ${target.discoveryRoot}`);
|
|
60
|
+
roots.add(root);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function targetsFromRegistry(registry, file) {
|
|
64
|
+
const definitions = defaultTargetDefinitions();
|
|
65
|
+
const known = new Map(definitions.map((definition) => [definition.key, definition]));
|
|
66
|
+
const overrides = new Map();
|
|
67
|
+
for (const override of registry.overrides) {
|
|
68
|
+
if (!known.has(override.key))
|
|
69
|
+
throw new Error(`unknown Target Definition override: ${override.key}`);
|
|
70
|
+
if (overrides.has(override.key))
|
|
71
|
+
throw new Error(`duplicate Target Definition override: ${override.key}`);
|
|
72
|
+
overrides.set(override.key, override);
|
|
73
|
+
}
|
|
74
|
+
const targets = [];
|
|
75
|
+
for (const definition of definitions) {
|
|
76
|
+
const override = overrides.get(definition.key);
|
|
77
|
+
if (override?.disabled)
|
|
78
|
+
continue;
|
|
79
|
+
const { disabled: _, ...fields } = override ?? {};
|
|
80
|
+
targets.push(resolveTarget({ ...definition, ...fields }, file));
|
|
81
|
+
}
|
|
82
|
+
const generics = registry.genericTargets.map((target) => resolveTarget(target, file));
|
|
83
|
+
if (generics.some((target) => target.kind !== 'generic'))
|
|
84
|
+
throw new Error(`invalid Generic Target entry in ${file}`);
|
|
85
|
+
assertUniqueTargets([...targets, ...generics], file);
|
|
86
|
+
return [...targets, ...generics];
|
|
87
|
+
}
|
|
88
|
+
function readTargetRegistry(file) {
|
|
89
|
+
if (!fs.existsSync(file))
|
|
90
|
+
return undefined;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new Error(`cannot read Target registry ${file}: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.overrides) || !Array.isArray(parsed.genericTargets))
|
|
99
|
+
throw new Error(`invalid Target registry: ${file}`);
|
|
100
|
+
const overrides = parsed.overrides.map((override) => {
|
|
101
|
+
if (!isRecord(override) || typeof override.key !== 'string')
|
|
102
|
+
throw new Error(`invalid Target Definition override in ${file}`);
|
|
103
|
+
const result = { key: override.key };
|
|
104
|
+
if (override.disabled !== undefined) {
|
|
105
|
+
if (override.disabled !== true)
|
|
106
|
+
throw new Error(`invalid Target Definition override in ${file}`);
|
|
107
|
+
result.disabled = true;
|
|
108
|
+
}
|
|
109
|
+
for (const field of ['discoveryRoot', 'parkingRoot', 'projectPath', 'lockFile']) {
|
|
110
|
+
if (override[field] === undefined)
|
|
111
|
+
continue;
|
|
112
|
+
if (typeof override[field] !== 'string')
|
|
113
|
+
throw new Error(`invalid Target Definition override in ${file}`);
|
|
114
|
+
result[field] = override[field];
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(result).length === 1)
|
|
117
|
+
throw new Error(`empty Target Definition override in ${file}`);
|
|
118
|
+
return result;
|
|
119
|
+
});
|
|
120
|
+
const genericTargets = parsed.genericTargets.map((target) => {
|
|
121
|
+
if (!isRecord(target) || target.kind !== 'generic' || typeof target.key !== 'string' ||
|
|
122
|
+
typeof target.discoveryRoot !== 'string' || typeof target.parkingRoot !== 'string' ||
|
|
123
|
+
typeof target.projectPath !== 'string' ||
|
|
124
|
+
(target.lockFile !== undefined && typeof target.lockFile !== 'string'))
|
|
125
|
+
throw new Error(`invalid Generic Target entry in ${file}`);
|
|
126
|
+
return {
|
|
127
|
+
key: target.key,
|
|
128
|
+
kind: 'generic',
|
|
129
|
+
discoveryRoot: target.discoveryRoot,
|
|
130
|
+
parkingRoot: target.parkingRoot,
|
|
131
|
+
projectPath: target.projectPath,
|
|
132
|
+
...(typeof target.lockFile === 'string' ? { lockFile: target.lockFile } : {}),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
const registry = { version: 1, overrides, genericTargets };
|
|
136
|
+
targetsFromRegistry(registry, file);
|
|
137
|
+
return registry;
|
|
138
|
+
}
|
|
139
|
+
function legacyRuntimeFromTarget(target) {
|
|
140
|
+
return {
|
|
141
|
+
key: target.key,
|
|
142
|
+
kind: target.kind === 'shared' ? 'shared' : 'agent',
|
|
143
|
+
discoveryRoot: target.discoveryRoot,
|
|
144
|
+
parkingRoot: target.parkingRoot,
|
|
145
|
+
projectPath: target.projectPath,
|
|
146
|
+
lockFile: target.lockFile,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function defaultLegacyRuntimes() {
|
|
150
|
+
return defaultTargetDefinitions().map(legacyRuntimeFromTarget);
|
|
151
|
+
}
|
|
152
|
+
function resolveLegacyRuntime(runtime, file) {
|
|
153
|
+
if (!runtime || typeof runtime.key !== 'string' || !isTargetKey(runtime.key) ||
|
|
154
|
+
(runtime.kind !== 'agent' && runtime.kind !== 'shared') ||
|
|
155
|
+
typeof runtime.discoveryRoot !== 'string' ||
|
|
156
|
+
typeof runtime.parkingRoot !== 'string' ||
|
|
157
|
+
typeof runtime.projectPath !== 'string' || path.isAbsolute(runtime.projectPath) ||
|
|
158
|
+
runtime.projectPath.split(path.sep).includes('..'))
|
|
159
|
+
throw new Error(`invalid Runtime entry in ${file}`);
|
|
160
|
+
const discoveryRoot = expandHome(runtime.discoveryRoot);
|
|
161
|
+
const parkingRoot = expandHome(runtime.parkingRoot);
|
|
162
|
+
const relative = path.relative(discoveryRoot, parkingRoot);
|
|
163
|
+
if (!relative || (!relative.startsWith('..') && !path.isAbsolute(relative)))
|
|
164
|
+
throw new Error(`parking root must be outside discovery root for Runtime ${runtime.key}`);
|
|
165
|
+
return {
|
|
166
|
+
...runtime,
|
|
167
|
+
discoveryRoot,
|
|
168
|
+
parkingRoot,
|
|
169
|
+
lockFile: runtime.lockFile
|
|
170
|
+
? expandHome(runtime.lockFile)
|
|
171
|
+
: runtime.kind === 'shared'
|
|
172
|
+
? path.join(path.dirname(discoveryRoot), '.skill-lock.json')
|
|
173
|
+
: undefined,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function readRuntimeRegistry(file) {
|
|
177
|
+
if (!fs.existsSync(file))
|
|
178
|
+
return undefined;
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
throw new Error(`cannot read Runtime registry ${file}: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.runtimes))
|
|
187
|
+
throw new Error(`invalid Runtime registry: ${file}`);
|
|
188
|
+
const runtimes = parsed.runtimes.map((runtime) => resolveLegacyRuntime(runtime, file));
|
|
189
|
+
const keys = new Set();
|
|
190
|
+
for (const runtime of runtimes) {
|
|
191
|
+
if (keys.has(runtime.key))
|
|
192
|
+
throw new Error(`duplicate Runtime key: ${runtime.key}`);
|
|
193
|
+
keys.add(runtime.key);
|
|
194
|
+
}
|
|
195
|
+
return runtimes;
|
|
196
|
+
}
|
|
197
|
+
function legacyRuntimes(file) {
|
|
198
|
+
if (!fs.existsSync(file))
|
|
199
|
+
return undefined;
|
|
200
|
+
const defaults = new Map(defaultLegacyRuntimes().map((runtime) => [runtime.key, runtime]));
|
|
201
|
+
return fs.readFileSync(file, 'utf8')
|
|
202
|
+
.split(/\r?\n/)
|
|
203
|
+
.map((line) => line.trim())
|
|
204
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
205
|
+
.map((line) => {
|
|
206
|
+
const separator = line.indexOf('=');
|
|
207
|
+
if (separator === -1)
|
|
208
|
+
throw new Error(`bad line in agents.conf: ${line}`);
|
|
209
|
+
const legacyKey = line.slice(0, separator).trim();
|
|
210
|
+
const key = legacyKey === 'agents' ? 'shared' : legacyKey;
|
|
211
|
+
const discoveryRoot = expandHome(line.slice(separator + 1).trim());
|
|
212
|
+
const known = defaults.get(key);
|
|
213
|
+
const kind = known?.kind ?? 'agent';
|
|
214
|
+
return resolveLegacyRuntime({
|
|
215
|
+
key,
|
|
216
|
+
kind,
|
|
217
|
+
discoveryRoot,
|
|
218
|
+
parkingRoot: path.join(path.dirname(discoveryRoot), '.skillspub-off', path.basename(discoveryRoot)),
|
|
219
|
+
projectPath: known?.projectPath ?? path.join(`.${key}`, 'skills'),
|
|
220
|
+
lockFile: kind === 'shared'
|
|
221
|
+
? path.join(path.dirname(discoveryRoot), '.skill-lock.json')
|
|
222
|
+
: undefined,
|
|
223
|
+
}, file);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function targetFromLegacyRuntime(runtime, file) {
|
|
227
|
+
const definition = defaultTargetDefinitions().find(({ key }) => key === runtime.key);
|
|
228
|
+
if (!definition) {
|
|
229
|
+
return resolveTarget({
|
|
230
|
+
key: runtime.key,
|
|
231
|
+
kind: 'generic',
|
|
232
|
+
discoveryRoot: runtime.discoveryRoot,
|
|
233
|
+
parkingRoot: runtime.parkingRoot,
|
|
234
|
+
projectPath: runtime.projectPath,
|
|
235
|
+
lockFile: runtime.lockFile,
|
|
236
|
+
}, file);
|
|
237
|
+
}
|
|
238
|
+
const expectedKind = definition.kind === 'shared' ? 'shared' : 'agent';
|
|
239
|
+
if (runtime.kind !== expectedKind)
|
|
240
|
+
throw new Error(`ambiguous Runtime kind for known Target Definition: ${runtime.key}`);
|
|
241
|
+
return resolveTarget({
|
|
242
|
+
key: runtime.key,
|
|
243
|
+
kind: definition.kind,
|
|
244
|
+
discoveryRoot: runtime.discoveryRoot,
|
|
245
|
+
parkingRoot: runtime.parkingRoot,
|
|
246
|
+
projectPath: runtime.projectPath,
|
|
247
|
+
lockFile: runtime.lockFile,
|
|
248
|
+
}, file);
|
|
249
|
+
}
|
|
250
|
+
const RUNTIMES_V1_TARGET_KEYS = new Set(['claude', 'shared', 'pi']);
|
|
251
|
+
function targetMigrationFromLegacy(runtimes, file) {
|
|
252
|
+
const definitions = new Map(defaultTargetDefinitions().map((definition) => [definition.key, definition]));
|
|
253
|
+
const overrides = [];
|
|
254
|
+
const genericTargets = [];
|
|
255
|
+
for (const runtime of runtimes) {
|
|
256
|
+
const target = targetFromLegacyRuntime(runtime, file);
|
|
257
|
+
const definition = definitions.get(target.key);
|
|
258
|
+
if (!definition) {
|
|
259
|
+
genericTargets.push(target);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
definitions.delete(target.key);
|
|
263
|
+
const base = resolveTarget(definition, file);
|
|
264
|
+
const override = { key: target.key };
|
|
265
|
+
for (const field of ['discoveryRoot', 'parkingRoot', 'projectPath', 'lockFile']) {
|
|
266
|
+
if (target[field] !== base[field])
|
|
267
|
+
override[field] = target[field];
|
|
268
|
+
}
|
|
269
|
+
if (Object.keys(override).length > 1)
|
|
270
|
+
overrides.push(override);
|
|
271
|
+
}
|
|
272
|
+
for (const key of definitions.keys())
|
|
273
|
+
if (RUNTIMES_V1_TARGET_KEYS.has(key))
|
|
274
|
+
overrides.push({ key, disabled: true });
|
|
275
|
+
const introducedDefinitions = [...definitions.values()]
|
|
276
|
+
.filter(({ key }) => !RUNTIMES_V1_TARGET_KEYS.has(key));
|
|
277
|
+
const registry = { version: 1, overrides, genericTargets };
|
|
278
|
+
targetsFromRegistry(registry, file);
|
|
279
|
+
return { registry, introducedDefinitions };
|
|
280
|
+
}
|
|
281
|
+
function canonicalTargetRegistry(registry) {
|
|
282
|
+
return JSON.stringify({
|
|
283
|
+
version: 1,
|
|
284
|
+
overrides: [...registry.overrides]
|
|
285
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
286
|
+
.map(({ key, disabled, discoveryRoot, parkingRoot, projectPath, lockFile }) => ({
|
|
287
|
+
key,
|
|
288
|
+
...(disabled ? { disabled: true } : {}),
|
|
289
|
+
...(discoveryRoot === undefined ? {} : { discoveryRoot }),
|
|
290
|
+
...(parkingRoot === undefined ? {} : { parkingRoot }),
|
|
291
|
+
...(projectPath === undefined ? {} : { projectPath }),
|
|
292
|
+
...(lockFile === undefined ? {} : { lockFile }),
|
|
293
|
+
})),
|
|
294
|
+
genericTargets: [...registry.genericTargets]
|
|
295
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
296
|
+
.map(({ key, discoveryRoot, parkingRoot, projectPath, lockFile }) => ({
|
|
297
|
+
key,
|
|
298
|
+
kind: 'generic',
|
|
299
|
+
discoveryRoot,
|
|
300
|
+
parkingRoot,
|
|
301
|
+
projectPath,
|
|
302
|
+
...(lockFile === undefined ? {} : { lockFile }),
|
|
303
|
+
})),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
export function pendingTargetDefinitions(home) {
|
|
307
|
+
if (readTargetRegistry(targetFile(home)))
|
|
308
|
+
return [];
|
|
309
|
+
const file = runtimeFile(home);
|
|
310
|
+
const legacy = readRuntimeRegistry(file);
|
|
311
|
+
return legacy ? targetMigrationFromLegacy(legacy, file).introducedDefinitions : [];
|
|
312
|
+
}
|
|
313
|
+
export function loadTargets(home) {
|
|
314
|
+
const file = targetFile(home);
|
|
315
|
+
const registry = readTargetRegistry(file);
|
|
316
|
+
if (registry)
|
|
317
|
+
return targetsFromRegistry(registry, file);
|
|
318
|
+
const legacy = readRuntimeRegistry(runtimeFile(home))
|
|
319
|
+
?? legacyRuntimes(path.join(home.configDir, 'agents.conf'));
|
|
320
|
+
if (!legacy)
|
|
321
|
+
return defaultTargetDefinitions().map((definition) => resolveTarget(definition, targetFile(home)));
|
|
322
|
+
const targets = legacy.map((runtime) => targetFromLegacyRuntime(runtime, runtimeFile(home)));
|
|
323
|
+
assertUniqueTargets(targets, runtimeFile(home));
|
|
324
|
+
return targets;
|
|
325
|
+
}
|
|
326
|
+
function fileHash(file) {
|
|
327
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
328
|
+
}
|
|
329
|
+
export function planTargetMigration(home) {
|
|
330
|
+
const legacyFile = runtimeFile(home);
|
|
331
|
+
const targetPath = targetFile(home);
|
|
332
|
+
const legacy = readRuntimeRegistry(legacyFile);
|
|
333
|
+
const existing = readTargetRegistry(targetPath);
|
|
334
|
+
if (!legacy) {
|
|
335
|
+
if (existing) {
|
|
336
|
+
return {
|
|
337
|
+
status: 'already-migrated',
|
|
338
|
+
targetFile: targetPath,
|
|
339
|
+
legacyFile,
|
|
340
|
+
writeTarget: false,
|
|
341
|
+
targetHash: fileHash(targetPath),
|
|
342
|
+
overrides: existing.overrides,
|
|
343
|
+
introducedDefinitions: [],
|
|
344
|
+
genericTargets: existing.genericTargets,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
throw new Error(`no legacy Runtime registry: ${legacyFile}`);
|
|
348
|
+
}
|
|
349
|
+
const { registry, introducedDefinitions } = targetMigrationFromLegacy(legacy, legacyFile);
|
|
350
|
+
if (existing && canonicalTargetRegistry(existing) !== canonicalTargetRegistry(registry))
|
|
351
|
+
throw new Error(`Target registry already exists and differs from legacy Runtime registry: ${targetPath}`);
|
|
352
|
+
const backupFile = `${legacyFile}.v1.bak`;
|
|
353
|
+
if (fs.existsSync(backupFile))
|
|
354
|
+
throw new Error(`legacy Runtime backup already exists: ${backupFile}`);
|
|
355
|
+
fs.accessSync(path.dirname(legacyFile), fs.constants.W_OK | fs.constants.X_OK);
|
|
356
|
+
return {
|
|
357
|
+
status: 'ready',
|
|
358
|
+
targetFile: targetPath,
|
|
359
|
+
legacyFile,
|
|
360
|
+
backupFile,
|
|
361
|
+
writeTarget: !existing,
|
|
362
|
+
legacyHash: fileHash(legacyFile),
|
|
363
|
+
targetHash: existing ? fileHash(targetPath) : undefined,
|
|
364
|
+
overrides: registry.overrides,
|
|
365
|
+
introducedDefinitions,
|
|
366
|
+
genericTargets: registry.genericTargets,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function registryFromPlan(plan) {
|
|
370
|
+
return {
|
|
371
|
+
version: 1,
|
|
372
|
+
overrides: plan.overrides,
|
|
373
|
+
genericTargets: plan.genericTargets,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function sameMigrationPlan(left, right) {
|
|
377
|
+
return left.status === right.status &&
|
|
378
|
+
left.targetFile === right.targetFile &&
|
|
379
|
+
left.legacyFile === right.legacyFile &&
|
|
380
|
+
left.backupFile === right.backupFile &&
|
|
381
|
+
left.writeTarget === right.writeTarget &&
|
|
382
|
+
left.legacyHash === right.legacyHash &&
|
|
383
|
+
left.targetHash === right.targetHash &&
|
|
384
|
+
JSON.stringify(left.introducedDefinitions) === JSON.stringify(right.introducedDefinitions) &&
|
|
385
|
+
canonicalTargetRegistry(registryFromPlan(left)) === canonicalTargetRegistry(registryFromPlan(right));
|
|
386
|
+
}
|
|
387
|
+
function writeTargetRegistry(file, registry) {
|
|
388
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
389
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
390
|
+
try {
|
|
391
|
+
fs.writeFileSync(temporary, JSON.stringify(registry, null, 2) + '\n');
|
|
392
|
+
fs.linkSync(temporary, file);
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
if (fs.existsSync(temporary))
|
|
396
|
+
fs.unlinkSync(temporary);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
export function applyTargetMigration(home, plan) {
|
|
400
|
+
if (plan.status === 'already-migrated')
|
|
401
|
+
return;
|
|
402
|
+
const fresh = planTargetMigration(home);
|
|
403
|
+
if (!sameMigrationPlan(plan, fresh))
|
|
404
|
+
throw Object.assign(new Error('Target migration changed after preview; preview again'), { code: 'concurrent_modification' });
|
|
405
|
+
const registry = registryFromPlan(fresh);
|
|
406
|
+
if (fresh.writeTarget)
|
|
407
|
+
writeTargetRegistry(fresh.targetFile, registry);
|
|
408
|
+
try {
|
|
409
|
+
const written = readTargetRegistry(fresh.targetFile);
|
|
410
|
+
if (!written || canonicalTargetRegistry(written) !== canonicalTargetRegistry(registry))
|
|
411
|
+
throw new Error(`Target registry validation failed: ${fresh.targetFile}`);
|
|
412
|
+
fs.renameSync(fresh.legacyFile, fresh.backupFile);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (fresh.writeTarget)
|
|
416
|
+
fs.unlinkSync(fresh.targetFile);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
export function normalizeSlotName(name) {
|
|
421
|
+
return name.trim().toLocaleLowerCase().replace(/[\s_]+/g, '-');
|
|
422
|
+
}
|
|
423
|
+
export function targetSlotId(targetId, slot) {
|
|
424
|
+
return `${targetId}\0${slot}`;
|
|
425
|
+
}
|
|
426
|
+
function displayTargetSlot(id) {
|
|
427
|
+
return id.replace('\0', '/');
|
|
428
|
+
}
|
|
429
|
+
function targetOf(entryPath) {
|
|
430
|
+
try {
|
|
431
|
+
return fs.readlinkSync(entryPath);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function scanRoot(target, root, activation, mirrors) {
|
|
438
|
+
let entries;
|
|
439
|
+
try {
|
|
440
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error.code === 'ENOENT')
|
|
444
|
+
return [];
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
const relationships = [];
|
|
448
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
449
|
+
if (entry.name.startsWith('.'))
|
|
450
|
+
continue;
|
|
451
|
+
const entryPath = path.join(root, entry.name);
|
|
452
|
+
const slot = normalizeSlotName(entry.name);
|
|
453
|
+
const mirror = entry.isSymbolicLink()
|
|
454
|
+
? undefined
|
|
455
|
+
: mirrors.get(targetSlotId(target.id, slot));
|
|
456
|
+
const form = entry.isSymbolicLink() ? 'link' : mirror ? 'mirror' : 'local';
|
|
457
|
+
let realPath;
|
|
458
|
+
let inspectionError;
|
|
459
|
+
try {
|
|
460
|
+
const stat = fs.statSync(entryPath);
|
|
461
|
+
if (!stat.isDirectory() || !fs.existsSync(path.join(entryPath, 'SKILL.md')))
|
|
462
|
+
continue;
|
|
463
|
+
realPath = fs.realpathSync(entryPath);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
if (form !== 'link')
|
|
467
|
+
throw error;
|
|
468
|
+
const code = error.code;
|
|
469
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
470
|
+
inspectionError = `${code ?? 'I/O'}: ${error.message}`;
|
|
471
|
+
}
|
|
472
|
+
relationships.push({
|
|
473
|
+
targetId: target.id,
|
|
474
|
+
targetKey: target.key,
|
|
475
|
+
slot,
|
|
476
|
+
name: entry.name,
|
|
477
|
+
activation,
|
|
478
|
+
form,
|
|
479
|
+
path: entryPath,
|
|
480
|
+
target: form === 'link' ? targetOf(entryPath) : undefined,
|
|
481
|
+
realPath,
|
|
482
|
+
resourceId: mirror?.sourceId ?? realPath,
|
|
483
|
+
mirror,
|
|
484
|
+
inspectionError,
|
|
485
|
+
readOnly: !target.writable,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return relationships;
|
|
489
|
+
}
|
|
490
|
+
function assertExternalParking(target) {
|
|
491
|
+
const relative = path.relative(target.discoveryRoot, target.parkingRoot);
|
|
492
|
+
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)))
|
|
493
|
+
throw new Error(`parking root must be outside discovery root for Target ${target.key}`);
|
|
494
|
+
}
|
|
495
|
+
function isRecord(value) {
|
|
496
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
497
|
+
}
|
|
498
|
+
function readProvenance(file) {
|
|
499
|
+
if (!file)
|
|
500
|
+
return { entries: new Map() };
|
|
501
|
+
try {
|
|
502
|
+
const entries = readNpxSkillsLock(file)
|
|
503
|
+
.filter(({ provenance }) => Object.values(provenance).some(Boolean))
|
|
504
|
+
.map(({ slot, provenance }) => [slot, provenance]);
|
|
505
|
+
return { entries: new Map(entries) };
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
return { entries: new Map(), error: error.message };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function isCliCoupled(root) {
|
|
512
|
+
try {
|
|
513
|
+
const content = fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8');
|
|
514
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
515
|
+
return Boolean(frontmatter && /(?:allowed-tools\s*:|Bash\()[\s\S]*?Bash\(/i.test(frontmatter));
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function sameProvenance(left, right) {
|
|
522
|
+
return left?.source === right?.source &&
|
|
523
|
+
left?.sourceUrl === right?.sourceUrl &&
|
|
524
|
+
left?.skillPath === right?.skillPath;
|
|
525
|
+
}
|
|
526
|
+
function repoName(provenance) {
|
|
527
|
+
const raw = provenance.source ?? provenance.sourceUrl;
|
|
528
|
+
if (!raw)
|
|
529
|
+
return undefined;
|
|
530
|
+
const cleaned = raw
|
|
531
|
+
.replace(/^git\+/, '')
|
|
532
|
+
.replace(/^(?:https?|ssh):\/\/(?:git@)?(?:www\.)?(?:github\.com|gitlab\.com)\//, '')
|
|
533
|
+
.replace(/^git@(?:github\.com|gitlab\.com):/, '')
|
|
534
|
+
.replace(/\.git(?:#.*)?$/, '')
|
|
535
|
+
.replace(/@[^/]+$/, '');
|
|
536
|
+
const match = cleaned.match(/^([^/]+\/[^/]+)$/);
|
|
537
|
+
return match?.[1];
|
|
538
|
+
}
|
|
539
|
+
export function hashDirectory(root) {
|
|
540
|
+
const hash = crypto.createHash('sha256');
|
|
541
|
+
const update = (value) => {
|
|
542
|
+
const bytes = typeof value === 'string' ? Buffer.from(value) : value;
|
|
543
|
+
const length = Buffer.allocUnsafe(8);
|
|
544
|
+
length.writeBigUInt64BE(BigInt(bytes.length));
|
|
545
|
+
hash.update(length);
|
|
546
|
+
hash.update(bytes);
|
|
547
|
+
};
|
|
548
|
+
const visit = (dir, prefix) => {
|
|
549
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
550
|
+
if (entry.name === '.git' || entry.name === 'node_modules')
|
|
551
|
+
continue;
|
|
552
|
+
const relative = path.join(prefix, entry.name);
|
|
553
|
+
const entryPath = path.join(dir, entry.name);
|
|
554
|
+
let kind = 'file';
|
|
555
|
+
if (entry.isDirectory())
|
|
556
|
+
kind = 'directory';
|
|
557
|
+
else if (entry.isSymbolicLink())
|
|
558
|
+
kind = 'link';
|
|
559
|
+
update(kind);
|
|
560
|
+
update(relative);
|
|
561
|
+
if (entry.isDirectory())
|
|
562
|
+
visit(entryPath, relative);
|
|
563
|
+
else if (entry.isSymbolicLink())
|
|
564
|
+
update(fs.readlinkSync(entryPath));
|
|
565
|
+
else if (entry.isFile())
|
|
566
|
+
update(fs.readFileSync(entryPath));
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
visit(root, '');
|
|
570
|
+
return hash.digest('hex');
|
|
571
|
+
}
|
|
572
|
+
export function readStateFile(file) {
|
|
573
|
+
try {
|
|
574
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
575
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
576
|
+
throw new Error('state must be a JSON object');
|
|
577
|
+
return parsed;
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
if (error.code === 'ENOENT')
|
|
581
|
+
return {};
|
|
582
|
+
throw new Error(`cannot read state ${file}: ${error.message}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
export function writeStateFile(file, state) {
|
|
586
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
587
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
588
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n');
|
|
589
|
+
fs.renameSync(temporary, file);
|
|
590
|
+
}
|
|
591
|
+
function groupTargetSlots(relationships) {
|
|
592
|
+
const bySlot = new Map();
|
|
593
|
+
const findings = [];
|
|
594
|
+
for (const relationship of relationships) {
|
|
595
|
+
const key = targetSlotId(relationship.targetId, relationship.slot);
|
|
596
|
+
const groupedRelationships = bySlot.get(key) ?? [];
|
|
597
|
+
groupedRelationships.push(relationship);
|
|
598
|
+
bySlot.set(key, groupedRelationships);
|
|
599
|
+
if (!relationship.realPath)
|
|
600
|
+
findings.push({
|
|
601
|
+
category: 'structural',
|
|
602
|
+
code: relationship.inspectionError ? 'unreadable-link' : 'broken-link',
|
|
603
|
+
message: relationship.inspectionError
|
|
604
|
+
? `cannot inspect link: ${relationship.path}: ${relationship.inspectionError}`
|
|
605
|
+
: `broken link: ${relationship.path} -> ${relationship.target ?? '?'}`,
|
|
606
|
+
targetId: relationship.targetId,
|
|
607
|
+
slot: relationship.slot,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
return { bySlot, findings };
|
|
611
|
+
}
|
|
612
|
+
function scanSlotProvenance(targets, bySlot, previous) {
|
|
613
|
+
const provenanceBySlot = new Map();
|
|
614
|
+
const invalidLockTargets = new Set();
|
|
615
|
+
const findings = [];
|
|
616
|
+
for (const target of targets) {
|
|
617
|
+
const lock = readProvenance(target.lockFile);
|
|
618
|
+
if (lock.error) {
|
|
619
|
+
invalidLockTargets.add(target.id);
|
|
620
|
+
findings.push({
|
|
621
|
+
category: 'structural',
|
|
622
|
+
code: 'invalid-lock',
|
|
623
|
+
message: `cannot read installer lock: ${lock.error}`,
|
|
624
|
+
targetId: target.id,
|
|
625
|
+
});
|
|
626
|
+
for (const [key, occupants] of bySlot) {
|
|
627
|
+
if (occupants[0].targetId !== target.id)
|
|
628
|
+
continue;
|
|
629
|
+
const prior = previous?.slots?.[key]?.provenance;
|
|
630
|
+
if (prior)
|
|
631
|
+
provenanceBySlot.set(key, prior);
|
|
632
|
+
}
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
for (const [slot, provenance] of lock.entries) {
|
|
636
|
+
const key = targetSlotId(target.id, slot);
|
|
637
|
+
const occupants = bySlot.get(key) ?? [];
|
|
638
|
+
if (occupants.length === 0)
|
|
639
|
+
findings.push({
|
|
640
|
+
category: 'structural',
|
|
641
|
+
code: 'lock-file-missing',
|
|
642
|
+
message: `lock entry has no Target Slot file: ${target.key}/${slot}`,
|
|
643
|
+
targetId: target.id,
|
|
644
|
+
slot,
|
|
645
|
+
});
|
|
646
|
+
provenanceBySlot.set(key, provenance);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return { provenanceBySlot, invalidLockTargets, findings };
|
|
650
|
+
}
|
|
651
|
+
function findTargetSlotIssues(bySlot, provenanceBySlot, invalidLockTargets, previous) {
|
|
652
|
+
const findings = [];
|
|
653
|
+
for (const [key, occupants] of bySlot) {
|
|
654
|
+
const oldSlot = previous?.slots?.[key];
|
|
655
|
+
if (oldSlot && !invalidLockTargets.has(occupants[0].targetId) &&
|
|
656
|
+
!sameProvenance(oldSlot.provenance, provenanceBySlot.get(key)))
|
|
657
|
+
findings.push({
|
|
658
|
+
category: 'change',
|
|
659
|
+
code: 'source-changed',
|
|
660
|
+
message: `Target Slot provenance changed: ${displayTargetSlot(key)}`,
|
|
661
|
+
targetId: occupants[0].targetId,
|
|
662
|
+
slot: occupants[0].slot,
|
|
663
|
+
});
|
|
664
|
+
const activations = new Set(occupants.map(({ activation }) => activation));
|
|
665
|
+
if (activations.size > 1)
|
|
666
|
+
findings.push({
|
|
667
|
+
category: 'structural',
|
|
668
|
+
code: 'on-off-conflict',
|
|
669
|
+
message: `Target Slot is present in ON and OFF roots: ${displayTargetSlot(key)}`,
|
|
670
|
+
targetId: occupants[0].targetId,
|
|
671
|
+
slot: occupants[0].slot,
|
|
672
|
+
});
|
|
673
|
+
if (new Set(occupants.map(({ name }) => name)).size > 1)
|
|
674
|
+
findings.push({
|
|
675
|
+
category: 'structural',
|
|
676
|
+
code: 'slot-conflict',
|
|
677
|
+
message: `multiple entry names normalize to Target Slot: ${displayTargetSlot(key)}`,
|
|
678
|
+
targetId: occupants[0].targetId,
|
|
679
|
+
slot: occupants[0].slot,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return findings;
|
|
683
|
+
}
|
|
684
|
+
function scanTargetSlots(relationships, targets, previous) {
|
|
685
|
+
const grouped = groupTargetSlots(relationships);
|
|
686
|
+
const provenance = scanSlotProvenance(targets, grouped.bySlot, previous);
|
|
687
|
+
const slots = [...grouped.bySlot].map(([id, occupants]) => ({
|
|
688
|
+
id,
|
|
689
|
+
targetId: occupants[0].targetId,
|
|
690
|
+
targetKey: occupants[0].targetKey,
|
|
691
|
+
name: occupants[0].slot,
|
|
692
|
+
relationships: occupants,
|
|
693
|
+
provenance: provenance.provenanceBySlot.get(id),
|
|
694
|
+
}));
|
|
695
|
+
return {
|
|
696
|
+
slots,
|
|
697
|
+
findings: [
|
|
698
|
+
...grouped.findings,
|
|
699
|
+
...provenance.findings,
|
|
700
|
+
...findTargetSlotIssues(grouped.bySlot, provenance.provenanceBySlot, provenance.invalidLockTargets, previous),
|
|
701
|
+
],
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function scanInventoryResources(relationships, targets) {
|
|
705
|
+
const grouped = new Map();
|
|
706
|
+
for (const relationship of relationships) {
|
|
707
|
+
if (!relationship.realPath || !relationship.resourceId)
|
|
708
|
+
continue;
|
|
709
|
+
let resource = grouped.get(relationship.resourceId);
|
|
710
|
+
if (!resource) {
|
|
711
|
+
const source = relationships.find((candidate) => candidate.resourceId === relationship.resourceId &&
|
|
712
|
+
candidate.realPath === relationship.resourceId)?.realPath ?? relationship.realPath;
|
|
713
|
+
resource = {
|
|
714
|
+
id: relationship.resourceId,
|
|
715
|
+
name: relationship.name,
|
|
716
|
+
realPath: source,
|
|
717
|
+
hash: hashDirectory(source),
|
|
718
|
+
cliCoupled: isCliCoupled(source),
|
|
719
|
+
relationships: [],
|
|
720
|
+
};
|
|
721
|
+
grouped.set(resource.id, resource);
|
|
722
|
+
}
|
|
723
|
+
resource.relationships.push(relationship);
|
|
724
|
+
}
|
|
725
|
+
const resources = [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
726
|
+
const missing = resources.flatMap((resource) => targets
|
|
727
|
+
.filter((target) => !resource.relationships.some((relationship) => relationship.targetId === target.id && relationship.slot === normalizeSlotName(resource.name)))
|
|
728
|
+
.map((target) => ({
|
|
729
|
+
resourceId: resource.id,
|
|
730
|
+
targetId: target.id,
|
|
731
|
+
slot: normalizeSlotName(resource.name),
|
|
732
|
+
})));
|
|
733
|
+
return { resources, missing };
|
|
734
|
+
}
|
|
735
|
+
function readMirrors(value) {
|
|
736
|
+
if (value === undefined)
|
|
737
|
+
return new Map();
|
|
738
|
+
if (!isRecord(value))
|
|
739
|
+
throw new Error('invalid state mirrors');
|
|
740
|
+
const mirrors = new Map();
|
|
741
|
+
for (const [slotId, mirror] of Object.entries(value)) {
|
|
742
|
+
if (!isRecord(mirror) || typeof mirror.sourceId !== 'string' || typeof mirror.hash !== 'string')
|
|
743
|
+
throw new Error('invalid state mirrors');
|
|
744
|
+
mirrors.set(slotId, { sourceId: mirror.sourceId, hash: mirror.hash });
|
|
745
|
+
}
|
|
746
|
+
return mirrors;
|
|
747
|
+
}
|
|
748
|
+
function findMirrorIssues(relationships, resources) {
|
|
749
|
+
const resourcesById = new Map(resources.map((resource) => [resource.id, resource]));
|
|
750
|
+
const findings = [];
|
|
751
|
+
for (const relationship of relationships) {
|
|
752
|
+
if (relationship.form !== 'mirror' || !relationship.mirror || !relationship.realPath)
|
|
753
|
+
continue;
|
|
754
|
+
const source = resourcesById.get(relationship.mirror.sourceId);
|
|
755
|
+
if (!source || source.realPath !== relationship.mirror.sourceId) {
|
|
756
|
+
findings.push({
|
|
757
|
+
category: 'structural',
|
|
758
|
+
code: 'mirror-source-missing',
|
|
759
|
+
message: `Mirror source is missing: ${relationship.path} -> ${relationship.mirror.sourceId}`,
|
|
760
|
+
targetId: relationship.targetId,
|
|
761
|
+
slot: relationship.slot,
|
|
762
|
+
});
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (hashDirectory(relationship.realPath) !== relationship.mirror.hash) {
|
|
766
|
+
relationship.diverged = true;
|
|
767
|
+
findings.push({
|
|
768
|
+
category: 'structural',
|
|
769
|
+
code: 'mirror-diverged',
|
|
770
|
+
message: `Mirror diverged: ${relationship.path}`,
|
|
771
|
+
resourceId: relationship.mirror.sourceId,
|
|
772
|
+
targetId: relationship.targetId,
|
|
773
|
+
slot: relationship.slot,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (source.hash !== relationship.mirror.hash) {
|
|
777
|
+
findings.push({
|
|
778
|
+
category: 'change',
|
|
779
|
+
code: 'mirror-drift',
|
|
780
|
+
message: `Mirror drift: ${relationship.path} is behind ${source.realPath}`,
|
|
781
|
+
resourceId: source.id,
|
|
782
|
+
targetId: relationship.targetId,
|
|
783
|
+
slot: relationship.slot,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return findings;
|
|
788
|
+
}
|
|
789
|
+
function findResourceIssues(resources, previous, tags) {
|
|
790
|
+
const findings = [];
|
|
791
|
+
const currentResourceIds = new Set(resources.map(({ id }) => id));
|
|
792
|
+
for (const resource of resources) {
|
|
793
|
+
const old = previous?.resources?.[resource.id];
|
|
794
|
+
if (!old)
|
|
795
|
+
findings.push({
|
|
796
|
+
category: 'change',
|
|
797
|
+
code: 'new-resource',
|
|
798
|
+
message: `new resource: ${resource.name} (${resource.realPath})`,
|
|
799
|
+
resourceId: resource.id,
|
|
800
|
+
});
|
|
801
|
+
else if (old.hash !== resource.hash)
|
|
802
|
+
findings.push({
|
|
803
|
+
category: 'change',
|
|
804
|
+
code: 'changed-resource',
|
|
805
|
+
message: `resource content changed: ${resource.name} (${resource.realPath})`,
|
|
806
|
+
resourceId: resource.id,
|
|
807
|
+
});
|
|
808
|
+
if ((tags[resource.id] ?? []).length === 0)
|
|
809
|
+
findings.push({
|
|
810
|
+
category: 'metadata',
|
|
811
|
+
code: 'untagged',
|
|
812
|
+
message: `untagged resource: ${resource.name} (${resource.realPath})`,
|
|
813
|
+
resourceId: resource.id,
|
|
814
|
+
});
|
|
815
|
+
if (resource.cliCoupled)
|
|
816
|
+
findings.push({
|
|
817
|
+
category: 'metadata',
|
|
818
|
+
code: 'cli-coupled',
|
|
819
|
+
message: `CLI-coupled resource: ${resource.name} (${resource.realPath})`,
|
|
820
|
+
resourceId: resource.id,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
for (const [resourceId, old] of Object.entries(previous?.resources ?? {})) {
|
|
824
|
+
if (!currentResourceIds.has(resourceId))
|
|
825
|
+
findings.push({
|
|
826
|
+
category: 'change',
|
|
827
|
+
code: 'removed-resource',
|
|
828
|
+
message: `resource removed: ${old.name} (${resourceId})`,
|
|
829
|
+
resourceId,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return findings;
|
|
833
|
+
}
|
|
834
|
+
function previousTargetInventory(state) {
|
|
835
|
+
// Compatibility input only: the renamed metadata is rewritten on the next persisted scan.
|
|
836
|
+
return (state.targetInventory ?? state.runtimeInventory);
|
|
837
|
+
}
|
|
838
|
+
function withTargetInventory(state, metadata) {
|
|
839
|
+
const { runtimeInventory: _, ...current } = state;
|
|
840
|
+
return { ...current, targetInventory: metadata };
|
|
841
|
+
}
|
|
842
|
+
function buildInventoryMetadata(resources, slots, previous, now) {
|
|
843
|
+
return {
|
|
844
|
+
version: 1,
|
|
845
|
+
resources: Object.fromEntries(resources.map((resource) => [resource.id, {
|
|
846
|
+
name: resource.name,
|
|
847
|
+
hash: resource.hash,
|
|
848
|
+
firstSeenAt: previous?.resources?.[resource.id]?.firstSeenAt ?? now,
|
|
849
|
+
lastSeenAt: now,
|
|
850
|
+
}])),
|
|
851
|
+
slots: Object.fromEntries(slots.map((slot) => [
|
|
852
|
+
slot.id,
|
|
853
|
+
{
|
|
854
|
+
resourceIds: [...new Set(slot.relationships.flatMap(({ resourceId }) => resourceId ? [resourceId] : []))].sort((a, b) => a.localeCompare(b)),
|
|
855
|
+
provenance: slot.provenance,
|
|
856
|
+
lastSeenAt: now,
|
|
857
|
+
},
|
|
858
|
+
])),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function buildRepoBundles(value, resources, slots) {
|
|
862
|
+
const bundles = isRecord(value) ? value : {};
|
|
863
|
+
const nextBundles = Object.fromEntries(Object.entries(bundles).map(([name, members]) => [name, [...members]]));
|
|
864
|
+
const currentResourceIds = new Set(resources.map(({ id }) => id));
|
|
865
|
+
for (const [name, members] of Object.entries(nextBundles)) {
|
|
866
|
+
if (!name.startsWith('repo:'))
|
|
867
|
+
continue;
|
|
868
|
+
nextBundles[name] = members.filter((member) => !currentResourceIds.has(member));
|
|
869
|
+
if (nextBundles[name].length === 0)
|
|
870
|
+
delete nextBundles[name];
|
|
871
|
+
}
|
|
872
|
+
for (const slot of slots) {
|
|
873
|
+
const repo = slot.provenance ? repoName(slot.provenance) : undefined;
|
|
874
|
+
if (!repo)
|
|
875
|
+
continue;
|
|
876
|
+
const resourceIds = [...new Set(slot.relationships.flatMap(({ resourceId }) => resourceId ? [resourceId] : []))];
|
|
877
|
+
if (resourceIds.length !== 1)
|
|
878
|
+
continue;
|
|
879
|
+
const name = `repo:${repo}`;
|
|
880
|
+
nextBundles[name] = [...new Set([...(nextBundles[name] ?? []), resourceIds[0]])]
|
|
881
|
+
.sort((a, b) => a.localeCompare(b));
|
|
882
|
+
}
|
|
883
|
+
return nextBundles;
|
|
884
|
+
}
|
|
885
|
+
function scanInventory({ scope, stateFile, catalogStateFile, targets, options, projectPath, }) {
|
|
886
|
+
const now = options.now ?? new Date().toISOString();
|
|
887
|
+
const state = readStateFile(stateFile);
|
|
888
|
+
const mirrors = readMirrors(state.mirrors);
|
|
889
|
+
const relationships = targets.flatMap((target) => {
|
|
890
|
+
assertExternalParking(target);
|
|
891
|
+
return [
|
|
892
|
+
...scanRoot(target, target.discoveryRoot, 'on', mirrors),
|
|
893
|
+
...scanRoot(target, target.parkingRoot, 'off', mirrors),
|
|
894
|
+
];
|
|
895
|
+
});
|
|
896
|
+
const { resources, missing } = scanInventoryResources(relationships, targets);
|
|
897
|
+
const previous = previousTargetInventory(state);
|
|
898
|
+
const slotScan = scanTargetSlots(relationships, targets, previous);
|
|
899
|
+
const catalogState = catalogStateFile === stateFile
|
|
900
|
+
? state
|
|
901
|
+
: readStateFile(catalogStateFile);
|
|
902
|
+
const tags = isRecord(catalogState.tags)
|
|
903
|
+
? catalogState.tags
|
|
904
|
+
: {};
|
|
905
|
+
const findings = [
|
|
906
|
+
...slotScan.findings,
|
|
907
|
+
...findMirrorIssues(relationships, resources),
|
|
908
|
+
...findResourceIssues(resources, previous, tags),
|
|
909
|
+
];
|
|
910
|
+
if (options.persist !== false) {
|
|
911
|
+
const metadata = buildInventoryMetadata(resources, slotScan.slots, previous, now);
|
|
912
|
+
const nextBundles = buildRepoBundles(catalogState.bundles, resources, slotScan.slots);
|
|
913
|
+
writeStateFile(stateFile, scope === 'global'
|
|
914
|
+
? { ...withTargetInventory(state, metadata), bundles: nextBundles, tags }
|
|
915
|
+
: withTargetInventory(state, metadata));
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
scope,
|
|
919
|
+
projectPath,
|
|
920
|
+
targets,
|
|
921
|
+
resources,
|
|
922
|
+
slots: slotScan.slots,
|
|
923
|
+
relationships,
|
|
924
|
+
missing,
|
|
925
|
+
findings,
|
|
926
|
+
stateFile,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
function assertBrokenLink(repair) {
|
|
930
|
+
let entry;
|
|
931
|
+
try {
|
|
932
|
+
entry = fs.lstatSync(repair.path);
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
throw new Error(`repair path is missing: ${repair.path}: ${error.message}`);
|
|
936
|
+
}
|
|
937
|
+
if (!entry.isSymbolicLink())
|
|
938
|
+
throw new Error(`repair path is no longer a symlink: ${repair.path}`);
|
|
939
|
+
const target = fs.readlinkSync(repair.path);
|
|
940
|
+
if (target !== repair.from)
|
|
941
|
+
throw new Error(`repair target changed: ${repair.path}: ${target}`);
|
|
942
|
+
try {
|
|
943
|
+
fs.statSync(path.resolve(path.dirname(repair.path), target));
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
const code = error.code;
|
|
947
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
948
|
+
return;
|
|
949
|
+
throw new Error(`cannot verify repair target: ${repair.path}: ${error.message}`);
|
|
950
|
+
}
|
|
951
|
+
throw new Error(`repair target is no longer broken: ${repair.path}`);
|
|
952
|
+
}
|
|
953
|
+
function repairTemporaryPath(repair, index) {
|
|
954
|
+
return `${repair.path}.skillspub-repair-${process.pid}-${index}`;
|
|
955
|
+
}
|
|
956
|
+
function assertRetargetTarget(repair) {
|
|
957
|
+
if (repair.kind === 'remove-broken-link')
|
|
958
|
+
return;
|
|
959
|
+
if (!repair.to || !repair.targetResourceId || !repair.targetHash)
|
|
960
|
+
throw new Error(`retarget repair has no verified target: ${repair.path}`);
|
|
961
|
+
const target = path.resolve(path.dirname(repair.path), repair.to);
|
|
962
|
+
const stat = fs.statSync(target);
|
|
963
|
+
if (!stat.isDirectory() || !fs.existsSync(path.join(target, 'SKILL.md')))
|
|
964
|
+
throw new Error(`retarget repair target is not a Skill resource: ${target}`);
|
|
965
|
+
if (fs.realpathSync(target) !== repair.targetResourceId)
|
|
966
|
+
throw new Error(`retarget repair target identity changed: ${target}`);
|
|
967
|
+
if (hashDirectory(target) !== repair.targetHash)
|
|
968
|
+
throw new Error(`retarget repair target content changed: ${target}`);
|
|
969
|
+
}
|
|
970
|
+
function preflightDoctorRepair(repair, index) {
|
|
971
|
+
if (repair.kind === 'migrate-legacy-off') {
|
|
972
|
+
if (!fs.lstatSync(repair.path, { throwIfNoEntry: false }))
|
|
973
|
+
throw new Error(`repair path is missing: ${repair.path}`);
|
|
974
|
+
if (!repair.to)
|
|
975
|
+
throw new Error(`migrate-legacy-off repair has no destination: ${repair.path}`);
|
|
976
|
+
if (fs.lstatSync(repair.to, { throwIfNoEntry: false }))
|
|
977
|
+
throw new Error(`migrate-legacy-off destination already exists: ${repair.to}`);
|
|
978
|
+
fs.accessSync(path.dirname(repair.path), fs.constants.W_OK | fs.constants.X_OK);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
assertBrokenLink(repair);
|
|
982
|
+
fs.accessSync(path.dirname(repair.path), fs.constants.W_OK | fs.constants.X_OK);
|
|
983
|
+
if (repair.kind === 'remove-broken-link')
|
|
984
|
+
return;
|
|
985
|
+
assertRetargetTarget(repair);
|
|
986
|
+
const temporary = repairTemporaryPath(repair, index);
|
|
987
|
+
try {
|
|
988
|
+
fs.lstatSync(temporary);
|
|
989
|
+
throw new Error(`temporary repair path already exists: ${temporary}`);
|
|
990
|
+
}
|
|
991
|
+
catch (error) {
|
|
992
|
+
if (error.code !== 'ENOENT')
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function applyDoctorRepair(repair, index) {
|
|
997
|
+
if (repair.kind === 'migrate-legacy-off') {
|
|
998
|
+
if (!repair.to)
|
|
999
|
+
throw new Error(`migrate-legacy-off repair has no destination: ${repair.path}`);
|
|
1000
|
+
if (fs.lstatSync(repair.to, { throwIfNoEntry: false }))
|
|
1001
|
+
throw new Error(`migrate-legacy-off destination already exists: ${repair.to}`);
|
|
1002
|
+
fs.mkdirSync(path.dirname(repair.to), { recursive: true });
|
|
1003
|
+
fs.renameSync(repair.path, repair.to);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
assertBrokenLink(repair);
|
|
1007
|
+
if (repair.kind === 'remove-broken-link') {
|
|
1008
|
+
fs.unlinkSync(repair.path);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
assertRetargetTarget(repair);
|
|
1012
|
+
if (!repair.to)
|
|
1013
|
+
throw new Error(`retarget repair has no target: ${repair.path}`);
|
|
1014
|
+
const temporary = repairTemporaryPath(repair, index);
|
|
1015
|
+
fs.symlinkSync(repair.to, temporary);
|
|
1016
|
+
try {
|
|
1017
|
+
fs.renameSync(temporary, repair.path);
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
try {
|
|
1021
|
+
fs.unlinkSync(temporary);
|
|
1022
|
+
}
|
|
1023
|
+
catch (cleanupError) {
|
|
1024
|
+
throw new Error(`${error.message}; temporary link remains at ${temporary}: ` +
|
|
1025
|
+
cleanupError.message);
|
|
1026
|
+
}
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
export function applyDoctorRepairs(repairs) {
|
|
1031
|
+
const paths = new Set();
|
|
1032
|
+
for (const [index, repair] of repairs.entries()) {
|
|
1033
|
+
if (paths.has(repair.path))
|
|
1034
|
+
throw new Error(`duplicate repair path: ${repair.path}`);
|
|
1035
|
+
paths.add(repair.path);
|
|
1036
|
+
preflightDoctorRepair(repair, index);
|
|
1037
|
+
}
|
|
1038
|
+
const completed = [];
|
|
1039
|
+
for (const [index, repair] of repairs.entries()) {
|
|
1040
|
+
try {
|
|
1041
|
+
applyDoctorRepair(repair, index);
|
|
1042
|
+
completed.push(repair);
|
|
1043
|
+
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
return {
|
|
1046
|
+
completed,
|
|
1047
|
+
failed: { repair, error: error.message },
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return { completed };
|
|
1052
|
+
}
|
|
1053
|
+
function matchingCounterpart(target, root, counterpart, evidence) {
|
|
1054
|
+
const relative = path.relative(path.resolve(root), target);
|
|
1055
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
1056
|
+
return undefined;
|
|
1057
|
+
const candidate = path.resolve(counterpart, relative);
|
|
1058
|
+
const candidateResourceId = evidence.occupants.get(candidate);
|
|
1059
|
+
const priorResourceId = path.resolve(rootIdentity(root), relative);
|
|
1060
|
+
const priorResource = evidence.priorResources[priorResourceId];
|
|
1061
|
+
if (!candidateResourceId || !evidence.priorResourceIds.has(priorResourceId) ||
|
|
1062
|
+
!isRecord(priorResource) || typeof priorResource.hash !== 'string' ||
|
|
1063
|
+
evidence.currentHashes.get(candidateResourceId) !== priorResource.hash)
|
|
1064
|
+
return undefined;
|
|
1065
|
+
return candidate;
|
|
1066
|
+
}
|
|
1067
|
+
function replacementTarget(relationship, report, state) {
|
|
1068
|
+
const rawTarget = relationship.target;
|
|
1069
|
+
if (!rawTarget)
|
|
1070
|
+
return undefined;
|
|
1071
|
+
const target = path.resolve(path.dirname(relationship.path), rawTarget);
|
|
1072
|
+
const evidence = {
|
|
1073
|
+
occupants: new Map(report.relationships.flatMap(({ path: entryPath, realPath }) => realPath ? [[path.resolve(entryPath), realPath]] : [])),
|
|
1074
|
+
currentHashes: new Map(report.resources.map(({ id, hash }) => [id, hash])),
|
|
1075
|
+
priorResourceIds: previousResourceIds(state, relationship),
|
|
1076
|
+
priorResources: previousTargetInventory(state)?.resources ?? {},
|
|
1077
|
+
};
|
|
1078
|
+
const candidates = new Set();
|
|
1079
|
+
for (const scannedTarget of report.targets) {
|
|
1080
|
+
for (const [root, counterpart] of [
|
|
1081
|
+
[scannedTarget.discoveryRoot, scannedTarget.parkingRoot],
|
|
1082
|
+
[scannedTarget.parkingRoot, scannedTarget.discoveryRoot],
|
|
1083
|
+
]) {
|
|
1084
|
+
const candidate = matchingCounterpart(target, root, counterpart, evidence);
|
|
1085
|
+
if (candidate)
|
|
1086
|
+
candidates.add(candidate);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (candidates.size !== 1)
|
|
1090
|
+
return undefined;
|
|
1091
|
+
const candidate = [...candidates][0];
|
|
1092
|
+
return path.isAbsolute(rawTarget)
|
|
1093
|
+
? candidate
|
|
1094
|
+
: path.relative(path.dirname(relationship.path), candidate);
|
|
1095
|
+
}
|
|
1096
|
+
function resourceExists(resources, resourceId) {
|
|
1097
|
+
if (resources.has(resourceId))
|
|
1098
|
+
return true;
|
|
1099
|
+
try {
|
|
1100
|
+
return fs.statSync(resourceId).isDirectory() &&
|
|
1101
|
+
fs.statSync(path.join(resourceId, 'SKILL.md')).isFile();
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function staleBundleReferences(value, resources) {
|
|
1108
|
+
if (!isRecord(value))
|
|
1109
|
+
return [];
|
|
1110
|
+
const stale = [];
|
|
1111
|
+
for (const [bundle, members] of Object.entries(value)) {
|
|
1112
|
+
if (!Array.isArray(members))
|
|
1113
|
+
continue;
|
|
1114
|
+
for (const member of members)
|
|
1115
|
+
if (typeof member === 'string' && !resourceExists(resources, member))
|
|
1116
|
+
stale.push([`Bundle ${bundle}:${member}`, member]);
|
|
1117
|
+
}
|
|
1118
|
+
return stale;
|
|
1119
|
+
}
|
|
1120
|
+
function staleTagReferences(value, resources) {
|
|
1121
|
+
if (!isRecord(value))
|
|
1122
|
+
return [];
|
|
1123
|
+
return Object.keys(value).flatMap((resourceId) => resourceExists(resources, resourceId) ? [] : [[`Tag:${resourceId}`, resourceId]]);
|
|
1124
|
+
}
|
|
1125
|
+
function stalePresetReferences(value, resources) {
|
|
1126
|
+
if (!isRecord(value))
|
|
1127
|
+
return [];
|
|
1128
|
+
const stale = [];
|
|
1129
|
+
for (const preset of Object.values(value)) {
|
|
1130
|
+
if (!isRecord(preset) || !Array.isArray(preset.selectors))
|
|
1131
|
+
continue;
|
|
1132
|
+
for (const selector of preset.selectors) {
|
|
1133
|
+
if (typeof selector !== 'string' || !selector.startsWith('skill:'))
|
|
1134
|
+
continue;
|
|
1135
|
+
const resourceId = selector.slice('skill:'.length);
|
|
1136
|
+
if (!resourceExists(resources, resourceId))
|
|
1137
|
+
stale.push([`Preset:${selector}`, resourceId]);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return stale;
|
|
1141
|
+
}
|
|
1142
|
+
function staleReferenceFindings(report, catalog) {
|
|
1143
|
+
const resources = new Set(report.resources.map(({ id }) => id));
|
|
1144
|
+
const stale = new Map([
|
|
1145
|
+
...staleBundleReferences(catalog.bundles, resources),
|
|
1146
|
+
...staleTagReferences(catalog.tags, resources),
|
|
1147
|
+
...stalePresetReferences(catalog.presets, resources),
|
|
1148
|
+
]);
|
|
1149
|
+
return [...stale].map(([reference, resourceId]) => ({
|
|
1150
|
+
category: 'structural',
|
|
1151
|
+
code: 'stale-reference',
|
|
1152
|
+
message: `stale reference preserved: ${reference}`,
|
|
1153
|
+
resourceId,
|
|
1154
|
+
}));
|
|
1155
|
+
}
|
|
1156
|
+
function activePresetNames(state) {
|
|
1157
|
+
const value = state.presetActivations;
|
|
1158
|
+
if (Array.isArray(value))
|
|
1159
|
+
return value.filter((item) => typeof item === 'string');
|
|
1160
|
+
return isRecord(value) ? Object.keys(value) : [];
|
|
1161
|
+
}
|
|
1162
|
+
function orphanedPresetFindings(report, state, catalog) {
|
|
1163
|
+
if (report.scope !== 'project')
|
|
1164
|
+
return [];
|
|
1165
|
+
const definitions = new Set(isRecord(catalog.presets) ? Object.keys(catalog.presets) : []);
|
|
1166
|
+
return activePresetNames(state).flatMap((preset) => definitions.has(preset) ? [] : [{
|
|
1167
|
+
category: 'structural',
|
|
1168
|
+
code: 'orphaned-preset-activation',
|
|
1169
|
+
message: `Orphaned Preset Activation: ${preset}; lastClaims frozen`,
|
|
1170
|
+
}]);
|
|
1171
|
+
}
|
|
1172
|
+
function baseIntents(state) {
|
|
1173
|
+
const intents = new Map();
|
|
1174
|
+
if (!isRecord(state.baseIntent))
|
|
1175
|
+
return intents;
|
|
1176
|
+
for (const [key, value] of Object.entries(state.baseIntent))
|
|
1177
|
+
if (value === 'on' || value === 'off')
|
|
1178
|
+
intents.set(key, value);
|
|
1179
|
+
return intents;
|
|
1180
|
+
}
|
|
1181
|
+
function collectClaimedSlots(value, slots) {
|
|
1182
|
+
let lists = [];
|
|
1183
|
+
if (Array.isArray(value))
|
|
1184
|
+
lists = [value];
|
|
1185
|
+
else if (isRecord(value))
|
|
1186
|
+
lists = Object.values(value).filter(Array.isArray);
|
|
1187
|
+
for (const list of lists)
|
|
1188
|
+
for (const item of list)
|
|
1189
|
+
if (typeof item === 'string' && item.includes('\0'))
|
|
1190
|
+
slots.add(item);
|
|
1191
|
+
}
|
|
1192
|
+
function parkingFindings(report, state) {
|
|
1193
|
+
const currentSlots = new Set(report.slots.map(({ id }) => id));
|
|
1194
|
+
const previousSlots = previousTargetInventory(state)?.slots ?? {};
|
|
1195
|
+
const claimedSlots = new Set();
|
|
1196
|
+
collectClaimedSlots(state.claims, claimedSlots);
|
|
1197
|
+
collectClaimedSlots(state.lastClaims, claimedSlots);
|
|
1198
|
+
return [...baseIntents(state)].flatMap(([id, intent]) => {
|
|
1199
|
+
if (intent !== 'off' || currentSlots.has(id) || claimedSlots.has(id) ||
|
|
1200
|
+
!isRecord(previousSlots[id]))
|
|
1201
|
+
return [];
|
|
1202
|
+
const separator = id.indexOf('\0');
|
|
1203
|
+
return [{
|
|
1204
|
+
category: 'structural',
|
|
1205
|
+
code: 'parking-entry-missing',
|
|
1206
|
+
message: `parking entry missing: ${displayTargetSlot(id)}`,
|
|
1207
|
+
targetId: separator < 0 ? undefined : id.slice(0, separator),
|
|
1208
|
+
slot: separator < 0 ? id : id.slice(separator + 1),
|
|
1209
|
+
}];
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
function lockMismatchFindings(report, state) {
|
|
1213
|
+
const previous = previousTargetInventory(state)?.slots ?? {};
|
|
1214
|
+
return report.slots.flatMap((slot) => {
|
|
1215
|
+
const old = previous[slot.id];
|
|
1216
|
+
return isRecord(old) && isRecord(old.provenance) && !slot.provenance ? [{
|
|
1217
|
+
category: 'structural',
|
|
1218
|
+
code: 'lock-file-mismatch',
|
|
1219
|
+
message: `npx-managed Target Slot has a file but no lock entry: ${slot.targetKey}/${slot.name}`,
|
|
1220
|
+
targetId: slot.targetId,
|
|
1221
|
+
slot: slot.name,
|
|
1222
|
+
}] : [];
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
/** Legacy ADR-0007 `.off/` parking inside the discovery root is invisible to the scanner. */
|
|
1226
|
+
function legacyOffEntries(report) {
|
|
1227
|
+
if (report.scope !== 'global')
|
|
1228
|
+
return [];
|
|
1229
|
+
const entries = [];
|
|
1230
|
+
for (const target of report.targets) {
|
|
1231
|
+
if (!target.writable)
|
|
1232
|
+
continue;
|
|
1233
|
+
const offDir = path.join(target.discoveryRoot, '.off');
|
|
1234
|
+
let dirents;
|
|
1235
|
+
try {
|
|
1236
|
+
dirents = fs.readdirSync(offDir, { withFileTypes: true });
|
|
1237
|
+
}
|
|
1238
|
+
catch {
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
for (const dirent of dirents) {
|
|
1242
|
+
if (dirent.name.startsWith('.'))
|
|
1243
|
+
continue;
|
|
1244
|
+
const entryPath = path.join(offDir, dirent.name);
|
|
1245
|
+
const stat = fs.lstatSync(entryPath, { throwIfNoEntry: false });
|
|
1246
|
+
if (!stat)
|
|
1247
|
+
continue;
|
|
1248
|
+
let keep = false;
|
|
1249
|
+
if (stat.isSymbolicLink()) {
|
|
1250
|
+
keep = true;
|
|
1251
|
+
}
|
|
1252
|
+
else if (stat.isDirectory()) {
|
|
1253
|
+
keep = fs.existsSync(path.join(entryPath, 'SKILL.md'));
|
|
1254
|
+
}
|
|
1255
|
+
if (!keep)
|
|
1256
|
+
continue;
|
|
1257
|
+
entries.push({
|
|
1258
|
+
target,
|
|
1259
|
+
name: dirent.name,
|
|
1260
|
+
entryPath,
|
|
1261
|
+
destination: path.join(target.parkingRoot, dirent.name),
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return entries;
|
|
1266
|
+
}
|
|
1267
|
+
function legacyOffFindings(entries) {
|
|
1268
|
+
return entries.map(({ target, name, entryPath }) => ({
|
|
1269
|
+
category: 'structural',
|
|
1270
|
+
code: 'legacy-off',
|
|
1271
|
+
message: `legacy OFF location: ${entryPath} (repair moves it to the parking area)`,
|
|
1272
|
+
targetId: target.id,
|
|
1273
|
+
slot: normalizeSlotName(name),
|
|
1274
|
+
}));
|
|
1275
|
+
}
|
|
1276
|
+
function doctorFindings(report, state, catalog, legacyOff) {
|
|
1277
|
+
return [
|
|
1278
|
+
...staleReferenceFindings(report, catalog),
|
|
1279
|
+
...orphanedPresetFindings(report, state, catalog),
|
|
1280
|
+
...parkingFindings(report, state),
|
|
1281
|
+
...lockMismatchFindings(report, state),
|
|
1282
|
+
...legacyOffFindings(legacyOff),
|
|
1283
|
+
];
|
|
1284
|
+
}
|
|
1285
|
+
function doctorReport(report, state, catalog) {
|
|
1286
|
+
const legacyOff = legacyOffEntries(report);
|
|
1287
|
+
return {
|
|
1288
|
+
...report,
|
|
1289
|
+
findings: [...report.findings, ...doctorFindings(report, state, catalog, legacyOff)],
|
|
1290
|
+
repairs: doctorRepairs(report, state, legacyOff),
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function previousResourceIds(state, relationship) {
|
|
1294
|
+
const slot = previousTargetInventory(state)?.slots[targetSlotId(relationship.targetId, relationship.slot)];
|
|
1295
|
+
if (!isRecord(slot) || !Array.isArray(slot.resourceIds))
|
|
1296
|
+
return new Set();
|
|
1297
|
+
return new Set(slot.resourceIds.filter((id) => typeof id === 'string'));
|
|
1298
|
+
}
|
|
1299
|
+
function retargetDetails(relationship, report, state) {
|
|
1300
|
+
const to = replacementTarget(relationship, report, state);
|
|
1301
|
+
if (!to)
|
|
1302
|
+
return undefined;
|
|
1303
|
+
const targetPath = path.resolve(path.dirname(relationship.path), to);
|
|
1304
|
+
const targetResourceId = report.relationships
|
|
1305
|
+
.find(({ path: entryPath }) => path.resolve(entryPath) === targetPath)?.realPath;
|
|
1306
|
+
if (!targetResourceId)
|
|
1307
|
+
return undefined;
|
|
1308
|
+
const targetHash = report.resources.find(({ id }) => id === targetResourceId)?.hash;
|
|
1309
|
+
return targetHash ? { to, targetResourceId, targetHash } : undefined;
|
|
1310
|
+
}
|
|
1311
|
+
function doctorRepairs(report, state, legacyOff) {
|
|
1312
|
+
const legacyRepairs = legacyOff.flatMap(({ target, name, entryPath, destination }) => {
|
|
1313
|
+
if (fs.lstatSync(destination, { throwIfNoEntry: false }))
|
|
1314
|
+
return [];
|
|
1315
|
+
return [{
|
|
1316
|
+
id: `migrate-legacy-off:${entryPath}`,
|
|
1317
|
+
kind: 'migrate-legacy-off',
|
|
1318
|
+
path: entryPath,
|
|
1319
|
+
from: entryPath,
|
|
1320
|
+
to: destination,
|
|
1321
|
+
targetId: target.id,
|
|
1322
|
+
slot: normalizeSlotName(name),
|
|
1323
|
+
}];
|
|
1324
|
+
});
|
|
1325
|
+
const conflicted = new Set(report.slots.flatMap(({ id, relationships }) => relationships.length > 1 ? [id] : []));
|
|
1326
|
+
const linkRepairs = report.relationships.flatMap((relationship) => {
|
|
1327
|
+
const conflict = conflicted.has(targetSlotId(relationship.targetId, relationship.slot));
|
|
1328
|
+
if (relationship.form !== 'link' || relationship.realPath ||
|
|
1329
|
+
relationship.inspectionError || relationship.readOnly || !relationship.target || conflict)
|
|
1330
|
+
return [];
|
|
1331
|
+
const retarget = retargetDetails(relationship, report, state);
|
|
1332
|
+
const kind = retarget ? 'retarget-link' : 'remove-broken-link';
|
|
1333
|
+
return [{
|
|
1334
|
+
id: `${kind}:${relationship.path}`,
|
|
1335
|
+
kind,
|
|
1336
|
+
path: relationship.path,
|
|
1337
|
+
from: relationship.target,
|
|
1338
|
+
...retarget,
|
|
1339
|
+
targetId: relationship.targetId,
|
|
1340
|
+
slot: relationship.slot,
|
|
1341
|
+
}];
|
|
1342
|
+
});
|
|
1343
|
+
return [...legacyRepairs, ...linkRepairs];
|
|
1344
|
+
}
|
|
1345
|
+
function globalInventory(home, targets, options) {
|
|
1346
|
+
const stateFile = path.join(home.configDir, 'state.json');
|
|
1347
|
+
return scanInventory({
|
|
1348
|
+
scope: 'global',
|
|
1349
|
+
stateFile,
|
|
1350
|
+
catalogStateFile: stateFile,
|
|
1351
|
+
targets: targets.map((target) => ({
|
|
1352
|
+
...target,
|
|
1353
|
+
id: `global:${target.key}`,
|
|
1354
|
+
scope: 'global',
|
|
1355
|
+
writable: true,
|
|
1356
|
+
})),
|
|
1357
|
+
options,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
export function scanGlobalInventory(home, targets = loadTargets(home), options = {}) {
|
|
1361
|
+
return globalInventory(home, targets, options);
|
|
1362
|
+
}
|
|
1363
|
+
function rootIdentity(root) {
|
|
1364
|
+
try {
|
|
1365
|
+
return fs.realpathSync(root);
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
return path.resolve(root);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
function projectTarget(target, directory, scope) {
|
|
1372
|
+
const targetKey = target.kind === 'shared' ? 'shared' : target.key;
|
|
1373
|
+
return {
|
|
1374
|
+
...target,
|
|
1375
|
+
id: `${scope}:${directory}:${target.key}`,
|
|
1376
|
+
scope,
|
|
1377
|
+
writable: scope === 'project',
|
|
1378
|
+
sourceDirectory: directory,
|
|
1379
|
+
discoveryRoot: path.join(directory, target.projectPath),
|
|
1380
|
+
parkingRoot: path.join(directory, '.skillspub', 'off', targetKey),
|
|
1381
|
+
lockFile: target.kind === 'shared'
|
|
1382
|
+
? path.join(directory, 'skills-lock.json')
|
|
1383
|
+
: undefined,
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
export function doctorGlobalInventory(home, targets = loadTargets(home)) {
|
|
1387
|
+
const stateFile = path.join(home.configDir, 'state.json');
|
|
1388
|
+
const report = globalInventory(home, targets, { persist: false });
|
|
1389
|
+
const state = readStateFile(stateFile);
|
|
1390
|
+
return doctorReport(report, state, state);
|
|
1391
|
+
}
|
|
1392
|
+
function projectInventory({ home, selectedPath, targets, options, }) {
|
|
1393
|
+
const projectPath = fs.realpathSync(selectedPath);
|
|
1394
|
+
if (!fs.statSync(projectPath).isDirectory())
|
|
1395
|
+
throw new Error(`Project path is not a directory: ${selectedPath}`);
|
|
1396
|
+
const scanned = targets.map((target) => projectTarget(target, projectPath, 'project'));
|
|
1397
|
+
const globalRoots = new Set(targets.map((target) => rootIdentity(target.discoveryRoot)));
|
|
1398
|
+
for (let directory = path.dirname(projectPath);;) {
|
|
1399
|
+
for (const target of targets) {
|
|
1400
|
+
const inherited = projectTarget(target, directory, 'parent');
|
|
1401
|
+
const exists = fs.existsSync(inherited.discoveryRoot) ||
|
|
1402
|
+
fs.existsSync(inherited.parkingRoot);
|
|
1403
|
+
if (exists && !globalRoots.has(rootIdentity(inherited.discoveryRoot)))
|
|
1404
|
+
scanned.push(inherited);
|
|
1405
|
+
}
|
|
1406
|
+
const parent = path.dirname(directory);
|
|
1407
|
+
if (parent === directory)
|
|
1408
|
+
break;
|
|
1409
|
+
directory = parent;
|
|
1410
|
+
}
|
|
1411
|
+
scanned.push(...targets.map((target) => ({
|
|
1412
|
+
...target,
|
|
1413
|
+
id: `global:${target.key}`,
|
|
1414
|
+
scope: 'global',
|
|
1415
|
+
writable: false,
|
|
1416
|
+
})));
|
|
1417
|
+
return scanInventory({
|
|
1418
|
+
scope: 'project',
|
|
1419
|
+
stateFile: path.join(projectPath, '.skillspub', 'state.json'),
|
|
1420
|
+
catalogStateFile: path.join(home.configDir, 'state.json'),
|
|
1421
|
+
targets: scanned,
|
|
1422
|
+
options,
|
|
1423
|
+
projectPath,
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
export function scanProjectInventory(home, selectedPath, targets = loadTargets(home), options = {}) {
|
|
1427
|
+
return projectInventory({ home, selectedPath, targets, options });
|
|
1428
|
+
}
|
|
1429
|
+
export function doctorProjectInventory(home, selectedPath, targets = loadTargets(home)) {
|
|
1430
|
+
const report = projectInventory({
|
|
1431
|
+
home,
|
|
1432
|
+
selectedPath,
|
|
1433
|
+
targets,
|
|
1434
|
+
options: { persist: false },
|
|
1435
|
+
});
|
|
1436
|
+
return doctorReport(report, readStateFile(report.stateFile), readStateFile(path.join(home.configDir, 'state.json')));
|
|
1437
|
+
}
|