archgen-skill 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -33
- package/bin/archgen.mjs +31 -1
- package/lib/block.js +147 -16
- package/lib/config.js +96 -0
- package/lib/doctor.js +144 -28
- package/lib/init.js +14 -3
- package/lib/install.js +124 -16
- package/lib/migrate.js +88 -0
- package/lib/migrations/001-stamp-provenance.mjs +77 -0
- package/lib/migrations/index.mjs +170 -0
- package/lib/restore.js +239 -0
- package/lib/store.js +125 -15
- package/lib/uninstall-project.js +6 -3
- package/lib/version-stamp.js +49 -0
- package/package.json +39 -10
- package/vendor/skills/archgen/SKILL.md +218 -145
- package/vendor/skills/archgen/archgen.config.json +16 -0
- package/vendor/skills/archgen/assets/skill-registry.json +4 -3
- package/vendor/skills/archgen/references/artifact-templates.md +308 -7
- package/vendor/skills/archgen/references/interview.md +226 -17
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +230 -44
- package/vendor/skills/archgen/references/platforms.md +56 -14
- package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
- package/vendor/skills/archgen/scripts/impact.mjs +14 -1
- package/vendor/skills/archgen/scripts/lib/graph.mjs +99 -5
- package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
- package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
- package/vendor/skills/archgen/scripts/set-status.mjs +51 -9
- package/vendor/skills/archgen/scripts/update-agents.mjs +16 -5
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/doctor.js
CHANGED
|
@@ -3,20 +3,28 @@
|
|
|
3
3
|
// Verifies a project install: canonical store integrity, version stamp,
|
|
4
4
|
// claude link resolution, managed blocks (exactly once each), and manifest
|
|
5
5
|
// entry resolution. Safe issues are repaired in place unless --check is given
|
|
6
|
-
// (report-only).
|
|
6
|
+
// (report-only). Advisory FAIL rows (broken states only a human can fix) are
|
|
7
|
+
// reported and counted in the summary but do NOT fail the run: the process
|
|
8
|
+
// exits non-zero only when a repair operation itself throws.
|
|
7
9
|
|
|
8
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
9
12
|
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { globalTargets } from './install.js';
|
|
10
14
|
import {
|
|
11
15
|
END,
|
|
12
16
|
FEATURES_END,
|
|
13
17
|
FEATURES_START,
|
|
14
18
|
START,
|
|
19
|
+
detectBlockVersion,
|
|
15
20
|
renderBlock,
|
|
16
21
|
upsertFeaturesRegistry,
|
|
17
22
|
upsertManagedFile,
|
|
18
23
|
} from './block.js';
|
|
19
|
-
import {
|
|
24
|
+
import { resolveSkillSource } from './init.js';
|
|
25
|
+
import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, pruneDevOnly, saveManifest } from './store.js';
|
|
26
|
+
import { compareSemver, parseSemver } from './version.js';
|
|
27
|
+
import { readStamp, writeStamp } from './version-stamp.js';
|
|
20
28
|
|
|
21
29
|
function lstatSafe(p) {
|
|
22
30
|
try { return lstatSync(p); } catch { return null; }
|
|
@@ -35,27 +43,76 @@ function relJoin(rel) {
|
|
|
35
43
|
/**
|
|
36
44
|
* @param {string} projectDir project root to inspect
|
|
37
45
|
* @param {string} packageRoot dir containing this CLI package
|
|
38
|
-
* @param {{check?: boolean}} opts check=true → report-only, no
|
|
46
|
+
* @param {{check?: boolean, home?: string}} opts check=true → report-only, no
|
|
47
|
+
* mutations; home overrides the homedir scanned for global installs (tests)
|
|
39
48
|
* @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
|
|
40
|
-
* tally: Record<string, number>, failures: number}}
|
|
49
|
+
* tally: Record<string, number>, failures: number}} `failures` counts
|
|
50
|
+
* repair operations that THEMSELVES threw — advisory FAIL rows are reported
|
|
51
|
+
* but never fail the run.
|
|
41
52
|
*/
|
|
42
53
|
export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
43
54
|
const root = resolve(projectDir);
|
|
44
55
|
const checkOnly = opts.check === true;
|
|
56
|
+
const homeDir = opts.home ?? homedir();
|
|
45
57
|
const version = cliVersion(packageRoot);
|
|
46
58
|
const checks = [];
|
|
47
59
|
const add = (status, msg) => checks.push({ status, msg });
|
|
60
|
+
let opFailures = 0;
|
|
61
|
+
// Run one repair step; a throw becomes a FAIL row instead of aborting the
|
|
62
|
+
// whole doctor pass (and is the only thing that flips the exit code).
|
|
63
|
+
const attempt = (fn, okStatus, okMsg, failLabel) => {
|
|
64
|
+
try {
|
|
65
|
+
fn();
|
|
66
|
+
add(okStatus, okMsg);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
opFailures++;
|
|
69
|
+
add('FAIL', `${failLabel}: ${e.message} - fix manually`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
48
72
|
|
|
49
73
|
const storeAbs = join(root, relJoin(STORE_REL));
|
|
50
74
|
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
51
75
|
const agentsAbs = join(root, 'AGENTS.md');
|
|
52
76
|
const claudeMdAbs = join(root, 'CLAUDE.md');
|
|
53
77
|
|
|
54
|
-
// 1. Canonical store validity.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
// 1. Canonical store validity — SKILL.md alone is not enough: a store
|
|
79
|
+
// missing whole subtrees (scripts/, references/) green-lights installs
|
|
80
|
+
// whose entry points are gone. Divergence from the packaged skill is a
|
|
81
|
+
// WARN, not a failure: user customization is legitimate.
|
|
82
|
+
const deferredStoreFindings = [];
|
|
83
|
+
if (!existsSync(join(storeAbs, 'SKILL.md'))) {
|
|
58
84
|
add('FAIL', 'store .agents/skills/archgen missing or incomplete - run `archgen init`');
|
|
85
|
+
} else {
|
|
86
|
+
const missingCore = ['scripts', 'references'].filter((d) => !existsSync(join(storeAbs, d)));
|
|
87
|
+
if (missingCore.length > 0) {
|
|
88
|
+
// Deferred so the managed-block FAIL rows below stay FIRST: existing
|
|
89
|
+
// consumers read checks.find(FAIL) expecting the marker verdict.
|
|
90
|
+
deferredStoreFindings.push(['FAIL', `store .agents/skills/archgen is missing core ${missingCore.map((d) => d + '/').join(', ')} - run \`archgen init\``]);
|
|
91
|
+
} else {
|
|
92
|
+
let diverged = false;
|
|
93
|
+
try {
|
|
94
|
+
diverged = hashDir(storeAbs) !== hashDir(resolveSkillSource(packageRoot));
|
|
95
|
+
} catch { /* packaged skill unavailable — skip the divergence probe */ }
|
|
96
|
+
if (diverged) add('WARN', 'store .agents/skills/archgen diverges from packaged skill (customized?)');
|
|
97
|
+
else add('OK', 'store .agents/skills/archgen (SKILL.md present)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 1b. Prune dev-only artifacts (scripts/test) that older installers shipped
|
|
102
|
+
// into the store. They are repo-only CI tooling and never belong in an
|
|
103
|
+
// installed tree; removing them here cleans existing stores without a
|
|
104
|
+
// full backup+re-copy. hashDir ignores the same subtrees, so this prune
|
|
105
|
+
// neither masks real customization nor reads as divergence.
|
|
106
|
+
if (existsSync(storeAbs)) {
|
|
107
|
+
const devOnlyPresent = DEV_ONLY_RELPATHS.filter((rel) => existsSync(join(storeAbs, ...rel.split('/'))));
|
|
108
|
+
if (devOnlyPresent.length > 0) {
|
|
109
|
+
if (checkOnly) {
|
|
110
|
+
add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
|
|
111
|
+
} else {
|
|
112
|
+
attempt(() => pruneDevOnly(storeAbs), 'FIXED',
|
|
113
|
+
`dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
59
116
|
}
|
|
60
117
|
|
|
61
118
|
// 2. Version stamp currency.
|
|
@@ -69,9 +126,8 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
69
126
|
} else if (checkOnly) {
|
|
70
127
|
add('WOULD-FIX', `version stamp ${stamp || 'missing'} is stale -> ${version}`);
|
|
71
128
|
} else {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
add('FIXED', `version stamp updated ${stamp || 'missing'} -> ${version}`);
|
|
129
|
+
attempt(() => writeStamp(storeAbs, version), 'FIXED',
|
|
130
|
+
`version stamp updated ${stamp || 'missing'} -> ${version}`, 'cannot write version stamp');
|
|
75
131
|
}
|
|
76
132
|
|
|
77
133
|
// 3. Claude link resolution.
|
|
@@ -107,32 +163,62 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
107
163
|
if (!existsSync(agentsAbs)) {
|
|
108
164
|
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
|
|
109
165
|
else {
|
|
110
|
-
|
|
111
|
-
|
|
166
|
+
// Through the guarded upsert (not raw writeFileSync): existsSync follows
|
|
167
|
+
// symlinks, so a dangling symlink would otherwise be written through.
|
|
168
|
+
attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
|
|
169
|
+
'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
|
|
112
170
|
}
|
|
113
171
|
} else {
|
|
114
|
-
|
|
172
|
+
let raw = readFileSync(agentsAbs, 'utf8');
|
|
115
173
|
const starts = countOccurrences(raw, START);
|
|
116
174
|
const ends = countOccurrences(raw, END);
|
|
117
|
-
if (starts === 0) {
|
|
175
|
+
if (starts === 0 && ends === 0) {
|
|
118
176
|
if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
|
|
119
177
|
else {
|
|
120
|
-
upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n'))
|
|
121
|
-
|
|
178
|
+
attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
|
|
179
|
+
'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
|
|
122
180
|
}
|
|
123
181
|
} else if (starts > 1 || ends > 1) {
|
|
124
182
|
add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
|
|
125
|
-
} else if (ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
183
|
+
} else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
184
|
+
// Orphan/inverted markers: repairing through upsertBlock would throw, so
|
|
185
|
+
// classify as FAIL instead of attempting the write.
|
|
126
186
|
add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
|
|
127
187
|
} else {
|
|
188
|
+
// Block-format currency: an unversioned or older-stamped managed block is
|
|
189
|
+
// upgraded in place through the normal upsert path (everything outside the
|
|
190
|
+
// markers survives byte-for-byte by design). Newer stamps are left alone.
|
|
191
|
+
const det = detectBlockVersion(raw);
|
|
192
|
+
const parsed = det.version ? parseSemver(det.version) : null;
|
|
193
|
+
const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
|
|
194
|
+
if (stale) {
|
|
195
|
+
const from = det.version ? 'v' + det.version : 'unversioned';
|
|
196
|
+
if (checkOnly) {
|
|
197
|
+
add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
|
|
198
|
+
} else {
|
|
199
|
+
let upgraded = false;
|
|
200
|
+
attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
|
|
201
|
+
'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
|
|
202
|
+
if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
128
205
|
const fsCount = countOccurrences(raw, FEATURES_START);
|
|
129
206
|
const feCount = countOccurrences(raw, FEATURES_END);
|
|
130
|
-
|
|
131
|
-
|
|
207
|
+
const fsIdx = raw.indexOf(FEATURES_START);
|
|
208
|
+
const feIdx = raw.indexOf(FEATURES_END);
|
|
209
|
+
if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
|
|
210
|
+
add('OK', 'AGENTS.md block + features registry present');
|
|
211
|
+
} else if (fsCount === 1 && feCount === 1) {
|
|
212
|
+
// Counts are right but ORDER is inverted (end before start): any
|
|
213
|
+
// registry write would throw (upsertFeaturesRegistry requires
|
|
214
|
+
// end > start), so flag the state instead of blessing it as OK.
|
|
215
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
|
|
216
|
+
else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
|
|
217
|
+
} else if (fsCount === 0 && feCount === 0) {
|
|
132
218
|
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
|
|
133
219
|
else {
|
|
134
|
-
upsertFeaturesRegistry(agentsAbs, [])
|
|
135
|
-
|
|
220
|
+
attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
|
|
221
|
+
'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
|
|
136
222
|
}
|
|
137
223
|
} else {
|
|
138
224
|
add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
|
|
@@ -144,8 +230,8 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
144
230
|
if (!existsSync(claudeMdAbs)) {
|
|
145
231
|
if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing -> created with @AGENTS.md bridge');
|
|
146
232
|
else {
|
|
147
|
-
upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END])
|
|
148
|
-
|
|
233
|
+
attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
|
|
234
|
+
'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
|
|
149
235
|
}
|
|
150
236
|
} else {
|
|
151
237
|
const raw = readFileSync(claudeMdAbs, 'utf8');
|
|
@@ -155,13 +241,16 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
155
241
|
const ends = countOccurrences(raw, END);
|
|
156
242
|
if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
|
|
157
243
|
else if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
|
|
244
|
+
else if (starts !== ends) add('FAIL', 'CLAUDE.md has broken archgen markers - fix manually');
|
|
158
245
|
else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
|
|
159
246
|
else {
|
|
160
|
-
upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END])
|
|
161
|
-
|
|
247
|
+
attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
|
|
248
|
+
'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
|
|
162
249
|
}
|
|
163
250
|
}
|
|
164
251
|
|
|
252
|
+
for (const [status, msg] of deferredStoreFindings) add(status, msg);
|
|
253
|
+
|
|
165
254
|
// 6. Manifest entries resolve.
|
|
166
255
|
const manifest = loadManifest(root);
|
|
167
256
|
if (!manifest) {
|
|
@@ -180,7 +269,34 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
180
269
|
}
|
|
181
270
|
}
|
|
182
271
|
|
|
272
|
+
// 7. Version-stamp inventory — every archgen skill tree visible from this
|
|
273
|
+
// project/machine, one line each, for tools that probe
|
|
274
|
+
// <skillRoot>/.archgen-version (the VS Code extension reads it to warn
|
|
275
|
+
// "skill outdated"). Report-only: doctor repairs the project stamp in
|
|
276
|
+
// section 2, never global installs.
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
const installs = [];
|
|
279
|
+
const noteInstall = (p) => {
|
|
280
|
+
// Dedupe by realpath (two harness dirs may link one tree) but DISPLAY the
|
|
281
|
+
// discovered path — that is the path tools probe <skillRoot>/.archgen-version through.
|
|
282
|
+
let real;
|
|
283
|
+
try { real = realpathSync(p); } catch { return; } // vanished mid-scan
|
|
284
|
+
if (!seen.has(real)) { seen.add(real); installs.push(p); }
|
|
285
|
+
};
|
|
286
|
+
if (existsSync(storeAbs)) noteInstall(storeAbs);
|
|
287
|
+
for (const t of globalTargets(homeDir)) {
|
|
288
|
+
const p = join(t, 'archgen');
|
|
289
|
+
if (existsSync(p)) noteInstall(p);
|
|
290
|
+
}
|
|
291
|
+
const ghSkills = join(root, '.github', 'skills', 'archgen');
|
|
292
|
+
if (existsSync(ghSkills)) noteInstall(ghSkills);
|
|
293
|
+
for (const p of installs) {
|
|
294
|
+
const v = readStamp(p);
|
|
295
|
+
const slot = v ?? (existsSync(join(p, VERSION_FILE)) ? 'unknown (pre-0.1 stamp)' : 'missing');
|
|
296
|
+
add(v ? 'OK' : 'WARN', `skill ${p}: ${slot}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
183
299
|
const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
|
|
184
300
|
for (const c of checks) tally[c.status]++;
|
|
185
|
-
return { root, checks, tally, failures:
|
|
301
|
+
return { root, checks, tally, failures: opFailures };
|
|
186
302
|
}
|
package/lib/init.js
CHANGED
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
// to a symlink; a customized one is left untouched with a warning;
|
|
18
18
|
// - installs are atomic: built in a temp sibling dir, then renamed.
|
|
19
19
|
|
|
20
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync
|
|
20
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { dirname, join, resolve } from 'node:path';
|
|
23
23
|
import { importsAgents, renderBlock, START, END, upsertManagedFile } from './block.js';
|
|
24
|
-
import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, cliVersion, hashDir, moveToBackup } from './store.js';
|
|
24
|
+
import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackup, pruneDevOnly } from './store.js';
|
|
25
|
+
import { writeStamp } from './version-stamp.js';
|
|
25
26
|
|
|
26
27
|
const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
27
28
|
|
|
@@ -79,6 +80,9 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
79
80
|
return 'kept-divergent';
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Escape check: a symlinked .claude/.claude/skills parent must not redirect
|
|
84
|
+
// the adapter creation outside the project root.
|
|
85
|
+
assertWithinRoot(root, claudeAbs);
|
|
82
86
|
try {
|
|
83
87
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
84
88
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -115,6 +119,12 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
115
119
|
|
|
116
120
|
const storeAbs = join(root, relJoin(STORE_REL));
|
|
117
121
|
|
|
122
|
+
// Supply-chain guards BEFORE any mutation: a symlinked store (a malicious
|
|
123
|
+
// clone can ship .agents/skills/archgen as a link to an unrelated dir) is
|
|
124
|
+
// refused, and the resolved real path must stay inside the project root.
|
|
125
|
+
assertNotSymlink(storeAbs);
|
|
126
|
+
assertWithinRoot(root, storeAbs);
|
|
127
|
+
|
|
118
128
|
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
119
129
|
const prev = lstatSafe(storeAbs);
|
|
120
130
|
if (prev) {
|
|
@@ -128,7 +138,8 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
128
138
|
mkdirSync(skillsParent, { recursive: true });
|
|
129
139
|
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
130
140
|
cpSync(source, tmp, { recursive: true });
|
|
131
|
-
|
|
141
|
+
pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
|
|
142
|
+
writeStamp(tmp, version);
|
|
132
143
|
rmSync(storeAbs, { recursive: true, force: true });
|
|
133
144
|
renameSync(tmp, storeAbs);
|
|
134
145
|
appendEntry(root, 'dir', STORE_REL);
|
package/lib/install.js
CHANGED
|
@@ -2,19 +2,31 @@
|
|
|
2
2
|
// Installs the archgen skill into every EXISTING harness skills dir; manifest
|
|
3
3
|
// records entries so --uninstall removes exactly what we put there.
|
|
4
4
|
|
|
5
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, lstatSync, unlinkSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { resolveSkillSource } from './init.js';
|
|
10
|
+
import { assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackupInto, pruneDevOnly, refuseSymlinkMessage } from './store.js';
|
|
11
|
+
import { writeStamp } from './version-stamp.js';
|
|
12
|
+
import { loadConfig } from './config.js';
|
|
10
13
|
|
|
11
14
|
// Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
|
|
12
15
|
// AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
|
|
13
16
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
17
|
|
|
15
|
-
const MANIFEST_NAME =
|
|
18
|
+
export const MANIFEST_NAME = loadConfig().files.globalManifest;
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
// Global backup vault: global installs live OUTSIDE any project, so there is
|
|
21
|
+
// no project root to host `.archgen/.backup/`. Convention chosen instead: each
|
|
22
|
+
// harness skills dir keeps its own sibling vault at
|
|
23
|
+
// <skills-dir>/.archgen-backups/<ISO-timestamp>/archgen/
|
|
24
|
+
// — a dot-prefixed sibling (never scanned as a skill by harnesses), colocated
|
|
25
|
+
// with what it protects, and discovered by lib/restore.js alongside the
|
|
26
|
+
// project vault.
|
|
27
|
+
export const GLOBAL_BACKUP_REL = '.archgen-backups';
|
|
28
|
+
|
|
29
|
+
export function globalTargets(home = homedir()) {
|
|
18
30
|
return [
|
|
19
31
|
join(home, '.claude', 'skills'),
|
|
20
32
|
join(home, '.agents', 'skills'),
|
|
@@ -24,6 +36,7 @@ function globalTargets(home = homedir()) {
|
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
function record(manifest, kind, path) {
|
|
39
|
+
assertNotSymlink(manifest);
|
|
27
40
|
const line = `${kind}\t${path}`;
|
|
28
41
|
let dup = false;
|
|
29
42
|
if (existsSync(manifest)) {
|
|
@@ -32,41 +45,136 @@ function record(manifest, kind, path) {
|
|
|
32
45
|
if (!dup) writeFileSync(manifest, (existsSync(manifest) ? readFileSync(manifest, 'utf8').replace(/\n*$/, '\n') : '') + line + '\n');
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
function lstatSafe(p) {
|
|
49
|
+
try { return lstatSync(p); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Collision-free timestamp for `<tdir>/.archgen-backups/<ts>/` (ms clock can tick twice). */
|
|
53
|
+
function nextBackupTs(tdir) {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
56
|
+
if (!existsSync(join(tdir, GLOBAL_BACKUP_REL, ts))) return ts;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
/**
|
|
36
|
-
* @param {{copy?: boolean, home?: string}} opts
|
|
61
|
+
* @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
|
|
62
|
+
* @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
|
|
63
|
+
* backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
|
|
64
|
+
* absolute vault locations written by this run.
|
|
37
65
|
*/
|
|
38
66
|
export function installGlobal(opts = {}) {
|
|
39
67
|
const home = opts.home ?? homedir();
|
|
40
68
|
if (!home) throw new Error('$HOME is not set; cannot resolve target directories.');
|
|
41
|
-
const
|
|
69
|
+
const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
|
|
70
|
+
const source = resolve(resolveSkillSource(packageRoot));
|
|
71
|
+
const version = cliVersion(packageRoot);
|
|
42
72
|
const manifest = join(home, MANIFEST_NAME);
|
|
43
73
|
const rows = [];
|
|
74
|
+
const backups = [];
|
|
44
75
|
let failures = 0;
|
|
45
76
|
|
|
46
|
-
const targets = [
|
|
47
|
-
|
|
77
|
+
const targets = [
|
|
78
|
+
...globalTargets(home).map((d) => [d, home]),
|
|
79
|
+
[join(process.cwd(), '.github', 'skills'), process.cwd()],
|
|
80
|
+
];
|
|
81
|
+
for (const [tdir, base] of targets) {
|
|
48
82
|
const dest = join(tdir, 'archgen');
|
|
49
83
|
if (!existsSync(tdir)) { rows.push(['SKIP', '-', tdir + ' (does not exist)']); continue; }
|
|
50
84
|
try {
|
|
85
|
+
// Realpath-escape guard: a symlinked harness dir (e.g. ~/.claude -> /etc)
|
|
86
|
+
// must not redirect the install outside the base it belongs to.
|
|
87
|
+
assertWithinRoot(base, tdir);
|
|
51
88
|
mkdirSync(tdir, { recursive: true }); // no-op when present
|
|
52
89
|
if (opts.copy) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
// Safety invariant: fingerprint dest BEFORE mutating; identical
|
|
91
|
+
// (stamp-ignoring) skips like link mode, divergent moves into the
|
|
92
|
+
// vault first — nothing is rmSync'd without a prior backup of it.
|
|
93
|
+
const prev = lstatSafe(dest);
|
|
94
|
+
if (prev && prev.isSymbolicLink()) {
|
|
95
|
+
// A symlink at dest is only ours when it points at this source (a
|
|
96
|
+
// previous link-mode install). Any other link is foreign: replacing
|
|
97
|
+
// it would let a planted symlink steer the copy, so refuse.
|
|
98
|
+
let linkTarget = null;
|
|
99
|
+
try { linkTarget = resolve(dirname(dest), readlinkSync(dest)); } catch { /* unreadable */ }
|
|
100
|
+
if (linkTarget !== source) throw new Error(refuseSymlinkMessage(dest));
|
|
101
|
+
}
|
|
102
|
+
let identical = false;
|
|
103
|
+
if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
|
|
104
|
+
try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
|
|
105
|
+
}
|
|
106
|
+
if (identical) {
|
|
107
|
+
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
108
|
+
record(manifest, 'copy', dest);
|
|
109
|
+
rows.push(['SAME', 'copy', dest]);
|
|
110
|
+
} else {
|
|
111
|
+
if (prev) {
|
|
112
|
+
const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
|
|
113
|
+
const abs = join(tdir, ...loc.split('/'));
|
|
114
|
+
backups.push(abs);
|
|
115
|
+
rows.push(['BACKED-UP', '-', abs]);
|
|
116
|
+
}
|
|
117
|
+
rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
|
|
118
|
+
assertNotSymlink(dest); // TOCTOU re-check: nothing may have re-linked dest between backup and copy
|
|
119
|
+
cpSync(source, dest, { recursive: true });
|
|
120
|
+
pruneDevOnly(dest); // dev-only artifacts (scripts/test) never reach global --copy trees
|
|
121
|
+
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
122
|
+
record(manifest, 'copy', dest);
|
|
123
|
+
rows.push(['OK', 'copy', dest]);
|
|
124
|
+
}
|
|
57
125
|
} else {
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
if (
|
|
126
|
+
let status = 'OK';
|
|
127
|
+
const prev = lstatSafe(dest);
|
|
128
|
+
if (prev && prev.isSymbolicLink()) {
|
|
129
|
+
if (existsSync(dest)) {
|
|
130
|
+
// A live link resolving anywhere but our source belongs to the
|
|
131
|
+
// user: leave it untouched and OUT of the uninstall manifest, or
|
|
132
|
+
// `uninstall` would later delete their link. (install.sh replaces
|
|
133
|
+
// foreign links via ln -sfn — that destructive semantic is
|
|
134
|
+
// deliberately NOT copied here.)
|
|
135
|
+
const linkTarget = resolve(dirname(dest), readlinkSync(dest));
|
|
136
|
+
if (linkTarget !== source) {
|
|
137
|
+
rows.push(['KEPT', '-', dest + ' (foreign symlink, not recorded)']);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
status = 'SAME';
|
|
141
|
+
} else {
|
|
142
|
+
// Dangling (npx cache eviction): recreate against the current
|
|
143
|
+
// source instead of leaving users broken while reporting SAME.
|
|
144
|
+
unlinkSync(dest); // removes the link itself, never any target
|
|
145
|
+
symlinkSync(source, dest, 'dir');
|
|
146
|
+
status = 'REPAIRED';
|
|
147
|
+
}
|
|
148
|
+
} else if (!prev) {
|
|
149
|
+
symlinkSync(source, dest, 'dir');
|
|
150
|
+
} else {
|
|
151
|
+
// Real directory/file at dest is NEVER clobbered: symlinkSync fails
|
|
152
|
+
// with EEXIST and surfaces as FAILED below (historical behavior).
|
|
153
|
+
symlinkSync(source, dest, 'dir');
|
|
154
|
+
}
|
|
155
|
+
// The link itself holds no bytes: the stamp must live INSIDE the real
|
|
156
|
+
// directory the link resolves to (the shared copy) so a read of
|
|
157
|
+
// <link>/.archgen-version sees it — true for symlinks and Windows
|
|
158
|
+
// junctions alike.
|
|
159
|
+
stampNote(rows, dest, () => writeStamp(source, version));
|
|
61
160
|
record(manifest, 'link', dest);
|
|
62
|
-
rows.push([
|
|
161
|
+
rows.push([status, 'link', dest]);
|
|
63
162
|
}
|
|
64
163
|
} catch (e) {
|
|
65
164
|
failures++;
|
|
66
165
|
rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
|
|
67
166
|
}
|
|
68
167
|
}
|
|
69
|
-
return { rows, failures, manifest };
|
|
168
|
+
return { rows, failures, manifest, backups };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Install succeeded even when the stamp could not be written (e.g. read-only vendor); surface it without failing. */
|
|
172
|
+
function stampNote(rows, dest, write) {
|
|
173
|
+
try {
|
|
174
|
+
write();
|
|
175
|
+
} catch (e) {
|
|
176
|
+
rows.push(['WARN', '-', dest + ' (installed; version stamp not written: ' + e.message + ')']);
|
|
177
|
+
}
|
|
70
178
|
}
|
|
71
179
|
|
|
72
180
|
export function uninstallGlobal(home = homedir()) {
|
package/lib/migrate.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// migrate.js — `archgen migrate [dir] [--check] [--apply] [--migration <id>]`.
|
|
2
|
+
//
|
|
3
|
+
// Evolves generated-artifact formats in existing projects with zero data loss.
|
|
4
|
+
// Tone and safety model mirror doctor: dry-run is the DEFAULT (--check lists
|
|
5
|
+
// pending migrations and the exact files they would touch, writing nothing);
|
|
6
|
+
// --apply runs them, snapshotting every affected file into
|
|
7
|
+
// .archgen/.backup/<ts>/migrate/ BEFORE any modification. Exit codes:
|
|
8
|
+
// 0 nothing pending, or all migrations applied cleanly
|
|
9
|
+
// 1 a migration failed mid-apply (earlier files stay backed up)
|
|
10
|
+
// 2 usage error (unknown flag / unknown --migration id / --check + --apply)
|
|
11
|
+
//
|
|
12
|
+
// bin/archgen.mjs wiring (integrator):
|
|
13
|
+
// case 'migrate': process.exitCode = await migrateMain(rest); break;
|
|
14
|
+
|
|
15
|
+
import { applyMigration, listMigrations, pendingMigrations } from './migrations/index.mjs';
|
|
16
|
+
import { resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const USAGE = 'usage: archgen-skill migrate [dir] [--check|--apply] [--migration <id>]';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string[]} argv args AFTER the subcommand, e.g. ['myproj', '--apply']
|
|
22
|
+
* @returns {Promise<number>} exit code int
|
|
23
|
+
*/
|
|
24
|
+
export async function migrateMain(argv = []) {
|
|
25
|
+
let dir = null;
|
|
26
|
+
let check = false;
|
|
27
|
+
let apply = false;
|
|
28
|
+
let target = null;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === '--check') check = true;
|
|
33
|
+
else if (a === '--apply') apply = true;
|
|
34
|
+
else if (a === '--migration') {
|
|
35
|
+
target = argv[++i];
|
|
36
|
+
if (!target || target.startsWith('-')) { console.error(USAGE); return 2; }
|
|
37
|
+
} else if (a.startsWith('-')) {
|
|
38
|
+
console.error(`unknown flag: ${a}\n${USAGE}`);
|
|
39
|
+
return 2;
|
|
40
|
+
} else if (dir === null) dir = a;
|
|
41
|
+
else { console.error(`unexpected argument: ${a}\n${USAGE}`); return 2; }
|
|
42
|
+
}
|
|
43
|
+
if (check && apply) { console.error('--check and --apply are mutually exclusive\n' + USAGE); return 2; }
|
|
44
|
+
|
|
45
|
+
const dryRun = !apply; // check mode is the default, always
|
|
46
|
+
const root = resolve(dir ?? process.cwd());
|
|
47
|
+
|
|
48
|
+
const all = await listMigrations();
|
|
49
|
+
if (target && !all.some((m) => m.id === target)) {
|
|
50
|
+
console.error(`unknown migration '${target}' (available: ${all.map((m) => m.id).join(', ') || 'none'})`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const pending = (await pendingMigrations(root)).filter((p) => !target || p.id === target);
|
|
55
|
+
|
|
56
|
+
console.log('archgen migrate — ' + root + (dryRun ? ' (check mode: no changes made)' : ''));
|
|
57
|
+
|
|
58
|
+
if (pending.length === 0) {
|
|
59
|
+
console.log(' = nothing to do — all migrations applied');
|
|
60
|
+
console.log('Summary: up to date — 0 pending');
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let fileCount = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
for (const p of pending) {
|
|
67
|
+
if (dryRun) {
|
|
68
|
+
console.log(` [would-apply] ${p.id} — ${p.description}`);
|
|
69
|
+
for (const f of p.files) console.log(` ${f}`);
|
|
70
|
+
fileCount += p.files.length;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const r = await applyMigration(p.id, root, { dryRun: false });
|
|
75
|
+
if (r.status === 'clean') { console.log(` [skip] ${r.id} — already applied`); continue; }
|
|
76
|
+
console.log(` [applied] ${r.id} — ${r.description}`);
|
|
77
|
+
for (const b of r.backups ?? []) console.log(` ${b.file} (backup: ${b.backup})`);
|
|
78
|
+
fileCount += (r.files ?? []).length;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
failed++;
|
|
81
|
+
console.log(` [fail] ${p.id} — ${e instanceof Error ? e.message : String(e)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (dryRun) console.log(`Summary: ${pending.length} migration(s) pending, ${fileCount} file(s) would change`);
|
|
86
|
+
else console.log(`Summary: ${pending.length - failed} applied, ${fileCount} file(s) changed, ${failed} failure(s)`);
|
|
87
|
+
return failed > 0 ? 1 : 0;
|
|
88
|
+
}
|