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/README.md +8 -10
- package/bin/archgen.mjs +135 -13
- package/lib/block.js +51 -10
- package/lib/bootstrap.js +33 -7
- package/lib/config.js +3 -2
- package/lib/doctor.js +159 -82
- package/lib/global-server.js +154 -0
- package/lib/init.js +53 -17
- package/lib/install.js +60 -7
- package/lib/mcp-server.js +149 -27
- 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 +63 -8
- package/lib/store.js +100 -23
- package/lib/uninstall-project.js +47 -9
- package/lib/version-stamp.js +2 -2
- package/lib/version.js +9 -1
- package/package.json +3 -3
- package/vendor/skills/archgen/SKILL.md +156 -375
- package/vendor/skills/archgen/archgen.config.json +1 -1
- package/vendor/skills/archgen/references/code-standards.md +28 -44
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
|
-
|
|
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 || !
|
|
207
|
-
try {
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 {
|
|
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(
|
|
192
|
+
r = spawnSync(inv.command, inv.args, {
|
|
89
193
|
encoding: 'utf8',
|
|
90
194
|
timeout,
|
|
91
195
|
env,
|
|
92
196
|
cwd: projectRoot,
|
|
93
|
-
shell:
|
|
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 {
|
|
127
|
-
const args =
|
|
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 {
|
|
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 {
|
|
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
|
|
9
|
-
//
|
|
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
|
|
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
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 =
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
-
? (
|
|
53
|
+
? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
|
|
43
54
|
: [''];
|
|
44
|
-
const dirs = (
|
|
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
|
+
}
|