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.
- package/README.md +20 -7
- package/bin/archgen.mjs +142 -15
- package/lib/block.js +52 -11
- package/lib/bootstrap.js +107 -0
- package/lib/config.js +3 -2
- package/lib/doctor.js +197 -77
- package/lib/init.js +77 -19
- package/lib/install.js +79 -7
- package/lib/mcp-server.js +241 -0
- package/lib/migrations/001-stamp-provenance.mjs +15 -8
- package/lib/migrations/index.mjs +19 -8
- package/lib/restore.js +52 -16
- package/lib/spawn-npm.js +99 -0
- package/lib/store.js +175 -28
- package/lib/uninstall-project.js +38 -7
- package/lib/version.js +20 -4
- package/package.json +3 -3
- package/vendor/skills/archgen/SKILL.md +363 -213
- package/vendor/skills/archgen/archgen.config.json +1 -1
- package/vendor/skills/archgen/references/code-standards.md +2 -2
- package/vendor/skills/archgen/assets/mcp-template.json +0 -42
- package/vendor/skills/archgen/assets/skill-registry.json +0 -24
- package/vendor/skills/archgen/references/artifact-templates.md +0 -406
- package/vendor/skills/archgen/references/interview.md +0 -275
- package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
- package/vendor/skills/archgen/references/orchestration.md +0 -292
- package/vendor/skills/archgen/references/platforms.md +0 -138
- package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
- package/vendor/skills/archgen/scripts/impact.mjs +0 -77
- package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
- package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
- package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
- package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
- package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
- package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
- package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
- package/vendor/skills/archgen/scripts/validate.mjs +0 -87
- 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
|
-
//
|
|
8
|
-
// them verbatim),
|
|
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
|
|
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
|
|
72
|
-
const abs = join(ctx.projectRoot, ...
|
|
73
|
-
|
|
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
|
}
|
package/lib/migrations/index.mjs
CHANGED
|
@@ -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
|
|
11
|
-
// file the migration declared via its optional `files(ctx)` export
|
|
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)
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
ADDED
|
@@ -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
|
+
}
|