archgen-skill 0.0.3 → 0.0.4
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 +10 -0
- package/bin/archgen.mjs +23 -0
- package/lib/block.js +94 -9
- package/lib/config.js +96 -0
- package/lib/doctor.js +123 -26
- package/lib/init.js +3 -2
- package/lib/install.js +105 -14
- package/lib/migrate.js +88 -0
- package/lib/migrations/001-stamp-provenance.mjs +77 -0
- package/lib/migrations/index.mjs +170 -0
- package/lib/restore.js +239 -0
- package/lib/store.js +27 -12
- package/lib/uninstall-project.js +6 -3
- package/lib/version-stamp.js +47 -0
- package/package.json +37 -9
- package/vendor/skills/archgen/SKILL.md +242 -74
- package/vendor/skills/archgen/archgen.config.json +16 -0
- package/vendor/skills/archgen/assets/skill-registry.json +4 -3
- package/vendor/skills/archgen/references/artifact-templates.md +251 -7
- package/vendor/skills/archgen/references/interview.md +180 -16
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +102 -39
- package/vendor/skills/archgen/references/platforms.md +1 -14
- package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
- package/vendor/skills/archgen/scripts/impact.mjs +14 -1
- package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
- package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
- package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
- package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
- package/vendor/skills/archgen/scripts/update-agents.mjs +4 -2
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/install.js
CHANGED
|
@@ -2,19 +2,31 @@
|
|
|
2
2
|
// Installs the archgen skill into every EXISTING harness skills dir; manifest
|
|
3
3
|
// records entries so --uninstall removes exactly what we put there.
|
|
4
4
|
|
|
5
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, lstatSync, unlinkSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { resolveSkillSource } from './init.js';
|
|
10
|
+
import { cliVersion, hashDir, moveToBackupInto } from './store.js';
|
|
11
|
+
import { writeStamp } from './version-stamp.js';
|
|
12
|
+
import { loadConfig } from './config.js';
|
|
10
13
|
|
|
11
14
|
// Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
|
|
12
15
|
// AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
|
|
13
16
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
17
|
|
|
15
|
-
const MANIFEST_NAME =
|
|
18
|
+
export const MANIFEST_NAME = loadConfig().files.globalManifest;
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
// Global backup vault: global installs live OUTSIDE any project, so there is
|
|
21
|
+
// no project root to host `.archgen/.backup/`. Convention chosen instead: each
|
|
22
|
+
// harness skills dir keeps its own sibling vault at
|
|
23
|
+
// <skills-dir>/.archgen-backups/<ISO-timestamp>/archgen/
|
|
24
|
+
// — a dot-prefixed sibling (never scanned as a skill by harnesses), colocated
|
|
25
|
+
// with what it protects, and discovered by lib/restore.js alongside the
|
|
26
|
+
// project vault.
|
|
27
|
+
export const GLOBAL_BACKUP_REL = '.archgen-backups';
|
|
28
|
+
|
|
29
|
+
export function globalTargets(home = homedir()) {
|
|
18
30
|
return [
|
|
19
31
|
join(home, '.claude', 'skills'),
|
|
20
32
|
join(home, '.agents', 'skills'),
|
|
@@ -32,15 +44,33 @@ function record(manifest, kind, path) {
|
|
|
32
44
|
if (!dup) writeFileSync(manifest, (existsSync(manifest) ? readFileSync(manifest, 'utf8').replace(/\n*$/, '\n') : '') + line + '\n');
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
function lstatSafe(p) {
|
|
48
|
+
try { return lstatSync(p); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Collision-free timestamp for `<tdir>/.archgen-backups/<ts>/` (ms clock can tick twice). */
|
|
52
|
+
function nextBackupTs(tdir) {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
55
|
+
if (!existsSync(join(tdir, GLOBAL_BACKUP_REL, ts))) return ts;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
/**
|
|
36
|
-
* @param {{copy?: boolean, home?: string}} opts
|
|
60
|
+
* @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
|
|
61
|
+
* @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
|
|
62
|
+
* backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
|
|
63
|
+
* absolute vault locations written by this run.
|
|
37
64
|
*/
|
|
38
65
|
export function installGlobal(opts = {}) {
|
|
39
66
|
const home = opts.home ?? homedir();
|
|
40
67
|
if (!home) throw new Error('$HOME is not set; cannot resolve target directories.');
|
|
41
|
-
const
|
|
68
|
+
const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
|
|
69
|
+
const source = resolve(resolveSkillSource(packageRoot));
|
|
70
|
+
const version = cliVersion(packageRoot);
|
|
42
71
|
const manifest = join(home, MANIFEST_NAME);
|
|
43
72
|
const rows = [];
|
|
73
|
+
const backups = [];
|
|
44
74
|
let failures = 0;
|
|
45
75
|
|
|
46
76
|
const targets = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
|
|
@@ -50,23 +80,84 @@ export function installGlobal(opts = {}) {
|
|
|
50
80
|
try {
|
|
51
81
|
mkdirSync(tdir, { recursive: true }); // no-op when present
|
|
52
82
|
if (opts.copy) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
// Safety invariant: fingerprint dest BEFORE mutating; identical
|
|
84
|
+
// (stamp-ignoring) skips like link mode, divergent moves into the
|
|
85
|
+
// vault first — nothing is rmSync'd without a prior backup of it.
|
|
86
|
+
const prev = lstatSafe(dest);
|
|
87
|
+
let identical = false;
|
|
88
|
+
if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
|
|
89
|
+
try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
|
|
90
|
+
}
|
|
91
|
+
if (identical) {
|
|
92
|
+
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
93
|
+
record(manifest, 'copy', dest);
|
|
94
|
+
rows.push(['SAME', 'copy', dest]);
|
|
95
|
+
} else {
|
|
96
|
+
if (prev) {
|
|
97
|
+
const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
|
|
98
|
+
const abs = join(tdir, ...loc.split('/'));
|
|
99
|
+
backups.push(abs);
|
|
100
|
+
rows.push(['BACKED-UP', '-', abs]);
|
|
101
|
+
}
|
|
102
|
+
rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
|
|
103
|
+
cpSync(source, dest, { recursive: true });
|
|
104
|
+
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
105
|
+
record(manifest, 'copy', dest);
|
|
106
|
+
rows.push(['OK', 'copy', dest]);
|
|
107
|
+
}
|
|
57
108
|
} else {
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
if (
|
|
109
|
+
let status = 'OK';
|
|
110
|
+
const prev = lstatSafe(dest);
|
|
111
|
+
if (prev && prev.isSymbolicLink()) {
|
|
112
|
+
if (existsSync(dest)) {
|
|
113
|
+
// A live link resolving anywhere but our source belongs to the
|
|
114
|
+
// user: leave it untouched and OUT of the uninstall manifest, or
|
|
115
|
+
// `uninstall` would later delete their link. (install.sh replaces
|
|
116
|
+
// foreign links via ln -sfn — that destructive semantic is
|
|
117
|
+
// deliberately NOT copied here.)
|
|
118
|
+
const linkTarget = resolve(dirname(dest), readlinkSync(dest));
|
|
119
|
+
if (linkTarget !== source) {
|
|
120
|
+
rows.push(['KEPT', '-', dest + ' (foreign symlink, not recorded)']);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
status = 'SAME';
|
|
124
|
+
} else {
|
|
125
|
+
// Dangling (npx cache eviction): recreate against the current
|
|
126
|
+
// source instead of leaving users broken while reporting SAME.
|
|
127
|
+
unlinkSync(dest); // removes the link itself, never any target
|
|
128
|
+
symlinkSync(source, dest, 'dir');
|
|
129
|
+
status = 'REPAIRED';
|
|
130
|
+
}
|
|
131
|
+
} else if (!prev) {
|
|
132
|
+
symlinkSync(source, dest, 'dir');
|
|
133
|
+
} else {
|
|
134
|
+
// Real directory/file at dest is NEVER clobbered: symlinkSync fails
|
|
135
|
+
// with EEXIST and surfaces as FAILED below (historical behavior).
|
|
136
|
+
symlinkSync(source, dest, 'dir');
|
|
137
|
+
}
|
|
138
|
+
// The link itself holds no bytes: the stamp must live INSIDE the real
|
|
139
|
+
// directory the link resolves to (the shared copy) so a read of
|
|
140
|
+
// <link>/.archgen-version sees it — true for symlinks and Windows
|
|
141
|
+
// junctions alike.
|
|
142
|
+
stampNote(rows, dest, () => writeStamp(source, version));
|
|
61
143
|
record(manifest, 'link', dest);
|
|
62
|
-
rows.push([
|
|
144
|
+
rows.push([status, 'link', dest]);
|
|
63
145
|
}
|
|
64
146
|
} catch (e) {
|
|
65
147
|
failures++;
|
|
66
148
|
rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
|
|
67
149
|
}
|
|
68
150
|
}
|
|
69
|
-
return { rows, failures, manifest };
|
|
151
|
+
return { rows, failures, manifest, backups };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Install succeeded even when the stamp could not be written (e.g. read-only vendor); surface it without failing. */
|
|
155
|
+
function stampNote(rows, dest, write) {
|
|
156
|
+
try {
|
|
157
|
+
write();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
rows.push(['WARN', '-', dest + ' (installed; version stamp not written: ' + e.message + ')']);
|
|
160
|
+
}
|
|
70
161
|
}
|
|
71
162
|
|
|
72
163
|
export function uninstallGlobal(home = homedir()) {
|
package/lib/migrate.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// migrate.js — `archgen migrate [dir] [--check] [--apply] [--migration <id>]`.
|
|
2
|
+
//
|
|
3
|
+
// Evolves generated-artifact formats in existing projects with zero data loss.
|
|
4
|
+
// Tone and safety model mirror doctor: dry-run is the DEFAULT (--check lists
|
|
5
|
+
// pending migrations and the exact files they would touch, writing nothing);
|
|
6
|
+
// --apply runs them, snapshotting every affected file into
|
|
7
|
+
// .archgen/.backup/<ts>/migrate/ BEFORE any modification. Exit codes:
|
|
8
|
+
// 0 nothing pending, or all migrations applied cleanly
|
|
9
|
+
// 1 a migration failed mid-apply (earlier files stay backed up)
|
|
10
|
+
// 2 usage error (unknown flag / unknown --migration id / --check + --apply)
|
|
11
|
+
//
|
|
12
|
+
// bin/archgen.mjs wiring (integrator):
|
|
13
|
+
// case 'migrate': process.exitCode = await migrateMain(rest); break;
|
|
14
|
+
|
|
15
|
+
import { applyMigration, listMigrations, pendingMigrations } from './migrations/index.mjs';
|
|
16
|
+
import { resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const USAGE = 'usage: archgen-skill migrate [dir] [--check|--apply] [--migration <id>]';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string[]} argv args AFTER the subcommand, e.g. ['myproj', '--apply']
|
|
22
|
+
* @returns {Promise<number>} exit code int
|
|
23
|
+
*/
|
|
24
|
+
export async function migrateMain(argv = []) {
|
|
25
|
+
let dir = null;
|
|
26
|
+
let check = false;
|
|
27
|
+
let apply = false;
|
|
28
|
+
let target = null;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === '--check') check = true;
|
|
33
|
+
else if (a === '--apply') apply = true;
|
|
34
|
+
else if (a === '--migration') {
|
|
35
|
+
target = argv[++i];
|
|
36
|
+
if (!target || target.startsWith('-')) { console.error(USAGE); return 2; }
|
|
37
|
+
} else if (a.startsWith('-')) {
|
|
38
|
+
console.error(`unknown flag: ${a}\n${USAGE}`);
|
|
39
|
+
return 2;
|
|
40
|
+
} else if (dir === null) dir = a;
|
|
41
|
+
else { console.error(`unexpected argument: ${a}\n${USAGE}`); return 2; }
|
|
42
|
+
}
|
|
43
|
+
if (check && apply) { console.error('--check and --apply are mutually exclusive\n' + USAGE); return 2; }
|
|
44
|
+
|
|
45
|
+
const dryRun = !apply; // check mode is the default, always
|
|
46
|
+
const root = resolve(dir ?? process.cwd());
|
|
47
|
+
|
|
48
|
+
const all = await listMigrations();
|
|
49
|
+
if (target && !all.some((m) => m.id === target)) {
|
|
50
|
+
console.error(`unknown migration '${target}' (available: ${all.map((m) => m.id).join(', ') || 'none'})`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const pending = (await pendingMigrations(root)).filter((p) => !target || p.id === target);
|
|
55
|
+
|
|
56
|
+
console.log('archgen migrate — ' + root + (dryRun ? ' (check mode: no changes made)' : ''));
|
|
57
|
+
|
|
58
|
+
if (pending.length === 0) {
|
|
59
|
+
console.log(' = nothing to do — all migrations applied');
|
|
60
|
+
console.log('Summary: up to date — 0 pending');
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let fileCount = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
for (const p of pending) {
|
|
67
|
+
if (dryRun) {
|
|
68
|
+
console.log(` [would-apply] ${p.id} — ${p.description}`);
|
|
69
|
+
for (const f of p.files) console.log(` ${f}`);
|
|
70
|
+
fileCount += p.files.length;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const r = await applyMigration(p.id, root, { dryRun: false });
|
|
75
|
+
if (r.status === 'clean') { console.log(` [skip] ${r.id} — already applied`); continue; }
|
|
76
|
+
console.log(` [applied] ${r.id} — ${r.description}`);
|
|
77
|
+
for (const b of r.backups ?? []) console.log(` ${b.file} (backup: ${b.backup})`);
|
|
78
|
+
fileCount += (r.files ?? []).length;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
failed++;
|
|
81
|
+
console.log(` [fail] ${p.id} — ${e instanceof Error ? e.message : String(e)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (dryRun) console.log(`Summary: ${pending.length} migration(s) pending, ${fileCount} file(s) would change`);
|
|
86
|
+
else console.log(`Summary: ${pending.length - failed} applied, ${fileCount} file(s) changed, ${failed} failure(s)`);
|
|
87
|
+
return failed > 0 ? 1 : 0;
|
|
88
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// 001-stamp-provenance.mjs — backfill the provenance header onto artifacts
|
|
2
|
+
// generated before the convention existed (see skill/references/
|
|
3
|
+
// artifact-templates.md for the forward-looking rule).
|
|
4
|
+
//
|
|
5
|
+
// What it writes: three YAML COMMENT lines at the very top of tasks.yaml and
|
|
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
|
|
10
|
+
// touched, nothing becomes required.
|
|
11
|
+
//
|
|
12
|
+
// # schema_version: 1
|
|
13
|
+
// # generator: archgen v0.0.4 (version token omitted when unknowable)
|
|
14
|
+
// # generated_at: 2026-08-26T09:00:00.000Z
|
|
15
|
+
//
|
|
16
|
+
// Idempotency: detect/files/apply all key off the `# schema_version:` marker
|
|
17
|
+
// line; stamped files are invisible to this migration, so a second run is a
|
|
18
|
+
// no-op. apply() re-checks per file before writing — it can never double-stamp.
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const SCHEMA_VERSION = 1;
|
|
24
|
+
const MARKER_RE = /^#\s*schema_version:\s*\d+\s*$/m;
|
|
25
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
26
|
+
const STORE_VERSION_REL = '.agents/skills/archgen/.archgen-version';
|
|
27
|
+
|
|
28
|
+
export const id = '001-stamp-provenance';
|
|
29
|
+
export const description = 'stamp provenance header (schema_version / generator / generated_at) onto tasks.yaml and architecture.yaml';
|
|
30
|
+
export const fromSchema = null;
|
|
31
|
+
export const toSchema = SCHEMA_VERSION;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Code-defensive generator version: trust only an exact semver stamp in the
|
|
35
|
+
* canonical store; anything else (absent, corrupt, pre-stamp) omits the token
|
|
36
|
+
* rather than guessing.
|
|
37
|
+
*/
|
|
38
|
+
function generatorToken(projectRoot) {
|
|
39
|
+
try {
|
|
40
|
+
const v = readFileSync(join(projectRoot, ...STORE_VERSION_REL.split('/')), 'utf8').trim();
|
|
41
|
+
if (SEMVER_RE.test(v)) return `archgen v${v}`;
|
|
42
|
+
} catch { /* absent or unreadable */ }
|
|
43
|
+
return 'archgen';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stampLines(projectRoot, generatedAt) {
|
|
47
|
+
return [
|
|
48
|
+
`# schema_version: ${SCHEMA_VERSION}`,
|
|
49
|
+
`# generator: ${generatorToken(projectRoot)}`,
|
|
50
|
+
`# generated_at: ${generatedAt}`,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unstampedArtifacts(ctx) {
|
|
55
|
+
return ctx.scanArtifacts().filter((a) => {
|
|
56
|
+
const abs = join(ctx.projectRoot, ...a.rel.split('/'));
|
|
57
|
+
return !MARKER_RE.test(readFileSync(abs, 'utf8'));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function detect(ctx) {
|
|
62
|
+
return unstampedArtifacts(ctx).length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function files(ctx) {
|
|
66
|
+
return unstampedArtifacts(ctx).map((a) => a.rel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function apply(ctx) {
|
|
70
|
+
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');
|
|
74
|
+
if (MARKER_RE.test(content)) continue; // re-check: never double-stamp
|
|
75
|
+
writeFileSync(abs, stampLines(ctx.projectRoot, generatedAt).join('\n') + '\n' + content);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// migrations/index.mjs — artifact-format migration framework.
|
|
2
|
+
//
|
|
3
|
+
// Generated artifacts live under `.archgen/<slug>/` and are written by LLM
|
|
4
|
+
// agents (never the CLI). As the artifact formats evolve, old projects must be
|
|
5
|
+
// brought forward WITHOUT data loss. This framework is the mechanism:
|
|
6
|
+
//
|
|
7
|
+
// - every migration is ONE numbered file (`NNN-name.mjs`) next to this file;
|
|
8
|
+
// - `detect(ctx)` decides whether a project still needs it (must be cheap,
|
|
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/`;
|
|
13
|
+
// - dry-run is the DEFAULT everywhere: nothing is written unless a caller
|
|
14
|
+
// explicitly passes `{ dryRun: false }`.
|
|
15
|
+
//
|
|
16
|
+
// Migration file contract (named exports; a default object is also accepted):
|
|
17
|
+
// export const id — '001-stamp-provenance' (MUST equal the filename stem)
|
|
18
|
+
// export const description — one line, doctor-tone
|
|
19
|
+
// export const fromSchema — source schema number or null (pre-schema files)
|
|
20
|
+
// export const toSchema — target schema number
|
|
21
|
+
// export function detect(ctx) -> boolean
|
|
22
|
+
// export function apply(ctx) -> void (mutates; framework backed up first)
|
|
23
|
+
// export function files(ctx) -> string[] of project-relative paths that
|
|
24
|
+
// apply() may touch (drives --check output
|
|
25
|
+
// AND the mandatory pre-apply backup set)
|
|
26
|
+
//
|
|
27
|
+
// ctx shape (built here, passed to detect/files/apply):
|
|
28
|
+
// ctx.projectRoot absolute root
|
|
29
|
+
// ctx.dryRun true unless explicitly applying
|
|
30
|
+
// ctx.scanArtifacts() -> [{ rel, kind }] for .archgen/*/tasks.yaml and
|
|
31
|
+
// .archgen/*/architecture.yaml (dot-dirs skipped)
|
|
32
|
+
// ctx.backup(rel) snapshot one project-relative file into
|
|
33
|
+
// .archgen/.backup/<ts>/migrate/<rel>; THROWS under
|
|
34
|
+
// dry-run so a mutating migration can never run silently
|
|
35
|
+
|
|
36
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
39
|
+
import { BACKUP_ROOT_REL } from '../store.js';
|
|
40
|
+
|
|
41
|
+
const MIGRATION_FILE_RE = /^(\d{3}-[a-z0-9][a-z0-9-]*)\.mjs$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load every migration file, sorted by id (numeric prefix == order).
|
|
45
|
+
* Malformed migrations fail LOUDLY here — a silently-skipped migration would
|
|
46
|
+
* corrupt the "everything applied" invariant.
|
|
47
|
+
* @returns {Promise<Array<{id: string, description: string, fromSchema: number|null,
|
|
48
|
+
* toSchema: number, detect: Function, apply: Function, files?: Function}>>}
|
|
49
|
+
*/
|
|
50
|
+
export async function listMigrations() {
|
|
51
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const stems = readdirSync(dir)
|
|
53
|
+
.map((f) => MIGRATION_FILE_RE.exec(f)?.[1])
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.sort();
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const stem of stems) {
|
|
58
|
+
const mod = await import(pathToFileURL(join(dir, stem + '.mjs')).href);
|
|
59
|
+
out.push(normalize(mod.default ?? mod, stem));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalize(m, stem) {
|
|
65
|
+
const fail = (why) => {
|
|
66
|
+
throw new Error(`migration ${stem}.mjs is malformed: ${why}`);
|
|
67
|
+
};
|
|
68
|
+
if (!m || typeof m !== 'object') fail('no exports');
|
|
69
|
+
if (m.id !== stem) fail(`id '${m.id}' must equal filename stem '${stem}'`);
|
|
70
|
+
if (typeof m.description !== 'string' || !m.description) fail('missing description');
|
|
71
|
+
if (!(m.fromSchema === null || typeof m.fromSchema === 'number')) fail('fromSchema must be a number or null');
|
|
72
|
+
if (typeof m.toSchema !== 'number') fail('toSchema must be a number');
|
|
73
|
+
if (typeof m.detect !== 'function') fail('missing detect(ctx)');
|
|
74
|
+
if (typeof m.apply !== 'function') fail('missing apply(ctx)');
|
|
75
|
+
if (m.files !== undefined && typeof m.files !== 'function') fail('files must be a function');
|
|
76
|
+
return m;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build the migration context. Internal — exported for tests only. */
|
|
80
|
+
export function makeCtx(projectRoot, { dryRun }) {
|
|
81
|
+
const root = resolve(projectRoot);
|
|
82
|
+
// One timestamp per context: all backups from a single apply run group
|
|
83
|
+
// under one directory (colons stripped, same convention as store.moveToBackup).
|
|
84
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
85
|
+
return {
|
|
86
|
+
projectRoot: root,
|
|
87
|
+
dryRun: dryRun === true,
|
|
88
|
+
|
|
89
|
+
/** Discover generated artifacts this framework knows how to migrate. */
|
|
90
|
+
scanArtifacts() {
|
|
91
|
+
const base = join(root, '.archgen');
|
|
92
|
+
if (!existsSync(base)) return [];
|
|
93
|
+
const out = [];
|
|
94
|
+
const slugs = readdirSync(base, { withFileTypes: true })
|
|
95
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
96
|
+
.map((e) => e.name)
|
|
97
|
+
.sort();
|
|
98
|
+
for (const slug of slugs) {
|
|
99
|
+
for (const f of ['tasks.yaml', 'architecture.yaml']) {
|
|
100
|
+
const abs = join(base, slug, f);
|
|
101
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
102
|
+
out.push({ rel: `.archgen/${slug}/${f}`, kind: f.replace(/\.yaml$/, '') });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Snapshot a project-relative file into `.archgen/.backup/<ts>/migrate/`.
|
|
111
|
+
* Copy (not move): the original stays in place until apply() rewrites it.
|
|
112
|
+
* @param {string} relPath project-relative, e.g. `.archgen/demo/tasks.yaml`
|
|
113
|
+
* @returns {string} backup location relative to the project root
|
|
114
|
+
*/
|
|
115
|
+
backup(relPath) {
|
|
116
|
+
if (this.dryRun) throw new Error(`backup('${relPath}') called during dry-run — migrations must never mutate under --check`);
|
|
117
|
+
const srcAbs = join(root, ...relPath.split('/'));
|
|
118
|
+
const destRel = `${BACKUP_ROOT_REL}/${ts}/migrate/${relPath}`;
|
|
119
|
+
const destAbs = join(root, ...destRel.split('/'));
|
|
120
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
121
|
+
copyFileSync(srcAbs, destAbs);
|
|
122
|
+
return destRel;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Migrations not yet satisfied by `projectRoot`, in order. ALWAYS read-only.
|
|
129
|
+
* @returns {Promise<Array<{id: string, description: string, fromSchema: number|null,
|
|
130
|
+
* toSchema: number, files: string[]}>>}
|
|
131
|
+
*/
|
|
132
|
+
export async function pendingMigrations(projectRoot) {
|
|
133
|
+
const pending = [];
|
|
134
|
+
for (const mig of await listMigrations()) {
|
|
135
|
+
const ctx = makeCtx(projectRoot, { dryRun: true });
|
|
136
|
+
if (!mig.detect(ctx)) continue;
|
|
137
|
+
pending.push({
|
|
138
|
+
id: mig.id,
|
|
139
|
+
description: mig.description,
|
|
140
|
+
fromSchema: mig.fromSchema,
|
|
141
|
+
toSchema: mig.toSchema,
|
|
142
|
+
files: mig.files ? mig.files(ctx).slice() : [],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return pending;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run one migration against `projectRoot`.
|
|
150
|
+
* @param {string} id migration id (filename stem)
|
|
151
|
+
* @param {string} projectRoot
|
|
152
|
+
* @param {{dryRun?: boolean}} opts dryRun defaults to TRUE — pass
|
|
153
|
+
* `{ dryRun: false }` to mutate (every declared file is backed up first)
|
|
154
|
+
* @returns {Promise<{status: 'applied'|'would-apply'|'clean'|'not-found',
|
|
155
|
+
* id: string, description?: string, files?: string[],
|
|
156
|
+
* backups?: Array<{file: string, backup: string}>}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function applyMigration(id, projectRoot, opts = {}) {
|
|
159
|
+
const dryRun = opts.dryRun !== false; // dry-run default, always
|
|
160
|
+
const mig = (await listMigrations()).find((m) => m.id === id);
|
|
161
|
+
if (!mig) return { status: 'not-found', id };
|
|
162
|
+
const ctx = makeCtx(projectRoot, { dryRun });
|
|
163
|
+
if (!mig.detect(ctx)) return { status: 'clean', id };
|
|
164
|
+
const files = mig.files ? mig.files(ctx).slice() : [];
|
|
165
|
+
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 };
|
|
170
|
+
}
|