archgen-skill 0.0.2 → 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 +29 -3
- package/bin/archgen.mjs +146 -12
- package/lib/block.js +197 -8
- package/lib/config.js +96 -0
- package/lib/doctor.js +283 -0
- package/lib/init.js +130 -27
- 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 +161 -0
- package/lib/uninstall-project.js +95 -0
- package/lib/version-stamp.js +47 -0
- package/lib/version.js +23 -0
- package/package.json +37 -9
- package/vendor/skills/archgen/SKILL.md +252 -73
- 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 +18 -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 +204 -0
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
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
|
+
}
|
package/lib/restore.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// restore.js — recovery tooling for archgen backups.
|
|
2
|
+
//
|
|
3
|
+
// Backups live in two vaults:
|
|
4
|
+
// project: <projectRoot>/.archgen/.backup/<ts>/<relpath…> (written by init)
|
|
5
|
+
// global: <skills-dir>/.archgen-backups/<ts>/archgen (written by install --copy)
|
|
6
|
+
//
|
|
7
|
+
// restoreBackups() discovers both, lists snapshots deterministically, and
|
|
8
|
+
// restores a chosen snapshot by copying each entry back to its original path.
|
|
9
|
+
// Safety invariant: the CURRENT state of every about-to-be-overwritten path is
|
|
10
|
+
// moved into a fresh safety snapshot FIRST — a restore never destroys anything
|
|
11
|
+
// without a prior backup of it. Restores themselves land atomically
|
|
12
|
+
// (temp-sibling dir, then rename).
|
|
13
|
+
|
|
14
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
18
|
+
import { BACKUP_ROOT_REL, moveToBackupInto } from './store.js';
|
|
19
|
+
import { GLOBAL_BACKUP_REL, globalTargets } from './install.js';
|
|
20
|
+
|
|
21
|
+
function toPosix(p) {
|
|
22
|
+
return p.split('\\').join('/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function byName(a, b) {
|
|
26
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lstatSafe(p) {
|
|
30
|
+
try { return lstatSync(p); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function summarize(dir) {
|
|
34
|
+
let files = 0;
|
|
35
|
+
let dirs = 0;
|
|
36
|
+
(function walk(d) {
|
|
37
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
38
|
+
if (e.isDirectory()) { dirs++; walk(join(d, e.name)); } else { files++; }
|
|
39
|
+
}
|
|
40
|
+
})(dir);
|
|
41
|
+
const topLevel = readdirSync(dir).sort(byName);
|
|
42
|
+
return { files, dirs, topLevel };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshotsUnder(origin, base, vaultAbs) {
|
|
46
|
+
if (!existsSync(vaultAbs)) return [];
|
|
47
|
+
return readdirSync(vaultAbs, { withFileTypes: true })
|
|
48
|
+
.filter((e) => e.isDirectory())
|
|
49
|
+
.map((e) => e.name)
|
|
50
|
+
.sort(byName)
|
|
51
|
+
.reverse()
|
|
52
|
+
.map((ts) => {
|
|
53
|
+
const snapshotDir = join(vaultAbs, ts);
|
|
54
|
+
let summary = null;
|
|
55
|
+
try { summary = summarize(snapshotDir); } catch { summary = { files: 0, dirs: 0, topLevel: [] }; }
|
|
56
|
+
return { id: ts, ts, origin, base, snapshotDir, summary };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Discover backup snapshots in both vaults.
|
|
62
|
+
* @param {{projectRoot?: string|null, home?: string, includeGlobal?: boolean}} opts
|
|
63
|
+
* @returns {Array<{id: string, ts: string, origin: 'project'|'global', base: string,
|
|
64
|
+
* snapshotDir: string, summary: {files: number, dirs: number, topLevel: string[]}}>}
|
|
65
|
+
* Sorted newest-first, project before global (deterministic order).
|
|
66
|
+
*/
|
|
67
|
+
export function discoverSnapshots(opts = {}) {
|
|
68
|
+
const home = opts.home ?? homedir();
|
|
69
|
+
const out = [];
|
|
70
|
+
if (opts.projectRoot) {
|
|
71
|
+
out.push(...snapshotsUnder('project', resolve(opts.projectRoot), join(resolve(opts.projectRoot), ...BACKUP_ROOT_REL.split('/'))));
|
|
72
|
+
}
|
|
73
|
+
if (opts.includeGlobal !== false) {
|
|
74
|
+
for (const tdir of globalTargets(home)) {
|
|
75
|
+
out.push(...snapshotsUnder('global', tdir, join(tdir, GLOBAL_BACKUP_REL)));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out.sort((a, b) => (a.ts === b.ts ? byName(a.origin + a.base, b.origin + b.base) : a.ts < b.ts ? 1 : -1));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List backups under a project root and the global harness dirs.
|
|
83
|
+
* @param {string} projectRoot project root to scan (may be '' when unused)
|
|
84
|
+
* @param {{home?: string, includeGlobal?: boolean}} options
|
|
85
|
+
*/
|
|
86
|
+
export function listBackups(projectRoot, options = {}) {
|
|
87
|
+
return discoverSnapshots({ projectRoot: projectRoot || null, home: options.home, includeGlobal: options.includeGlobal });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uniqueSafetyTs(base, backupRootRel) {
|
|
91
|
+
for (;;) {
|
|
92
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
93
|
+
if (!existsSync(join(base, ...backupRootRel.split('/'), ts))) return ts;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Atomic copy-back: temp sibling dir, then rename into place. */
|
|
98
|
+
function layDown(srcAbs, destAbs) {
|
|
99
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
100
|
+
const tmp = join(dirname(destAbs), '.archgen-restore-' + randomBytes(6).toString('hex'));
|
|
101
|
+
cpSync(srcAbs, tmp, { recursive: true });
|
|
102
|
+
renameSync(tmp, destAbs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Restore backups discovered across the project and global vaults.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} projectRoot project root to scan (used for listing; may be ''
|
|
109
|
+
* when only global snapshots are wanted)
|
|
110
|
+
* @param {{list?: boolean, snapshot?: string, target?: string, home?: string,
|
|
111
|
+
* includeGlobal?: boolean}} options
|
|
112
|
+
* - `list` (default true): dry-run discovery, returns `snapshots`.
|
|
113
|
+
* - `snapshot`: timestamp id to restore (takes precedence over `list`).
|
|
114
|
+
* Ambiguous ids (same ts in several vaults) must be narrowed via
|
|
115
|
+
* `includeGlobal:false` or an exact single match.
|
|
116
|
+
* - `target`: restore INTO this directory instead of the recorded origin
|
|
117
|
+
* (entries keep their layout relative to it).
|
|
118
|
+
* @returns {{action: 'list'|'restored', snapshots?: Array<object>,
|
|
119
|
+
* restored?: Array<{from: string, to: string}>,
|
|
120
|
+
* safetyBackups?: Array<{rel: string, abs: string}>, warnings: string[]}}
|
|
121
|
+
*/
|
|
122
|
+
export function restoreBackups(projectRoot, options = {}) {
|
|
123
|
+
const warnings = [];
|
|
124
|
+
const all = discoverSnapshots({
|
|
125
|
+
projectRoot,
|
|
126
|
+
home: options.home,
|
|
127
|
+
includeGlobal: options.includeGlobal !== false,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!options.snapshot) {
|
|
131
|
+
return { action: 'list', snapshots: all, warnings };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const matches = all.filter((s) => s.ts === options.snapshot || s.id === options.snapshot);
|
|
135
|
+
if (matches.length === 0) {
|
|
136
|
+
throw new Error(`no backup snapshot '${options.snapshot}' found (run with list mode to see available ids)`);
|
|
137
|
+
}
|
|
138
|
+
if (matches.length > 1) {
|
|
139
|
+
throw new Error(`ambiguous snapshot '${options.snapshot}' exists in ${matches.length} vaults; narrow the scope`);
|
|
140
|
+
}
|
|
141
|
+
const snap = matches[0];
|
|
142
|
+
|
|
143
|
+
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) }));
|
|
148
|
+
|
|
149
|
+
// Pass 1 — safety: move EVERY existing destination aside before laying down
|
|
150
|
+
// anything, so a mid-restore failure still leaves current state fully backed up.
|
|
151
|
+
const safetyBackups = [];
|
|
152
|
+
const safetyRootRel = snap.origin === 'global' ? GLOBAL_BACKUP_REL : BACKUP_ROOT_REL;
|
|
153
|
+
const safetyBase = snap.origin === 'global' ? snap.base : destBase;
|
|
154
|
+
const safetyTs = uniqueSafetyTs(safetyBase, safetyRootRel);
|
|
155
|
+
for (const en of entries) {
|
|
156
|
+
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);
|
|
159
|
+
const abs = join(safetyBase, ...loc.split('/'));
|
|
160
|
+
safetyBackups.push({ rel: loc, abs });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Pass 2 — copy back atomically.
|
|
164
|
+
const restored = [];
|
|
165
|
+
for (const en of entries) {
|
|
166
|
+
try {
|
|
167
|
+
layDown(en.srcAbs, en.destAbs);
|
|
168
|
+
restored.push({ from: en.srcAbs, to: en.destAbs });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
warnings.push(`could not restore ${en.name}: ${e.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { action: 'restored', snapshot: snap, restored, safetyBackups, warnings };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseFlags(argv) {
|
|
178
|
+
const opts = { list: true, snapshot: null, project: null, global: false, yes: false };
|
|
179
|
+
for (let i = 0; i < argv.length; i++) {
|
|
180
|
+
const a = argv[i];
|
|
181
|
+
if (a === '--list') opts.list = true;
|
|
182
|
+
else if (a === '--snapshot') {
|
|
183
|
+
const v = argv[++i];
|
|
184
|
+
if (!v || v.startsWith('--')) return { error: '--snapshot requires a timestamp argument' };
|
|
185
|
+
opts.snapshot = v;
|
|
186
|
+
} else if (a === '--project') {
|
|
187
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : process.cwd();
|
|
188
|
+
opts.project = v;
|
|
189
|
+
} else if (a === '--global') opts.global = true;
|
|
190
|
+
else if (a === '--yes') opts.yes = true; // accepted for CLI ergonomics; restore is interactive-free
|
|
191
|
+
else return { error: `unknown flag: ${a}` };
|
|
192
|
+
}
|
|
193
|
+
return opts;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* CLI entry point (`archgen-skill restore …`). Interactive-free and
|
|
198
|
+
* deterministic; never prompts. Exit codes: 0 success/listing, 1 on errors.
|
|
199
|
+
* @param {string[]} argv flags after the command name
|
|
200
|
+
* @returns {Promise<number>} exit code
|
|
201
|
+
*/
|
|
202
|
+
export async function restoreMain(argv) {
|
|
203
|
+
const parsed = parseFlags(argv);
|
|
204
|
+
if (parsed.error) {
|
|
205
|
+
console.error('archgen restore: ' + parsed.error);
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
const { list, snapshot, project, global: wantGlobal, yes } = parsed;
|
|
209
|
+
void yes;
|
|
210
|
+
|
|
211
|
+
const scope = {
|
|
212
|
+
projectRoot: wantGlobal ? null : (project ?? process.cwd()),
|
|
213
|
+
includeGlobal: !project || wantGlobal,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
if (!snapshot) {
|
|
218
|
+
const snapshots = listBackups(scope.projectRoot ?? '', { includeGlobal: scope.includeGlobal });
|
|
219
|
+
console.log(`archgen restore — ${snapshots.length} backup snapshot(s) found`);
|
|
220
|
+
for (const s of snapshots) {
|
|
221
|
+
const names = s.summary.topLevel.length ? s.summary.topLevel.join(', ') : '(empty)';
|
|
222
|
+
console.log(` ${s.ts} [${s.origin}] ${s.base} (${s.summary.files} file(s), ${s.summary.dirs} dir(s): ${names})`);
|
|
223
|
+
}
|
|
224
|
+
console.log('Dry-run listing only. Pass --snapshot <ts> to restore.');
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const r = restoreBackups(scope.projectRoot ?? '', { snapshot, includeGlobal: scope.includeGlobal });
|
|
229
|
+
console.log(`archgen restore — restoring ${snapshot} [${r.snapshot.origin}]`);
|
|
230
|
+
for (const sb of r.safetyBackups) console.log(` ~ safety backup of current state: ${sb.abs}`);
|
|
231
|
+
for (const en of r.restored) console.log(` + restored ${en.to}`);
|
|
232
|
+
for (const w of r.warnings) console.error(' ! ' + w);
|
|
233
|
+
console.log(`Done: ${r.restored.length} path(s) restored, ${r.safetyBackups.length} safety backup(s).`);
|
|
234
|
+
return r.warnings.length > 0 ? 1 : 0;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error('archgen restore: ' + (e instanceof Error ? e.message : String(e)));
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
}
|