archgen-skill 0.0.6 → 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 +8 -1
- package/bin/archgen.mjs +123 -13
- package/lib/block.js +51 -10
- package/lib/bootstrap.js +8 -4
- package/lib/config.js +3 -2
- package/lib/doctor.js +156 -79
- package/lib/init.js +45 -16
- package/lib/install.js +60 -7
- package/lib/mcp-server.js +88 -6
- package/lib/migrations/001-stamp-provenance.mjs +12 -5
- package/lib/migrations/index.mjs +19 -8
- package/lib/restore.js +52 -16
- package/lib/spawn-npm.js +49 -4
- package/lib/store.js +100 -23
- package/lib/uninstall-project.js +22 -4
- package/lib/version.js +9 -1
- package/package.json +1 -1
- package/vendor/skills/archgen/SKILL.md +2 -2
- package/vendor/skills/archgen/archgen.config.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
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
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,8 +30,8 @@ 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 };
|
|
@@ -52,3 +53,47 @@ export function whichOnPath(bin) {
|
|
|
52
53
|
}
|
|
53
54
|
return null;
|
|
54
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
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
rmSync,
|
|
32
32
|
writeFileSync,
|
|
33
33
|
} from 'node:fs';
|
|
34
|
-
import { dirname, join, sep } from 'node:path';
|
|
34
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
35
35
|
import { loadConfig } from './config.js';
|
|
36
36
|
|
|
37
37
|
export const STORE_REL = '.agents/skills/archgen';
|
|
@@ -44,6 +44,18 @@ export const MANIFEST_REL = '.archgen/' + CONFIG_FILES.projectManifest;
|
|
|
44
44
|
export const BACKUP_ROOT_REL = '.archgen/' + CONFIG_FILES.backupDir;
|
|
45
45
|
export const VERSION_FILE = CONFIG_FILES.stamp;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Name of the sidecar that records WHICH paths a backup snapshot holds.
|
|
49
|
+
*
|
|
50
|
+
* A snapshot's directory layout alone does not say what was backed up: the
|
|
51
|
+
* tree `<ts>/.agents/skills/archgen` and the tree `<ts>/archgen` are the same
|
|
52
|
+
* shape at different depths, and restoring the snapshot's top-level directory
|
|
53
|
+
* would replace a whole `.agents/` tree — every sibling skill with it. The
|
|
54
|
+
* record names the exact paths, so a restore puts back exactly those and
|
|
55
|
+
* leaves siblings alone.
|
|
56
|
+
*/
|
|
57
|
+
export const BACKUP_PATHS_FILE = '.archgen-backup-paths.json';
|
|
58
|
+
|
|
47
59
|
const GUARDED_RELPATHS = new Set(['.claude', '.agents', '.claude/skills', '.agents/skills']);
|
|
48
60
|
|
|
49
61
|
/**
|
|
@@ -133,30 +145,58 @@ export function assertNotSymlink(absPath) {
|
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
148
|
+
* Resolve `p` to the real location of its nearest existing ancestor, keeping
|
|
149
|
+
* the not-yet-existing tail as lexical segments.
|
|
150
|
+
*
|
|
151
|
+
* `realpathSync` cannot answer for a path that does not exist yet, but the
|
|
152
|
+
* guard must still work before anything is created (init's very first run) —
|
|
153
|
+
* so the tail is appended to the resolved ancestor instead of being dropped.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} p absolute path
|
|
156
|
+
* @returns {string|null} resolved path, or null when nothing could be resolved
|
|
141
157
|
*/
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
let probe = absPath;
|
|
158
|
+
function realpathWithTail(p) {
|
|
159
|
+
const tail = [];
|
|
160
|
+
let probe = resolve(p);
|
|
146
161
|
for (;;) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
162
|
+
try {
|
|
163
|
+
const real = realpathSync(probe);
|
|
164
|
+
return tail.length ? join(real, ...tail) : real;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (!e || e.code !== 'ENOENT') return null;
|
|
167
|
+
const parent = dirname(probe);
|
|
168
|
+
if (parent === probe) return null;
|
|
169
|
+
tail.unshift(basename(probe));
|
|
170
|
+
probe = parent;
|
|
156
171
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Realpath-escape guard: refuse when the real location of `absPath` lands
|
|
177
|
+
* outside `rootAbs`. Catches `.agents -> /elsewhere` style redirects that a
|
|
178
|
+
* final-component lstat cannot see.
|
|
179
|
+
*
|
|
180
|
+
* The LEXICAL check always applies — it is the only guard that can work when
|
|
181
|
+
* the root does not exist yet (init calls this before it created anything),
|
|
182
|
+
* and a lexical escape (`../../etc/passwd`) is an escape under any root.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} rootAbs project/base root
|
|
185
|
+
* @param {string} absPath path that must stay inside the root
|
|
186
|
+
*/
|
|
187
|
+
export function assertWithinRoot(rootAbs, absPath) {
|
|
188
|
+
const rootLex = resolve(rootAbs);
|
|
189
|
+
const pathLex = resolve(absPath);
|
|
190
|
+
if (pathLex !== rootLex && !pathLex.startsWith(rootLex + sep)) {
|
|
191
|
+
throw new Error(refuseSymlinkMessage(absPath));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rootReal = realpathWithTail(rootLex);
|
|
195
|
+
const pathReal = realpathWithTail(pathLex);
|
|
196
|
+
// Unresolvable (no existing ancestor at all): the lexical check stands.
|
|
197
|
+
if (!rootReal || !pathReal) return;
|
|
198
|
+
if (pathReal !== rootReal && !pathReal.startsWith(rootReal + sep)) {
|
|
199
|
+
throw new Error(refuseSymlinkMessage(absPath));
|
|
160
200
|
}
|
|
161
201
|
}
|
|
162
202
|
|
|
@@ -248,8 +288,11 @@ export function loadManifest(root) {
|
|
|
248
288
|
|
|
249
289
|
export function saveManifest(root, manifest) {
|
|
250
290
|
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
251
|
-
|
|
291
|
+
// Guard BEFORE creating the parent: a symlinked `.archgen` would otherwise
|
|
292
|
+
// redirect the whole manifest write outside the project.
|
|
252
293
|
assertNotSymlink(p);
|
|
294
|
+
assertWithinRoot(root, p);
|
|
295
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
253
296
|
writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n');
|
|
254
297
|
}
|
|
255
298
|
|
|
@@ -265,12 +308,45 @@ export function appendEntry(root, kind, relPath) {
|
|
|
265
308
|
saveManifest(root, m);
|
|
266
309
|
}
|
|
267
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Read the path record of a backup snapshot.
|
|
313
|
+
* @param {string} snapshotDir absolute path of the snapshot directory
|
|
314
|
+
* @returns {string[]|null} the recorded relative paths, or null when the
|
|
315
|
+
* snapshot carries no record (written by an older CLI)
|
|
316
|
+
*/
|
|
317
|
+
export function readBackupPaths(snapshotDir) {
|
|
318
|
+
let raw;
|
|
319
|
+
try {
|
|
320
|
+
raw = JSON.parse(readFileSync(join(snapshotDir, BACKUP_PATHS_FILE), 'utf8'));
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
if (!raw || !Array.isArray(raw.paths)) return null;
|
|
325
|
+
return raw.paths.filter((p) => typeof p === 'string' && p !== '');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Add `relPaths` to a snapshot's path record (creating it when absent). */
|
|
329
|
+
function recordBackupPaths(snapshotDir, relPaths) {
|
|
330
|
+
const existing = readBackupPaths(snapshotDir) ?? [];
|
|
331
|
+
const merged = [...existing];
|
|
332
|
+
for (const p of relPaths) if (!merged.includes(p)) merged.push(p);
|
|
333
|
+
writeFileSync(
|
|
334
|
+
join(snapshotDir, BACKUP_PATHS_FILE),
|
|
335
|
+
JSON.stringify({ version: 1, paths: merged }, null, 2) + '\n',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
268
339
|
/**
|
|
269
340
|
* Move an existing path into `<root>/<backupRootRel>/<ts>/<relPath>`, preserving
|
|
270
341
|
* the relative layout so backups restore unambiguously. Generalized core shared
|
|
271
342
|
* by the project vault (.archgen/.backup) and the global vault
|
|
272
343
|
* (<skills-dir>/.archgen-backups); `ts` can be injected so one operation lands
|
|
273
344
|
* every entry under a single timestamp.
|
|
345
|
+
*
|
|
346
|
+
* The exact relative path is RECORDED in the snapshot: a restore must put back
|
|
347
|
+
* that path, not the snapshot's top-level directory (which can hold sibling
|
|
348
|
+
* skills the snapshot never covered).
|
|
349
|
+
*
|
|
274
350
|
* @returns {string} backup location relative to `root`
|
|
275
351
|
*/
|
|
276
352
|
export function moveToBackupInto(root, relPath, backupRootRel, ts = new Date().toISOString().replace(/:/g, '-')) {
|
|
@@ -290,6 +366,7 @@ export function moveToBackupInto(root, relPath, backupRootRel, ts = new Date().t
|
|
|
290
366
|
cpSync(srcAbs, destAbs, { recursive: true });
|
|
291
367
|
rmSync(srcAbs, { recursive: true, force: true });
|
|
292
368
|
}
|
|
369
|
+
recordBackupPaths(join(root, ...backupRootRel.split('/'), ts), [relPath]);
|
|
293
370
|
return destRel;
|
|
294
371
|
}
|
|
295
372
|
|
package/lib/uninstall-project.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
MANIFEST_REL,
|
|
16
16
|
STORE_REL,
|
|
17
17
|
VERSION_FILE,
|
|
18
|
+
assertWithinRoot,
|
|
18
19
|
hashDir,
|
|
19
20
|
pruneEmptyParents,
|
|
20
21
|
rmIfExists,
|
|
@@ -60,9 +61,18 @@ export function uninstallProject(projectDir, packageRoot) {
|
|
|
60
61
|
warnings.push('.claude/skills/archgen is a real directory - left untouched (remove manually if unwanted)');
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
// 2. Managed blocks — strip markers, keep all user content.
|
|
64
|
+
// 2. Managed blocks — strip markers, keep all user content. A symlinked
|
|
65
|
+
// managed file is REFUSED (never written through) and skipped, so the
|
|
66
|
+
// rest of the uninstall still runs and the project is left in a
|
|
67
|
+
// coherent state instead of half-uninstalled.
|
|
64
68
|
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
|
65
|
-
|
|
69
|
+
const abs = join(root, name);
|
|
70
|
+
const fileSt = lstatSafe(abs);
|
|
71
|
+
if (fileSt && fileSt.isSymbolicLink()) {
|
|
72
|
+
warnings.push(`${name} is a symlink - left untouched (refusing to write through it)`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (stripManagedFile(abs).hadBlock) strippedBlocks.push(name);
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
// 3. Canonical store — remove ONLY if unmodified vs vendor.
|
|
@@ -80,8 +90,16 @@ export function uninstallProject(projectDir, packageRoot) {
|
|
|
80
90
|
let vendor = null;
|
|
81
91
|
try { vendor = hashDir(resolveSkillSource(packageRoot)); } catch { vendor = null; }
|
|
82
92
|
if (vendor !== null && mine === vendor) {
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
// Containment FIRST: a symlinked `.agents` would make this delete a
|
|
94
|
+
// tree OUTSIDE the project, so the guard runs before the removal.
|
|
95
|
+
try {
|
|
96
|
+
assertWithinRoot(root, storeAbs);
|
|
97
|
+
rmSync(storeAbs, { recursive: true, force: true });
|
|
98
|
+
storeRemoved = true;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
storeKept = true;
|
|
101
|
+
warnings.push('canonical store kept at .agents/skills/archgen: ' + e.message);
|
|
102
|
+
}
|
|
85
103
|
} else {
|
|
86
104
|
storeKept = true;
|
|
87
105
|
warnings.push('canonical store kept at .agents/skills/archgen: modified vs vendor (or unverifiable) - review manually');
|
package/lib/version.js
CHANGED
|
@@ -7,10 +7,18 @@ export function parseSemver(v) {
|
|
|
7
7
|
return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Compare two semver strings.
|
|
12
|
+
*
|
|
13
|
+
* @returns {number|null} -1 / 0 / 1, or null when either side is not a
|
|
14
|
+
* semver. An unparseable version is NOT "equal" to anything: reporting 0
|
|
15
|
+
* would make a registry tag like "beta" read as "already up to date" and
|
|
16
|
+
* silently skip the caller's refusal path.
|
|
17
|
+
*/
|
|
10
18
|
export function compareSemver(a, b) {
|
|
11
19
|
const x = parseSemver(a);
|
|
12
20
|
const y = parseSemver(b);
|
|
13
|
-
if (!x || !y) return
|
|
21
|
+
if (!x || !y) return null;
|
|
14
22
|
for (const k of ['major', 'minor', 'patch']) {
|
|
15
23
|
if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;
|
|
16
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archgen-skill",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Architecture generation & autonomous task execution for coding agents - conversational requirements in, dependency-ordered tasks out, executed wave-by-wave with verifier gates. Works with Claude Code, OpenCode, Cursor, Codex, Gemini CLI and any agentskills.io harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -4,10 +4,10 @@ description: Architecture generation and autonomous task execution for coding ag
|
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires Node.js >= 20. The archgen MCP server (archgen-mcp) provides every deterministic capability; the skill itself ships no code.
|
|
6
6
|
metadata:
|
|
7
|
-
version: 0.0.
|
|
7
|
+
version: 0.0.7
|
|
8
8
|
repo: https://github.com/akashmaurya99/archgen
|
|
9
9
|
requires:
|
|
10
|
-
archgen_mcp: ">=0.0.
|
|
10
|
+
archgen_mcp: ">=0.0.7"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# archgen — architecture generation & wave execution
|