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/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
- * Realpath-escape guard: resolve `absPath` (climbing to the nearest existing
137
- * ancestor when it does not exist yet, so a symlinked PARENT dir is caught
138
- * before anything is created beneath it) and refuse when the real location
139
- * lands outside `rootAbs`. Catches `.agents -> /elsewhere` style redirects
140
- * that a final-component lstat cannot see.
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
- export function assertWithinRoot(rootAbs, absPath) {
143
- let rootReal;
144
- try { rootReal = realpathSync(rootAbs); } catch { return; }
145
- let probe = absPath;
158
+ function realpathWithTail(p) {
159
+ const tail = [];
160
+ let probe = resolve(p);
146
161
  for (;;) {
147
- let exists = true;
148
- try { lstatSync(probe); } catch { exists = false; }
149
- if (exists) {
150
- let real;
151
- try { real = realpathSync(probe); } catch { return; } // broken chain: nothing writable through it
152
- if (real !== rootReal && !real.startsWith(rootReal + sep)) {
153
- throw new Error(refuseSymlinkMessage(absPath));
154
- }
155
- return;
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
- const parent = dirname(probe);
158
- if (parent === probe) return;
159
- probe = parent;
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
- mkdirSync(dirname(p), { recursive: true });
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
 
@@ -2,10 +2,11 @@
2
2
  //
3
3
  // Removes exactly what archgen owns in a project: the claude symlink, the
4
4
  // managed blocks, the canonical store (ONLY when unmodified vs the vendor
5
- // skill — a customized store is kept with a warning), and the install
6
- // manifest. `.archgen/<slug>` feature folders and `.archgen/.backup/` are
7
- // always preserved. Parents are pruned only when left completely empty, and
8
- // never past the guarded .claude/.agents levels.
5
+ // skill — a customized store is kept with a warning), the install
6
+ // manifest, and the legacy extension runtime directory. `.archgen/<slug>`
7
+ // feature folders and `.archgen/.backup/` are always preserved. Parents are
8
+ // pruned only when left completely empty, and never past the guarded
9
+ // .claude/.agents levels.
9
10
 
10
11
  import { existsSync, lstatSync, readlinkSync, rmSync, unlinkSync } from 'node:fs';
11
12
  import { dirname, join, resolve } from 'node:path';
@@ -15,6 +16,7 @@ import {
15
16
  MANIFEST_REL,
16
17
  STORE_REL,
17
18
  VERSION_FILE,
19
+ assertWithinRoot,
18
20
  hashDir,
19
21
  pruneEmptyParents,
20
22
  rmIfExists,
@@ -35,6 +37,7 @@ function relJoin(rel) {
35
37
  * @param {string} packageRoot dir containing this CLI package (vendor source)
36
38
  * @returns {{root: string, linkRemoved: boolean, strippedBlocks: string[],
37
39
  * storeRemoved: boolean, storeKept: boolean, manifestRemoved: boolean,
40
+ * runtimeRemoved: boolean,
38
41
  * mcpRemoved: string[], warnings: string[]}}
39
42
  */
40
43
  export function uninstallProject(projectDir, packageRoot) {
@@ -60,9 +63,18 @@ export function uninstallProject(projectDir, packageRoot) {
60
63
  warnings.push('.claude/skills/archgen is a real directory - left untouched (remove manually if unwanted)');
61
64
  }
62
65
 
63
- // 2. Managed blocks — strip markers, keep all user content.
66
+ // 2. Managed blocks — strip markers, keep all user content. A symlinked
67
+ // managed file is REFUSED (never written through) and skipped, so the
68
+ // rest of the uninstall still runs and the project is left in a
69
+ // coherent state instead of half-uninstalled.
64
70
  for (const name of ['AGENTS.md', 'CLAUDE.md']) {
65
- if (stripManagedFile(join(root, name)).hadBlock) strippedBlocks.push(name);
71
+ const abs = join(root, name);
72
+ const fileSt = lstatSafe(abs);
73
+ if (fileSt && fileSt.isSymbolicLink()) {
74
+ warnings.push(`${name} is a symlink - left untouched (refusing to write through it)`);
75
+ continue;
76
+ }
77
+ if (stripManagedFile(abs).hadBlock) strippedBlocks.push(name);
66
78
  }
67
79
 
68
80
  // 3. Canonical store — remove ONLY if unmodified vs vendor.
@@ -80,8 +92,16 @@ export function uninstallProject(projectDir, packageRoot) {
80
92
  let vendor = null;
81
93
  try { vendor = hashDir(resolveSkillSource(packageRoot)); } catch { vendor = null; }
82
94
  if (vendor !== null && mine === vendor) {
83
- rmSync(storeAbs, { recursive: true, force: true });
84
- storeRemoved = true;
95
+ // Containment FIRST: a symlinked `.agents` would make this delete a
96
+ // tree OUTSIDE the project, so the guard runs before the removal.
97
+ try {
98
+ assertWithinRoot(root, storeAbs);
99
+ rmSync(storeAbs, { recursive: true, force: true });
100
+ storeRemoved = true;
101
+ } catch (e) {
102
+ storeKept = true;
103
+ warnings.push('canonical store kept at .agents/skills/archgen: ' + e.message);
104
+ }
85
105
  } else {
86
106
  storeKept = true;
87
107
  warnings.push('canonical store kept at .agents/skills/archgen: modified vs vendor (or unverifiable) - review manually');
@@ -104,5 +124,23 @@ export function uninstallProject(projectDir, packageRoot) {
104
124
  const manifestRemoved = rmIfExists(join(root, relJoin(MANIFEST_REL)));
105
125
  if (manifestRemoved) pruneEmptyParents(root, dirname(MANIFEST_REL));
106
126
 
107
- return { root, linkRemoved, strippedBlocks, storeRemoved, storeKept, manifestRemoved, mcpRemoved, warnings };
127
+ // 6. The legacy extension runtime directory. The VS Code extension wrote a
128
+ // heartbeat and per-command acknowledgements under `.archgen/.runtime/`;
129
+ // it is dead state now that the extension and its bridge are gone, and it
130
+ // is archgen-owned, so the uninstall removes it. Feature folders and
131
+ // backups beside it are untouched.
132
+ const runtimeRemoved = rmIfExists(join(root, '.archgen', '.runtime'));
133
+ if (runtimeRemoved) pruneEmptyParents(root, '.archgen/.runtime');
134
+
135
+ return {
136
+ root,
137
+ linkRemoved,
138
+ strippedBlocks,
139
+ storeRemoved,
140
+ storeKept,
141
+ manifestRemoved,
142
+ runtimeRemoved,
143
+ mcpRemoved,
144
+ warnings,
145
+ };
108
146
  }
@@ -1,7 +1,7 @@
1
1
  // version-stamp.js — the `.archgen-version` stamp contract.
2
2
  //
3
- // WHY: the VS Code extension reads `<skillRoot>/.archgen-version` to tell an
4
- // outdated skill copy from a current one and warns non-blockingly (missing or
3
+ // WHY: the MCP server reads `<skillRoot>/.archgen-version` to tell an
4
+ // outdated skill copy from a current one and reports it (missing or
5
5
  // unparseable content means "unknown (legacy)" — a pre-stamp install). The
6
6
  // CLI is the only writer: every operation that lays down skill files (init,
7
7
  // global install in symlink AND --copy mode, update via its init/doctor
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 0;
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.6",
3
+ "version": "0.1.0",
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",
@@ -38,10 +38,10 @@
38
38
  "bugs": {
39
39
  "url": "https://github.com/akashmaurya99/archgen/issues"
40
40
  },
41
- "//": "vendor/skills/archgen is a full mirror of ../../skill synced by scripts/sync-vendor.mjs (run automatically via prepublishOnly). The extension ships separately as a VS Code vsix.",
41
+ "//": "vendor/skills/archgen is a full mirror of ../../skill synced by scripts/sync-vendor.mjs (run automatically via prepublishOnly).",
42
42
  "scripts": {
43
43
  "sync:config": "node scripts/sync-config.mjs",
44
- "sync:check": "node scripts/sync-config.mjs --check",
44
+ "sync:check": "node scripts/sync-config.mjs --check && node scripts/sync-vendor.mjs --check",
45
45
  "sync": "node scripts/sync-config.mjs && node scripts/sync-vendor.mjs",
46
46
  "prepublishOnly": "node scripts/sync-config.mjs && node scripts/sync-vendor.mjs",
47
47
  "test": "ARCHGEN_CODEGRAPH=0 node --test test/*.test.mjs"