archgen-skill 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +20 -7
  2. package/bin/archgen.mjs +142 -15
  3. package/lib/block.js +52 -11
  4. package/lib/bootstrap.js +107 -0
  5. package/lib/config.js +3 -2
  6. package/lib/doctor.js +197 -77
  7. package/lib/init.js +77 -19
  8. package/lib/install.js +79 -7
  9. package/lib/mcp-server.js +241 -0
  10. package/lib/migrations/001-stamp-provenance.mjs +15 -8
  11. package/lib/migrations/index.mjs +19 -8
  12. package/lib/restore.js +52 -16
  13. package/lib/spawn-npm.js +99 -0
  14. package/lib/store.js +175 -28
  15. package/lib/uninstall-project.js +38 -7
  16. package/lib/version.js +20 -4
  17. package/package.json +3 -3
  18. package/vendor/skills/archgen/SKILL.md +363 -213
  19. package/vendor/skills/archgen/archgen.config.json +1 -1
  20. package/vendor/skills/archgen/references/code-standards.md +2 -2
  21. package/vendor/skills/archgen/assets/mcp-template.json +0 -42
  22. package/vendor/skills/archgen/assets/skill-registry.json +0 -24
  23. package/vendor/skills/archgen/references/artifact-templates.md +0 -406
  24. package/vendor/skills/archgen/references/interview.md +0 -275
  25. package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
  26. package/vendor/skills/archgen/references/orchestration.md +0 -292
  27. package/vendor/skills/archgen/references/platforms.md +0 -138
  28. package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
  29. package/vendor/skills/archgen/scripts/impact.mjs +0 -77
  30. package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
  31. package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
  32. package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
  33. package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
  34. package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
  35. package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
  36. package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
  37. package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
  38. package/vendor/skills/archgen/scripts/validate.mjs +0 -87
  39. package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
@@ -0,0 +1,241 @@
1
+ // mcp-server.js — locate and drive the `archgen-mcp` server from the CLI.
2
+ //
3
+ // WHY THE CLI DELEGATES: archgen's deterministic capability now lives in ONE
4
+ // place — the MCP server. The CLI used to shell out to the skill's
5
+ // scripts/codegraph.mjs; it now shells out to `archgen-mcp bootstrap`, which
6
+ // runs the same code the agent's tools run. There is no second implementation
7
+ // of harness wiring, CodeGraph bootstrap, or doctor logic.
8
+ //
9
+ // NEVER FATAL: no npm, no network, a read-only config, or a missing MCP
10
+ // server must still leave a working skill install. Failures are REPORTED,
11
+ // never thrown — the same posture bootstrapCodegraph has always had.
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { homedir } from 'node:os';
15
+ import { basename, dirname, join, resolve } from 'node:path';
16
+
17
+ export const MCP_PACKAGE = 'archgen-mcp';
18
+
19
+ /**
20
+ * Locate the archgen-mcp binary.
21
+ *
22
+ * Resolution order (first hit wins):
23
+ * 1. $ARCHGEN_MCP_BIN — explicit override
24
+ * 2. <project>/node_modules/.bin/archgen-mcp
25
+ * 3. `archgen-mcp` on PATH — a global npm install
26
+ * 4. `npx -y archgen-mcp` — network fallback (npx caches it)
27
+ *
28
+ * @param {{projectRoot?: string, home?: string, env?: Record<string, string|undefined>}} opts
29
+ * @returns {{bin: string|null, source: 'override'|'local'|'path'|'npx', command: string, args: string[]}}
30
+ */
31
+ export function resolveMcpBinary(opts = {}) {
32
+ const { projectRoot = process.cwd(), home = homedir(), env = process.env } = opts;
33
+
34
+ const override = env.ARCHGEN_MCP_BIN;
35
+ if (override && existsSync(override)) {
36
+ return { bin: override, source: 'override', command: override, args: [] };
37
+ }
38
+
39
+ const local = join(
40
+ resolve(projectRoot),
41
+ 'node_modules',
42
+ '.bin',
43
+ process.platform === 'win32' ? 'archgen-mcp.cmd' : 'archgen-mcp',
44
+ );
45
+ if (existsSync(local)) return { bin: local, source: 'local', command: local, args: [] };
46
+
47
+ const onPath = whichOnPath('archgen-mcp', env);
48
+ if (onPath) return { bin: onPath, source: 'path', command: onPath, args: [] };
49
+
50
+ return { bin: null, source: 'npx', command: 'npx', args: ['-y', MCP_PACKAGE] };
51
+ }
52
+
53
+ /**
54
+ * Locate an executable on PATH (cross-platform, no shell).
55
+ * @param {string} bin
56
+ * @param {Record<string, string|undefined>} env
57
+ * @returns {string|null}
58
+ */
59
+ export function whichOnPath(bin, env = process.env) {
60
+ const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';') : [''];
61
+ const dirs = (env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
62
+ for (const d of dirs) {
63
+ for (const ext of exts) {
64
+ const p = join(d, bin + ext);
65
+ try {
66
+ if (existsSync(p) && !p.endsWith('/')) return p;
67
+ } catch {
68
+ /* not here */
69
+ }
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Quote one argument for cmd.exe.
77
+ *
78
+ * cmd.exe parses `&`, `|`, `<`, `>`, `^` and whitespace as syntax even in an
79
+ * unquoted argument — an unquoted project path is split on spaces and its
80
+ * `&`/`|` are executed. Double quotes neutralize all of them. `%` is the one
81
+ * character cmd expands even inside quotes, so an argument carrying one is
82
+ * refused rather than silently rewritten.
83
+ *
84
+ * @param {string} arg
85
+ * @returns {string} the quoted argument
86
+ */
87
+ export function quoteWindowsArg(arg) {
88
+ const s = String(arg);
89
+ if (s === '') return '""';
90
+ if (/["%]/.test(s)) {
91
+ throw new Error(`refusing to pass an unsafe character through the Windows shell: ${JSON.stringify(s)}`);
92
+ }
93
+ return '"' + s + '"';
94
+ }
95
+
96
+ /**
97
+ * Find the JS entry point a Windows npm shim launches.
98
+ *
99
+ * npm writes `<name>.cmd` beside the package it points at, and the package's
100
+ * package.json names the real script. Resolving it lets the script run with
101
+ * the current node binary — no shell, no quoting, nothing to inject.
102
+ *
103
+ * @param {string} cmdPath path of the .cmd/.bat shim
104
+ * @returns {string|null} absolute path of the JS entry point
105
+ */
106
+ function resolveShimEntry(cmdPath) {
107
+ const name = basename(cmdPath).replace(/\.(cmd|bat)$/i, '');
108
+ const dir = dirname(cmdPath);
109
+ const candidates = [
110
+ join(dir, 'node_modules', MCP_PACKAGE), // global npm prefix
111
+ join(dir, '..', MCP_PACKAGE), // node_modules/.bin -> node_modules/<pkg>
112
+ join(dir, '..', 'node_modules', MCP_PACKAGE), // prefix/lib layout
113
+ ];
114
+ for (const pkgDir of candidates) {
115
+ let pkg;
116
+ try {
117
+ pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
118
+ } catch {
119
+ continue; // not this layout
120
+ }
121
+ const bin = pkg && pkg.bin;
122
+ const rel = typeof bin === 'string' ? bin : bin && typeof bin === 'object' ? bin[name] : null;
123
+ if (!rel) continue;
124
+ const js = resolve(pkgDir, rel);
125
+ if (existsSync(js)) return js;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Build the spawn invocation for the MCP server.
132
+ *
133
+ * On Windows an npm-installed binary is a `.cmd` shim that only cmd.exe can
134
+ * run — and cmd.exe parses unquoted arguments as shell syntax. The shim's JS
135
+ * entry point is preferred (run with the current node binary, no shell at
136
+ * all); when it cannot be resolved, every argument is quoted for cmd.exe.
137
+ *
138
+ * @param {string} command executable to run
139
+ * @param {string[]} args arguments
140
+ * @param {string} [platform] process.platform (injectable for tests)
141
+ * @param {Record<string, string|undefined>} [env] environment used for lookups
142
+ * @returns {{command: string, args: string[], shell: boolean}}
143
+ */
144
+ export function mcpInvocation(command, args, platform = process.platform, env = process.env) {
145
+ if (platform !== 'win32') return { command, args, shell: false };
146
+ let cmd = command;
147
+ if (!/\.(cmd|bat)$/i.test(cmd) && !/[\\/]/.test(cmd)) {
148
+ // A bare name (e.g. 'npx') resolves to a .cmd shim on Windows.
149
+ const shim = whichOnPath(cmd + '.cmd', env);
150
+ if (shim) cmd = shim;
151
+ }
152
+ if (!/\.(cmd|bat)$/i.test(cmd)) return { command, args, shell: false };
153
+ const js = resolveShimEntry(cmd);
154
+ if (js) return { command: process.execPath, args: [js, ...args], shell: false };
155
+ return { command: quoteWindowsArg(cmd), args: args.map(quoteWindowsArg), shell: true };
156
+ }
157
+
158
+ /**
159
+ * Run one `archgen-mcp` subcommand and return its JSON output.
160
+ *
161
+ * @param {string[]} args the subcommand + flags
162
+ * @param {{projectRoot?: string, timeout?: number, env?: Record<string, string|undefined>}} opts
163
+ * @returns {{ok: boolean, code: number|null, data: any, stdout: string, stderr: string, detail?: string}}
164
+ */
165
+ export function runMcp(args, opts = {}) {
166
+ const { projectRoot = process.cwd(), timeout = 600000, env = process.env } = opts;
167
+ const resolved = resolveMcpBinary({ projectRoot, env });
168
+ const inv = mcpInvocation(resolved.command, [...resolved.args, ...args], process.platform, env);
169
+
170
+ let r;
171
+ try {
172
+ r = spawnSync(inv.command, inv.args, {
173
+ encoding: 'utf8',
174
+ timeout,
175
+ env,
176
+ cwd: projectRoot,
177
+ shell: inv.shell,
178
+ });
179
+ } catch (e) {
180
+ return { ok: false, code: null, data: null, stdout: '', stderr: '', detail: String(e?.message ?? e) };
181
+ }
182
+ if (r.error) {
183
+ return { ok: false, code: null, data: null, stdout: '', stderr: '', detail: String(r.error.message ?? r.error) };
184
+ }
185
+ const stdout = r.stdout ?? '';
186
+ let data = null;
187
+ try {
188
+ data = JSON.parse(stdout);
189
+ } catch {
190
+ /* not JSON — the caller renders the raw output */
191
+ }
192
+ return {
193
+ ok: r.status === 0,
194
+ code: r.status ?? null,
195
+ data,
196
+ stdout,
197
+ stderr: r.stderr ?? '',
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Wire the archgen MCP server into the detected harnesses and ensure CodeGraph.
203
+ *
204
+ * @param {{projectRoot: string, location?: 'project'|'global'|'both', approved?: boolean, env?: Record<string, string|undefined>}} opts
205
+ * @returns {{ok: boolean, skipped?: string, detail?: string, report?: any}}
206
+ */
207
+ export function bootstrapMcp(opts) {
208
+ const { projectRoot, location = 'both', approved = true, env = process.env } = opts;
209
+ const args = ['bootstrap', '--project', projectRoot, '--location', location, '--json'];
210
+ if (approved) args.push('--yes');
211
+
212
+ const r = runMcp(args, { projectRoot, env });
213
+ if (r.detail) {
214
+ return { ok: false, detail: `cannot run ${MCP_PACKAGE}: ${r.detail}` };
215
+ }
216
+ if (r.data === null) {
217
+ const firstLine = (r.stderr || r.stdout || '').trim().split('\n')[0];
218
+ return { ok: false, detail: firstLine || `${MCP_PACKAGE} bootstrap produced no output` };
219
+ }
220
+ return { ok: r.ok, report: r.data, detail: r.ok ? undefined : r.stderr.trim().split('\n')[0] };
221
+ }
222
+
223
+ /**
224
+ * Health-check the archgen MCP install.
225
+ * @param {{projectRoot: string, env?: Record<string, string|undefined>}} opts
226
+ */
227
+ export function mcpDoctor(opts) {
228
+ const { projectRoot, env = process.env } = opts;
229
+ const r = runMcp(['doctor', '--project', projectRoot, '--json'], { projectRoot, env, timeout: 60000 });
230
+ return { ok: r.ok, report: r.data, detail: r.detail ?? (r.data ? undefined : r.stderr.trim().split('\n')[0]) };
231
+ }
232
+
233
+ /**
234
+ * Remove the archgen MCP entries.
235
+ * @param {{projectRoot: string, env?: Record<string, string|undefined>}} opts
236
+ */
237
+ export function uninstallMcp(opts) {
238
+ const { projectRoot, env = process.env } = opts;
239
+ const r = runMcp(['uninstall-mcp', '--project', projectRoot, '--yes', '--json'], { projectRoot, env, timeout: 60000 });
240
+ return { ok: r.ok, report: r.data, detail: r.detail };
241
+ }
@@ -4,13 +4,13 @@
4
4
  //
5
5
  // What it writes: three YAML COMMENT lines at the very top of tasks.yaml and
6
6
  // architecture.yaml. Comments are inert to every consumer by contract — the
7
- // skill's zero-dep parser records them positionally (set-status.mjs preserves
8
- // them verbatim), verify-plan.mjs never sees them as data, and the VS Code
9
- // extension port behaves identically. Additive-only: no existing key is
7
+ // MCP core's parser records them positionally (`archgen_tasks{set_status}`
8
+ // preserves them verbatim), the verifier never sees them as data, and the VS
9
+ // Code extension port behaves identically. Additive-only: no existing key is
10
10
  // touched, nothing becomes required.
11
11
  //
12
12
  // # schema_version: 1
13
- // # generator: archgen v0.0.4 (version token omitted when unknowable)
13
+ // # generator: archgen v1.2.3 (version token omitted when unknowable)
14
14
  // # generated_at: 2026-08-26T09:00:00.000Z
15
15
  //
16
16
  // Idempotency: detect/files/apply all key off the `# schema_version:` marker
@@ -66,11 +66,18 @@ export function files(ctx) {
66
66
  return unstampedArtifacts(ctx).map((a) => a.rel);
67
67
  }
68
68
 
69
- export function apply(ctx) {
69
+ export function apply(ctx, files) {
70
+ // The framework passes the EXACT list it backed up: re-deriving the file
71
+ // set here could mutate a file that appeared after the backup and was
72
+ // therefore never backed up.
73
+ const rels = Array.isArray(files)
74
+ ? files
75
+ : unstampedArtifacts(ctx).map((a) => a.rel);
70
76
  const generatedAt = new Date().toISOString(); // one timestamp per run
71
- for (const a of unstampedArtifacts(ctx)) {
72
- const abs = join(ctx.projectRoot, ...a.rel.split('/'));
73
- const content = readFileSync(abs, 'utf8');
77
+ for (const rel of rels) {
78
+ const abs = join(ctx.projectRoot, ...rel.split('/'));
79
+ let content;
80
+ try { content = readFileSync(abs, 'utf8'); } catch { continue; } // vanished mid-run
74
81
  if (MARKER_RE.test(content)) continue; // re-check: never double-stamp
75
82
  writeFileSync(abs, stampLines(ctx.projectRoot, generatedAt).join('\n') + '\n' + content);
76
83
  }
@@ -7,9 +7,10 @@
7
7
  // - every migration is ONE numbered file (`NNN-name.mjs`) next to this file;
8
8
  // - `detect(ctx)` decides whether a project still needs it (must be cheap,
9
9
  // read-only, and false once applied — migrations are idempotent);
10
- // - `apply(ctx)` mutates, but ONLY after the framework has snapshotted every
11
- // file the migration declared via its optional `files(ctx)` export into
12
- // `.archgen/.backup/<timestamp>/migrate/`;
10
+ // - `apply(ctx, files)` mutates, but ONLY after the framework has snapshotted
11
+ // every file the migration declared via its optional `files(ctx)` export
12
+ // into `.archgen/.backup/<timestamp>/migrate/`; the framework hands apply
13
+ // the exact list it backed up, so the mutated set IS the backed-up set;
13
14
  // - dry-run is the DEFAULT everywhere: nothing is written unless a caller
14
15
  // explicitly passes `{ dryRun: false }`.
15
16
  //
@@ -19,7 +20,8 @@
19
20
  // export const fromSchema — source schema number or null (pre-schema files)
20
21
  // export const toSchema — target schema number
21
22
  // export function detect(ctx) -> boolean
22
- // export function apply(ctx) -> void (mutates; framework backed up first)
23
+ // export function apply(ctx, files) -> void (mutates exactly `files`;
24
+ // the framework backed them up first)
23
25
  // export function files(ctx) -> string[] of project-relative paths that
24
26
  // apply() may touch (drives --check output
25
27
  // AND the mandatory pre-apply backup set)
@@ -163,8 +165,17 @@ export async function applyMigration(id, projectRoot, opts = {}) {
163
165
  if (!mig.detect(ctx)) return { status: 'clean', id };
164
166
  const files = mig.files ? mig.files(ctx).slice() : [];
165
167
  if (dryRun) return { status: 'would-apply', id, description: mig.description, files };
166
- // Backup EVERYTHING the migration declared before it touches anything.
167
- const backups = files.map((file) => ({ file, backup: ctx.backup(file) }));
168
- await mig.apply(ctx);
169
- return { status: 'applied', id, description: mig.description, files, backups };
168
+ // Backup EVERYTHING the migration declared before it touches anything, then
169
+ // hand apply() the exact list that was backed up: the mutated set is then
170
+ // the backed-up set, never a freshly re-derived one.
171
+ const backedUp = [];
172
+ const backups = [];
173
+ for (const file of files) {
174
+ const abs = join(ctx.projectRoot, ...file.split('/'));
175
+ if (!existsSync(abs)) continue; // vanished between the scan and now
176
+ backups.push({ file, backup: ctx.backup(file) });
177
+ backedUp.push(file);
178
+ }
179
+ await mig.apply(ctx, backedUp);
180
+ return { status: 'applied', id, description: mig.description, files: backedUp, backups };
170
181
  }
package/lib/restore.js CHANGED
@@ -14,14 +14,10 @@
14
14
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
15
15
  import { randomBytes } from 'node:crypto';
16
16
  import { homedir } from 'node:os';
17
- import { dirname, join, relative, resolve } from 'node:path';
18
- import { BACKUP_ROOT_REL, moveToBackupInto } from './store.js';
17
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
18
+ import { BACKUP_PATHS_FILE, BACKUP_ROOT_REL, moveToBackupInto, readBackupPaths } from './store.js';
19
19
  import { GLOBAL_BACKUP_REL, globalTargets } from './install.js';
20
20
 
21
- function toPosix(p) {
22
- return p.split('\\').join('/');
23
- }
24
-
25
21
  function byName(a, b) {
26
22
  return a < b ? -1 : a > b ? 1 : 0;
27
23
  }
@@ -35,10 +31,11 @@ function summarize(dir) {
35
31
  let dirs = 0;
36
32
  (function walk(d) {
37
33
  for (const e of readdirSync(d, { withFileTypes: true })) {
34
+ if (e.name === BACKUP_PATHS_FILE) continue; // metadata, not backed-up content
38
35
  if (e.isDirectory()) { dirs++; walk(join(d, e.name)); } else { files++; }
39
36
  }
40
37
  })(dir);
41
- const topLevel = readdirSync(dir).sort(byName);
38
+ const topLevel = readdirSync(dir).filter((n) => n !== BACKUP_PATHS_FILE).sort(byName);
42
39
  return { files, dirs, topLevel };
43
40
  }
44
41
 
@@ -102,6 +99,47 @@ function layDown(srcAbs, destAbs) {
102
99
  renameSync(tmp, destAbs);
103
100
  }
104
101
 
102
+ /** A recorded path is usable only when it stays inside the destination base. */
103
+ function isSafeRel(rel) {
104
+ if (typeof rel !== 'string' || rel === '') return false;
105
+ if (isAbsolute(rel)) return false;
106
+ return !rel.split('/').some((seg) => seg === '..' || seg === '');
107
+ }
108
+
109
+ /** Every FILE a snapshot holds, relative to the snapshot dir (legacy snapshots). */
110
+ function snapshotFiles(snapshotDir, prefix = '') {
111
+ const out = [];
112
+ for (const e of readdirSync(join(snapshotDir, prefix), { withFileTypes: true })) {
113
+ if (e.name === BACKUP_PATHS_FILE) continue;
114
+ const rel = prefix ? prefix + '/' + e.name : e.name;
115
+ if (e.isDirectory()) out.push(...snapshotFiles(snapshotDir, rel));
116
+ else out.push(rel);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * The paths a snapshot restores, relative to the destination base.
123
+ *
124
+ * The snapshot's OWN path record is authoritative: it names the exact paths
125
+ * that were backed up. Restoring the snapshot's top-level directory instead
126
+ * would replace a whole `.agents/` tree and take every sibling skill with it.
127
+ * Snapshots written before the record existed fall back to per-file restore:
128
+ * still exact about WHAT is written, never about what is deleted.
129
+ */
130
+ function restoreEntries(snap, destBase) {
131
+ const recorded = readBackupPaths(snap.snapshotDir);
132
+ const rels = (recorded && recorded.length > 0)
133
+ ? recorded.filter(isSafeRel)
134
+ : snapshotFiles(snap.snapshotDir).filter(isSafeRel);
135
+ return rels.map((rel) => ({
136
+ rel,
137
+ name: rel.split('/').pop(),
138
+ srcAbs: join(snap.snapshotDir, ...rel.split('/')),
139
+ destAbs: join(destBase, ...rel.split('/')),
140
+ }));
141
+ }
142
+
105
143
  /**
106
144
  * Restore backups discovered across the project and global vaults.
107
145
  *
@@ -141,21 +179,17 @@ export function restoreBackups(projectRoot, options = {}) {
141
179
  const snap = matches[0];
142
180
 
143
181
  const destBase = options.target ? resolve(options.target) : snap.base;
144
- const entries = readdirSync(snap.snapshotDir, { withFileTypes: true })
145
- .map((e) => e.name)
146
- .sort(byName)
147
- .map((name) => ({ name, srcAbs: join(snap.snapshotDir, name), destAbs: join(destBase, name) }));
182
+ const entries = restoreEntries(snap, destBase);
148
183
 
149
184
  // Pass 1 — safety: move EVERY existing destination aside before laying down
150
185
  // anything, so a mid-restore failure still leaves current state fully backed up.
151
186
  const safetyBackups = [];
152
187
  const safetyRootRel = snap.origin === 'global' ? GLOBAL_BACKUP_REL : BACKUP_ROOT_REL;
153
- const safetyBase = snap.origin === 'global' ? snap.base : destBase;
188
+ const safetyBase = destBase;
154
189
  const safetyTs = uniqueSafetyTs(safetyBase, safetyRootRel);
155
190
  for (const en of entries) {
156
191
  if (!lstatSafe(en.destAbs)) continue;
157
- const rel = snap.origin === 'global' ? en.name : toPosix(relative(destBase, en.destAbs));
158
- const loc = moveToBackupInto(safetyBase, rel, safetyRootRel, safetyTs);
192
+ const loc = moveToBackupInto(safetyBase, en.rel, safetyRootRel, safetyTs);
159
193
  const abs = join(safetyBase, ...loc.split('/'));
160
194
  safetyBackups.push({ rel: loc, abs });
161
195
  }
@@ -181,10 +215,12 @@ function parseFlags(argv) {
181
215
  if (a === '--list') opts.list = true;
182
216
  else if (a === '--snapshot') {
183
217
  const v = argv[++i];
184
- if (!v || v.startsWith('--')) return { error: '--snapshot requires a timestamp argument' };
218
+ // A flag-shaped value is never a value: '-foo' is an option, not an id.
219
+ if (!v || v.startsWith('-')) return { error: '--snapshot requires a timestamp argument' };
185
220
  opts.snapshot = v;
186
221
  } else if (a === '--project') {
187
- const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : process.cwd();
222
+ const next = argv[i + 1];
223
+ const v = next && !next.startsWith('-') ? argv[++i] : process.cwd();
188
224
  opts.project = v;
189
225
  } else if (a === '--global') opts.global = true;
190
226
  else if (a === '--yes') opts.yes = true; // accepted for CLI ergonomics; restore is interactive-free
@@ -0,0 +1,99 @@
1
+ // spawn-npm.js — run npm on every platform without a shell.
2
+ // An unshelled spawnSync("npm", …) fails with EINVAL on Windows since the
3
+ // CVE-2024-27980 hardening, so npm's JS entry point is invoked with the
4
+ // current node binary instead.
5
+ import { existsSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ /** npm-cli.js path: `npm_execpath` first, then node-relative layouts. */
10
+ export function npmCliPath(env = process.env) {
11
+ const fromEnv = env.npm_execpath;
12
+ if (typeof fromEnv === 'string' && fromEnv.endsWith('.js') && existsSync(fromEnv)) {
13
+ return fromEnv;
14
+ }
15
+ const dir = dirname(process.execPath);
16
+ const candidates =
17
+ process.platform === 'win32'
18
+ ? [join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js')] // Windows: node.exe sits beside node_modules/
19
+ : [join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')]; // POSIX: bin/../lib/...
20
+ for (const c of candidates) if (existsSync(c)) return c;
21
+ // Homebrew cellar layout: /opt/homebrew/{bin,lib}/...
22
+ if (process.platform !== 'win32') {
23
+ const brew = join(dir, '..', '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
24
+ if (existsSync(brew)) return brew;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Spawn npm with { command, args, shell }. Args MUST be registry-verified
31
+ * constants (the shell fallback on Windows interpolates them).
32
+ */
33
+ export function npmInvocation(npmArgs, env = process.env) {
34
+ const cli = npmCliPath(env);
35
+ if (cli) return { command: process.execPath, args: [cli, ...npmArgs], shell: false };
36
+ const isWin = process.platform === 'win32';
37
+ return { command: isWin ? 'npm.cmd' : 'npm', args: npmArgs, shell: isWin };
38
+ }
39
+
40
+ /** Cross-platform `which` (PATHEXT on Windows). */
41
+ export function whichOnPath(bin) {
42
+ const exts = process.platform === 'win32'
43
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
44
+ : [''];
45
+ const dirs = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
46
+ for (const d of dirs) {
47
+ for (const ext of exts) {
48
+ const p = join(d, bin + ext);
49
+ try {
50
+ if (existsSync(p)) return p;
51
+ } catch { /* next */ }
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * The global npm prefix — where `npm install -g` puts its binaries.
59
+ * @returns {string|null} null when npm cannot be queried
60
+ */
61
+ export function npmGlobalPrefix(env = process.env) {
62
+ try {
63
+ const inv = npmInvocation(['prefix', '-g'], env);
64
+ const r = spawnSync(inv.command, inv.args, {
65
+ encoding: 'utf8',
66
+ timeout: 15000,
67
+ shell: inv.shell,
68
+ env,
69
+ });
70
+ if (r.status !== 0 || !r.stdout) return null;
71
+ return r.stdout.trim().split(/\r?\n/).pop() || null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The path of a globally-installed npm binary.
79
+ *
80
+ * A PATH search returns the FIRST match, which is often an OLD global
81
+ * install shadowing the one npm just wrote — so the global prefix (the
82
+ * directory npm actually installs into) is consulted first.
83
+ *
84
+ * @param {string} name binary name, e.g. 'archgen-skill'
85
+ * @returns {string|null}
86
+ */
87
+ export function globalBinPath(name, env = process.env, platform = process.platform) {
88
+ const prefix = npmGlobalPrefix(env);
89
+ if (!prefix) return null;
90
+ const candidates = platform === 'win32'
91
+ ? [join(prefix, name + '.cmd'), join(prefix, name)]
92
+ : [join(prefix, 'bin', name)];
93
+ for (const c of candidates) {
94
+ try {
95
+ if (existsSync(c)) return c;
96
+ } catch { /* next */ }
97
+ }
98
+ return null;
99
+ }