archgen-skill 0.0.6 → 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/lib/install.js CHANGED
@@ -180,7 +180,7 @@ export function installGlobal(opts = {}) {
180
180
  noMcp: opts.mcp === false,
181
181
  });
182
182
  if (!r.ok) {
183
- rows.push(['WARN', '-', 'server bootstrap incomplete — run: npx archgen-mcp bootstrap --location global --yes']);
183
+ rows.push(['WARN', '-', 'server bootstrap incomplete' + (r.detail ? ` (${r.detail})` : '') + ' — run: npx archgen-mcp bootstrap --location global --yes']);
184
184
  }
185
185
  }
186
186
 
@@ -196,17 +196,70 @@ function stampNote(rows, dest, write) {
196
196
  }
197
197
  }
198
198
 
199
- export function uninstallGlobal(home = homedir()) {
199
+ /**
200
+ * The exact destinations a global install may create.
201
+ *
202
+ * A manifest entry is acted on ONLY when it names one of them. A plain
203
+ * suffix test (`endsWith('skills/archgen')`) would let any path with that
204
+ * ending be removed — including paths outside the known harness dirs.
205
+ */
206
+ function allowedGlobalDestinations(home) {
207
+ const set = new Set(globalTargets(home).map((d) => resolve(join(d, 'archgen'))));
208
+ set.add(resolve(join(process.cwd(), '.github', 'skills', 'archgen')));
209
+ return set;
210
+ }
211
+
212
+ /**
213
+ * Move an edited copy-mode install into the global backup vault before it is
214
+ * removed: the uninstall must never be the last place those edits exist.
215
+ * An install that still matches the packaged skill needs no backup.
216
+ */
217
+ function backupEditedCopy(dest, packageRoot, backups) {
218
+ let identical = false;
219
+ try {
220
+ identical = hashDir(dest) === hashDir(resolveSkillSource(packageRoot));
221
+ } catch {
222
+ identical = false; // unverifiable -> preserve it
223
+ }
224
+ if (identical) return;
225
+ const tdir = dirname(dest);
226
+ const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
227
+ backups.push(join(tdir, ...loc.split('/')));
228
+ }
229
+
230
+ /**
231
+ * Remove the globally-installed copies recorded in the manifest.
232
+ *
233
+ * Only entries that name a known harness destination are removed. An entry
234
+ * that cannot be verified is SKIPPED and KEPT in the manifest — the manifest
235
+ * is only removed when nothing was left behind.
236
+ *
237
+ * @param {string} home home directory holding the manifest
238
+ * @param {{packageRoot?: string}} opts
239
+ */
240
+ export function uninstallGlobal(home = homedir(), opts = {}) {
200
241
  const manifest = join(home, MANIFEST_NAME);
201
242
  if (!existsSync(manifest)) return { removed: 0, failed: 0, noop: true };
243
+ const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
244
+ const allowed = allowedGlobalDestinations(home);
202
245
  let removed = 0, failed = 0;
246
+ const kept = [];
247
+ const backups = [];
203
248
  for (const line of readFileSync(manifest, 'utf8').split('\n')) {
204
249
  if (!line.trim()) continue;
205
250
  const [kind, p] = line.split('\t');
206
- if (!p || !p.endsWith(join('skills', 'archgen'))) { failed++; continue; } // safety guard
207
- try { rmSync(p, { force: true, recursive: true }); removed++; }
208
- catch { failed++; }
251
+ if (!p || !allowed.has(resolve(p))) { failed++; kept.push(line); continue; } // safety guard
252
+ try {
253
+ if (kind === 'copy') backupEditedCopy(p, packageRoot, backups);
254
+ rmSync(p, { force: true, recursive: true }); removed++;
255
+ } catch { failed++; kept.push(line); }
256
+ }
257
+ if (kept.length === 0) {
258
+ try { rmSync(manifest, { force: true }); } catch { failed++; }
259
+ } else {
260
+ // The skipped entries stay recorded: the manifest is the only thing that
261
+ // knows they were never removed.
262
+ try { writeFileSync(manifest, kept.join('\n') + '\n'); } catch { failed++; }
209
263
  }
210
- rmSync(manifest, { force: true });
211
- return { removed, failed, noop: false };
264
+ return { removed, failed, noop: false, backups };
212
265
  }
package/lib/mcp-server.js CHANGED
@@ -9,10 +9,11 @@
9
9
  // NEVER FATAL: no npm, no network, a read-only config, or a missing MCP
10
10
  // server must still leave a working skill install. Failures are REPORTED,
11
11
  // never thrown — the same posture bootstrapCodegraph has always had.
12
- import { existsSync } from 'node:fs';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
13
  import { spawnSync } from 'node:child_process';
14
+ import { whichOnPath } from './spawn-npm.js';
14
15
  import { homedir } from 'node:os';
15
- import { join, resolve } from 'node:path';
16
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
16
17
 
17
18
  export const MCP_PACKAGE = 'archgen-mcp';
18
19
 
@@ -52,26 +53,127 @@ export function resolveMcpBinary(opts = {}) {
52
53
 
53
54
  /**
54
55
  * Locate an executable on PATH (cross-platform, no shell).
55
- * @param {string} bin
56
- * @param {Record<string, string|undefined>} env
57
- * @returns {string|null}
56
+ *
57
+ * ONE implementation, in spawn-npm.js — this module used to carry its own
58
+ * copy, and the two had drifted: the copy here skipped a path ending in '/',
59
+ * the one there did not, and neither checked that the path was a FILE. Two
60
+ * implementations of one rule is how the rules start disagreeing.
61
+ *
62
+ * Imported for this module's own use AND re-exported, so the callers that
63
+ * already import it from here keep working.
64
+ */
65
+ export { whichOnPath };
66
+
67
+ /**
68
+ * Quote one argument for cmd.exe.
69
+ *
70
+ * cmd.exe parses `&`, `|`, `<`, `>`, `^` and whitespace as syntax even in an
71
+ * unquoted argument — an unquoted project path is split on spaces and its
72
+ * `&`/`|` are executed. Double quotes neutralize all of them. `%` is the one
73
+ * character cmd expands even inside quotes, so an argument carrying one is
74
+ * refused rather than silently rewritten.
75
+ *
76
+ * @param {string} arg
77
+ * @returns {string} the quoted argument
78
+ */
79
+ export function quoteWindowsArg(arg) {
80
+ const s = String(arg);
81
+ if (s === '') return '""';
82
+ if (/["%]/.test(s)) {
83
+ throw new Error(`refusing to pass an unsafe character through the Windows shell: ${JSON.stringify(s)}`);
84
+ }
85
+ return '"' + s + '"';
86
+ }
87
+
88
+ /**
89
+ * Find the JS entry point a Windows npm shim launches.
90
+ *
91
+ * npm writes `<name>.cmd` beside the package it points at, and the package's
92
+ * package.json names the real script. Resolving it lets the script run with
93
+ * the current node binary — no shell, no quoting, nothing to inject.
94
+ *
95
+ * @param {string} cmdPath path of the .cmd/.bat shim
96
+ * @returns {string|null} absolute path of the JS entry point
58
97
  */
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
- }
98
+ function resolveShimEntry(cmdPath) {
99
+ const name = basename(cmdPath).replace(/\.(cmd|bat)$/i, '');
100
+ const dir = dirname(cmdPath);
101
+ const candidates = [
102
+ join(dir, 'node_modules', MCP_PACKAGE), // global npm prefix
103
+ join(dir, '..', MCP_PACKAGE), // node_modules/.bin -> node_modules/<pkg>
104
+ join(dir, '..', 'node_modules', MCP_PACKAGE), // prefix/lib layout
105
+ ];
106
+ for (const pkgDir of candidates) {
107
+ let pkg;
108
+ try {
109
+ pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
110
+ } catch {
111
+ continue; // not this layout
70
112
  }
113
+ const bin = pkg && pkg.bin;
114
+ const rel = typeof bin === 'string' ? bin : bin && typeof bin === 'object' ? bin[name] : null;
115
+ if (!rel) continue;
116
+ const js = resolve(pkgDir, rel);
117
+ if (existsSync(js)) return js;
71
118
  }
72
119
  return null;
73
120
  }
74
121
 
122
+ /**
123
+ * Build the spawn invocation for the MCP server.
124
+ *
125
+ * On Windows an npm-installed binary is a `.cmd` shim that only cmd.exe can
126
+ * run — and cmd.exe parses unquoted arguments as shell syntax. The shim's JS
127
+ * entry point is preferred (run with the current node binary, no shell at
128
+ * all); when it cannot be resolved, every argument is quoted for cmd.exe.
129
+ *
130
+ * @param {string} command executable to run
131
+ * @param {string[]} args arguments
132
+ * @param {string} [platform] process.platform (injectable for tests)
133
+ * @param {Record<string, string|undefined>} [env] environment used for lookups
134
+ * @returns {{command: string, args: string[], shell: boolean}}
135
+ */
136
+ export function mcpInvocation(command, args, platform = process.platform, env = process.env) {
137
+ if (platform !== 'win32') return { command, args, shell: false };
138
+ let cmd = command;
139
+ if (!/\.(cmd|bat)$/i.test(cmd) && !/[\\/]/.test(cmd)) {
140
+ // A bare name (e.g. 'npx') resolves to a .cmd shim on Windows.
141
+ const shim = whichOnPath(cmd + '.cmd', env);
142
+ if (shim) cmd = shim;
143
+ }
144
+ if (!/\.(cmd|bat)$/i.test(cmd)) return { command, args, shell: false };
145
+ const js = resolveShimEntry(cmd);
146
+ if (js) return { command: process.execPath, args: [js, ...args], shell: false };
147
+ return { command: quoteWindowsArg(cmd), args: args.map(quoteWindowsArg), shell: true };
148
+ }
149
+
150
+ /**
151
+ * The project root an MCP call is made against.
152
+ *
153
+ * It MUST be an ABSOLUTE path.
154
+ *
155
+ * The child process resolves a relative root against ITS OWN working
156
+ * directory, so a root that was missing — or a property-name mismatch
157
+ * between the caller and the callee — did not fail. It silently wrote the
158
+ * harness configs, the CodeGraph index and the managed blocks into a
159
+ * directory named "undefined", next to the real project.
160
+ *
161
+ * The guard turns that class of mistake into an immediate, named failure.
162
+ *
163
+ * @param {unknown} projectRoot
164
+ * @param {string} fn the caller's name, for the message
165
+ * @returns {string} the absolute project root
166
+ */
167
+ export function requireProjectRoot(projectRoot, fn) {
168
+ if (typeof projectRoot !== 'string' || projectRoot.trim() === '') {
169
+ throw new Error(`${fn}: projectRoot is required and must be an absolute path`);
170
+ }
171
+ if (!isAbsolute(projectRoot)) {
172
+ throw new Error(`${fn}: projectRoot must be an absolute path (got '${projectRoot}')`);
173
+ }
174
+ return resolve(projectRoot);
175
+ }
176
+
75
177
  /**
76
178
  * Run one `archgen-mcp` subcommand and return its JSON output.
77
179
  *
@@ -80,19 +182,19 @@ export function whichOnPath(bin, env = process.env) {
80
182
  * @returns {{ok: boolean, code: number|null, data: any, stdout: string, stderr: string, detail?: string}}
81
183
  */
82
184
  export function runMcp(args, opts = {}) {
83
- const { projectRoot = process.cwd(), timeout = 600000, env = process.env } = opts;
185
+ const { timeout = 600000, env = process.env } = opts;
186
+ const projectRoot = requireProjectRoot(opts.projectRoot ?? process.cwd(), 'runMcp');
84
187
  const resolved = resolveMcpBinary({ projectRoot, env });
188
+ const inv = mcpInvocation(resolved.command, [...resolved.args, ...args], process.platform, env);
85
189
 
86
190
  let r;
87
191
  try {
88
- r = spawnSync(resolved.command, [...resolved.args, ...args], {
192
+ r = spawnSync(inv.command, inv.args, {
89
193
  encoding: 'utf8',
90
194
  timeout,
91
195
  env,
92
196
  cwd: projectRoot,
93
- shell: false,
94
- // On Windows the npx shim is a .cmd file, which needs a shell.
95
- ...(process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command) ? { shell: true } : {}),
197
+ shell: inv.shell,
96
198
  });
97
199
  } catch (e) {
98
200
  return { ok: false, code: null, data: null, stdout: '', stderr: '', detail: String(e?.message ?? e) };
@@ -116,6 +218,25 @@ export function runMcp(args, opts = {}) {
116
218
  };
117
219
  }
118
220
 
221
+ /**
222
+ * The arguments for the server's bootstrap call.
223
+ *
224
+ * Exported so the project root in them can be asserted directly: the bug
225
+ * this guards against was a property-name mismatch that put the literal
226
+ * string "undefined" in this exact position.
227
+ *
228
+ * @param {string} projectRoot
229
+ * @param {{location?: 'project'|'global'|'both', approved?: boolean}} [opts]
230
+ * @returns {string[]}
231
+ */
232
+ export function bootstrapArgs(projectRoot, opts = {}) {
233
+ const { location = 'both', approved = true } = opts;
234
+ const root = requireProjectRoot(projectRoot, 'bootstrapArgs');
235
+ const args = ['bootstrap', '--project', root, '--location', location, '--json'];
236
+ if (approved) args.push('--yes');
237
+ return args;
238
+ }
239
+
119
240
  /**
120
241
  * Wire the archgen MCP server into the detected harnesses and ensure CodeGraph.
121
242
  *
@@ -123,11 +244,10 @@ export function runMcp(args, opts = {}) {
123
244
  * @returns {{ok: boolean, skipped?: string, detail?: string, report?: any}}
124
245
  */
125
246
  export function bootstrapMcp(opts) {
126
- const { projectRoot, location = 'both', approved = true, env = process.env } = opts;
127
- const args = ['bootstrap', '--project', projectRoot, '--location', location, '--json'];
128
- if (approved) args.push('--yes');
247
+ const { location = 'both', approved = true, env = process.env } = opts;
248
+ const args = bootstrapArgs(opts.projectRoot, { location, approved });
129
249
 
130
- const r = runMcp(args, { projectRoot, env });
250
+ const r = runMcp(args, { projectRoot: opts.projectRoot, env });
131
251
  if (r.detail) {
132
252
  return { ok: false, detail: `cannot run ${MCP_PACKAGE}: ${r.detail}` };
133
253
  }
@@ -143,7 +263,8 @@ export function bootstrapMcp(opts) {
143
263
  * @param {{projectRoot: string, env?: Record<string, string|undefined>}} opts
144
264
  */
145
265
  export function mcpDoctor(opts) {
146
- const { projectRoot, env = process.env } = opts;
266
+ const { env = process.env } = opts;
267
+ const projectRoot = requireProjectRoot(opts.projectRoot, 'mcpDoctor');
147
268
  const r = runMcp(['doctor', '--project', projectRoot, '--json'], { projectRoot, env, timeout: 60000 });
148
269
  return { ok: r.ok, report: r.data, detail: r.detail ?? (r.data ? undefined : r.stderr.trim().split('\n')[0]) };
149
270
  }
@@ -153,7 +274,8 @@ export function mcpDoctor(opts) {
153
274
  * @param {{projectRoot: string, env?: Record<string, string|undefined>}} opts
154
275
  */
155
276
  export function uninstallMcp(opts) {
156
- const { projectRoot, env = process.env } = opts;
277
+ const { env = process.env } = opts;
278
+ const projectRoot = requireProjectRoot(opts.projectRoot, 'uninstallMcp');
157
279
  const r = runMcp(['uninstall-mcp', '--project', projectRoot, '--yes', '--json'], { projectRoot, env, timeout: 60000 });
158
280
  return { ok: r.ok, report: r.data, detail: r.detail };
159
281
  }
@@ -5,12 +5,12 @@
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
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
- // touched, nothing becomes required.
8
+ // preserves them verbatim), the verifier never sees them as data, and every
9
+ // other YAML consumer ignores them by the same contract. Additive-only: no
10
+ // existing key is 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
package/lib/spawn-npm.js CHANGED
@@ -3,11 +3,12 @@
3
3
  // CVE-2024-27980 hardening, so npm's JS entry point is invoked with the
4
4
  // current node binary instead.
5
5
  import { existsSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
6
7
  import { dirname, join } from 'node:path';
7
8
 
8
9
  /** npm-cli.js path: `npm_execpath` first, then node-relative layouts. */
9
- export function npmCliPath() {
10
- const fromEnv = process.env.npm_execpath;
10
+ export function npmCliPath(env = process.env) {
11
+ const fromEnv = env.npm_execpath;
11
12
  if (typeof fromEnv === 'string' && fromEnv.endsWith('.js') && existsSync(fromEnv)) {
12
13
  return fromEnv;
13
14
  }
@@ -29,19 +30,29 @@ export function npmCliPath() {
29
30
  * Spawn npm with { command, args, shell }. Args MUST be registry-verified
30
31
  * constants (the shell fallback on Windows interpolates them).
31
32
  */
32
- export function npmInvocation(npmArgs) {
33
- const cli = npmCliPath();
33
+ export function npmInvocation(npmArgs, env = process.env) {
34
+ const cli = npmCliPath(env);
34
35
  if (cli) return { command: process.execPath, args: [cli, ...npmArgs], shell: false };
35
36
  const isWin = process.platform === 'win32';
36
37
  return { command: isWin ? 'npm.cmd' : 'npm', args: npmArgs, shell: isWin };
37
38
  }
38
39
 
39
- /** Cross-platform `which` (PATHEXT on Windows). */
40
- export function whichOnPath(bin) {
40
+ /**
41
+ * Cross-platform `which` (PATHEXT on Windows).
42
+ *
43
+ * The env is a PARAMETER, not an assumption: a caller may be searching a
44
+ * modified PATH (a test, a sandboxed HOME), and reading process.env behind
45
+ * its back would answer a different question than the one asked.
46
+ *
47
+ * @param {string} bin
48
+ * @param {Record<string, string|undefined>} [env]
49
+ * @returns {string|null}
50
+ */
51
+ export function whichOnPath(bin, env = process.env) {
41
52
  const exts = process.platform === 'win32'
42
- ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
53
+ ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
43
54
  : [''];
44
- const dirs = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
55
+ const dirs = (env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
45
56
  for (const d of dirs) {
46
57
  for (const ext of exts) {
47
58
  const p = join(d, bin + ext);
@@ -52,3 +63,47 @@ export function whichOnPath(bin) {
52
63
  }
53
64
  return null;
54
65
  }
66
+
67
+ /**
68
+ * The global npm prefix — where `npm install -g` puts its binaries.
69
+ * @returns {string|null} null when npm cannot be queried
70
+ */
71
+ export function npmGlobalPrefix(env = process.env) {
72
+ try {
73
+ const inv = npmInvocation(['prefix', '-g'], env);
74
+ const r = spawnSync(inv.command, inv.args, {
75
+ encoding: 'utf8',
76
+ timeout: 15000,
77
+ shell: inv.shell,
78
+ env,
79
+ });
80
+ if (r.status !== 0 || !r.stdout) return null;
81
+ return r.stdout.trim().split(/\r?\n/).pop() || null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The path of a globally-installed npm binary.
89
+ *
90
+ * A PATH search returns the FIRST match, which is often an OLD global
91
+ * install shadowing the one npm just wrote — so the global prefix (the
92
+ * directory npm actually installs into) is consulted first.
93
+ *
94
+ * @param {string} name binary name, e.g. 'archgen-skill'
95
+ * @returns {string|null}
96
+ */
97
+ export function globalBinPath(name, env = process.env, platform = process.platform) {
98
+ const prefix = npmGlobalPrefix(env);
99
+ if (!prefix) return null;
100
+ const candidates = platform === 'win32'
101
+ ? [join(prefix, name + '.cmd'), join(prefix, name)]
102
+ : [join(prefix, 'bin', name)];
103
+ for (const c of candidates) {
104
+ try {
105
+ if (existsSync(c)) return c;
106
+ } catch { /* next */ }
107
+ }
108
+ return null;
109
+ }