@sabaiway/agent-workflow-kit 1.11.0 → 1.12.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/tools/fs-safe.mjs CHANGED
@@ -4,15 +4,20 @@
4
4
  // unit-testable without touching the real filesystem; the defaults are Node's SYNC fs (matching the
5
5
  // tools/ detector style). Dependency-free, Node >= 18.
6
6
  //
7
- // Three primitives:
7
+ // Five primitives:
8
8
  // assertContainedRealPath — refuse to write through/into a symlink, or to a dest outside a root.
9
9
  // copyTreeRefresh — recursive copy that OVERWRITES regular files (refresh), SKIPS a symlink
10
10
  // whose dest already exists (additive), and guards every dest component.
11
11
  // linkManaged — create/keep ONLY a symlink we own; STOP (typed ManagedLinkConflict) on
12
12
  // a foreign symlink or a non-symlink dest; refuse a symlinked source.
13
+ // removeTreeManaged — the inverse of copyTreeRefresh: recursively remove a dir/file ONLY when
14
+ // it (and its path) is not reached through a symlink and stays within root.
15
+ // unlinkManaged — the inverse of linkManaged: remove ONLY a symlink whose target is ours;
16
+ // STOP (typed ManagedLinkConflict) on a foreign symlink or a non-symlink.
13
17
 
14
18
  import {
15
19
  lstatSync, existsSync, mkdirSync, readdirSync, copyFileSync, readlinkSync, symlinkSync,
20
+ rmSync, unlinkSync,
16
21
  } from 'node:fs';
17
22
  import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
18
23
 
@@ -130,3 +135,47 @@ export const linkManaged = (src, dest, root, deps = {}) => {
130
135
  { dest, expected: src, found: target },
131
136
  );
132
137
  };
138
+
139
+ // Recursively remove a managed dir/file — the inverse of copyTreeRefresh. `assertContainedRealPath`
140
+ // guards `target` first: it refuses a `target` outside `root`, and refuses when `root`, any
141
+ // intermediate component, OR `target` itself is a symlink — so we never delete *through* or *at* a
142
+ // symlink (a symlinked skill dir is a STOP, not a follow-and-delete). A recursive `rm` does NOT
143
+ // follow symlinks *inside* the tree (Node unlinks a symlink entry rather than recursing into its
144
+ // target), so an internal symlink is removed safely without touching what it points at. Outcomes:
145
+ // 'removed', or 'noop' when the target is already absent. Dependency-injected (lstat / rm).
146
+ export const removeTreeManaged = (target, root, deps = {}) => {
147
+ const lstat = deps.lstat ?? lstatSync;
148
+ const rm = deps.rm ?? ((p) => rmSync(p, { recursive: true, force: true }));
149
+ assertContainedRealPath(root, target, deps);
150
+ if (lstatNoFollow(target, lstat) === null) return 'noop';
151
+ rm(target);
152
+ return 'removed';
153
+ };
154
+
155
+ // Remove ONLY a symlink we own — the inverse of linkManaged. Guards the PARENT chain (root +
156
+ // intermediate dirs) the same way linkManaged does (the leaf IS the managed symlink, so it is
157
+ // inspected, not traversal-rejected). Outcomes: 'unlinked' (a symlink whose resolved target is our
158
+ // `expectedSrc` — including a dangling-but-ours link), 'noop' (dest absent), or a thrown
159
+ // ManagedLinkConflict (a non-symlink, or a symlink pointing elsewhere — never removed).
160
+ export const unlinkManaged = (dest, expectedSrc, root, deps = {}) => {
161
+ const lstat = deps.lstat ?? lstatSync;
162
+ const readlink = deps.readlink ?? readlinkSync;
163
+ const unlink = deps.unlink ?? unlinkSync;
164
+
165
+ assertContainedRealPath(root, dirname(dest), deps);
166
+ const existing = lstatNoFollow(dest, lstat);
167
+ if (existing === null) return 'noop';
168
+ if (!existing.isSymbolicLink()) {
169
+ throw managedLinkConflict(`refusing to remove a non-symlink at ${dest}`, { dest, found: 'file' });
170
+ }
171
+ const target = readlink(dest);
172
+ const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(dest), target);
173
+ if (resolvedTarget !== resolve(expectedSrc)) {
174
+ throw managedLinkConflict(
175
+ `refusing to remove a foreign symlink at ${dest} (points at ${target}, not our ${expectedSrc})`,
176
+ { dest, expected: expectedSrc, found: target },
177
+ );
178
+ }
179
+ unlink(dest);
180
+ return 'unlinked';
181
+ };
@@ -9,6 +9,8 @@ import {
9
9
  assertContainedRealPath,
10
10
  copyTreeRefresh,
11
11
  linkManaged,
12
+ removeTreeManaged,
13
+ unlinkManaged,
12
14
  MANAGED_LINK_CONFLICT,
13
15
  } from './fs-safe.mjs';
14
16
 
@@ -204,3 +206,141 @@ describe('linkManaged', () => {
204
206
  assert.throws(() => linkManaged(srcDir, join(root, 'cmd'), root), /regular file/i);
205
207
  });
206
208
  });
209
+
210
+ // ── removeTreeManaged ───────────────────────────────────────────────────────────
211
+
212
+ describe('removeTreeManaged', () => {
213
+ it('recursively removes a managed dir tree', () => {
214
+ const root = join(dir, 'skills');
215
+ const skill = join(root, 'agent-workflow-kit');
216
+ mkdirSync(join(skill, 'tools'), { recursive: true });
217
+ writeFileSync(join(skill, 'SKILL.md'), 'x');
218
+ writeFileSync(join(skill, 'tools', 'a.mjs'), 'y');
219
+ const result = removeTreeManaged(skill, root);
220
+ assert.equal(result, 'removed');
221
+ assert.equal(existsSync(skill), false);
222
+ assert.equal(existsSync(root), true); // only the target went, not the parent
223
+ });
224
+
225
+ it('is a no-op when the target is already absent', () => {
226
+ const root = join(dir, 'skills');
227
+ mkdirSync(root);
228
+ assert.equal(removeTreeManaged(join(root, 'gone'), root), 'noop');
229
+ });
230
+
231
+ it('STOPs on a symlinked target (never follows + deletes through it)', () => {
232
+ const root = join(dir, 'skills');
233
+ const real = join(dir, 'real-skill');
234
+ mkdirSync(root);
235
+ mkdirSync(real);
236
+ writeFileSync(join(real, 'keep.txt'), 'keep');
237
+ symlinkSync(real, join(root, 'agent-workflow-kit')); // the skill dir is a symlink
238
+ assert.throws(() => removeTreeManaged(join(root, 'agent-workflow-kit'), root), /symlink/i);
239
+ assert.equal(existsSync(join(real, 'keep.txt')), true); // the target it pointed at is untouched
240
+ });
241
+
242
+ it('removes a symlink ENTRY inside the tree without touching what it points at', () => {
243
+ const root = join(dir, 'skills');
244
+ const skill = join(root, 'agent-workflow-kit');
245
+ const outside = join(dir, 'outside');
246
+ mkdirSync(skill, { recursive: true });
247
+ mkdirSync(outside);
248
+ writeFileSync(join(outside, 'precious.txt'), 'precious');
249
+ symlinkSync(outside, join(skill, 'link-to-outside')); // an internal symlink
250
+ removeTreeManaged(skill, root);
251
+ assert.equal(existsSync(skill), false);
252
+ assert.equal(existsSync(join(outside, 'precious.txt')), true); // never recursed through the link
253
+ });
254
+
255
+ it('refuses a target outside the root', () => {
256
+ const root = join(dir, 'skills');
257
+ mkdirSync(root);
258
+ assert.throws(() => removeTreeManaged(join(dir, 'elsewhere'), root), /outside/);
259
+ });
260
+
261
+ it('rm is injectable (no real deletion when injected)', () => {
262
+ const root = join(dir, 'skills');
263
+ const skill = join(root, 'agent-workflow-kit');
264
+ mkdirSync(skill, { recursive: true });
265
+ let removed = null;
266
+ const result = removeTreeManaged(skill, root, { rm: (p) => { removed = p; } });
267
+ assert.equal(result, 'removed');
268
+ assert.equal(removed, skill);
269
+ assert.equal(existsSync(skill), true); // the injected rm did nothing
270
+ });
271
+ });
272
+
273
+ // ── unlinkManaged ───────────────────────────────────────────────────────────────
274
+
275
+ describe('unlinkManaged', () => {
276
+ const makeSrc = () => {
277
+ const src = join(dir, 'src.sh');
278
+ writeFileSync(src, '#!/bin/sh\n');
279
+ return src;
280
+ };
281
+
282
+ it('unlinks a symlink that points at our source', () => {
283
+ const src = makeSrc();
284
+ const root = join(dir, 'bin');
285
+ mkdirSync(root);
286
+ const dest = join(root, 'cmd');
287
+ symlinkSync(src, dest);
288
+ const result = unlinkManaged(dest, src, root);
289
+ assert.equal(result, 'unlinked');
290
+ assert.equal(existsSync(dest), false);
291
+ assert.equal(existsSync(src), true); // the source it pointed at is untouched
292
+ });
293
+
294
+ it('is a no-op when the dest is absent', () => {
295
+ const src = makeSrc();
296
+ const root = join(dir, 'bin');
297
+ mkdirSync(root);
298
+ assert.equal(unlinkManaged(join(root, 'cmd'), src, root), 'noop');
299
+ });
300
+
301
+ it('STOPs on a non-symlink dest (typed ManagedLinkConflict)', () => {
302
+ const src = makeSrc();
303
+ const root = join(dir, 'bin');
304
+ mkdirSync(root);
305
+ const dest = join(root, 'cmd');
306
+ writeFileSync(dest, 'someone-elses-file');
307
+ assert.throws(() => unlinkManaged(dest, src, root), (err) => err.code === MANAGED_LINK_CONFLICT);
308
+ assert.equal(readFileSync(dest, 'utf8'), 'someone-elses-file'); // untouched
309
+ });
310
+
311
+ it('STOPs on a foreign symlink (points elsewhere)', () => {
312
+ const src = makeSrc();
313
+ const root = join(dir, 'bin');
314
+ mkdirSync(root);
315
+ const dest = join(root, 'cmd');
316
+ const foreign = join(dir, 'foreign.sh');
317
+ writeFileSync(foreign, '#!/bin/sh\n');
318
+ symlinkSync(foreign, dest);
319
+ assert.throws(() => unlinkManaged(dest, src, root), (err) => err.code === MANAGED_LINK_CONFLICT);
320
+ assert.equal(readlinkSync(dest), foreign); // untouched
321
+ });
322
+
323
+ it('removes a dangling symlink that still textually points at our source', () => {
324
+ const src = join(dir, 'src.sh'); // never created → the link is dangling
325
+ const root = join(dir, 'bin');
326
+ mkdirSync(root);
327
+ const dest = join(root, 'cmd');
328
+ symlinkSync(src, dest);
329
+ assert.equal(unlinkManaged(dest, src, root), 'unlinked');
330
+ assert.equal(lstatSync(root).isDirectory(), true);
331
+ assert.equal(existsSync(dest), false);
332
+ });
333
+
334
+ it('unlink is injectable', () => {
335
+ const src = makeSrc();
336
+ const root = join(dir, 'bin');
337
+ mkdirSync(root);
338
+ const dest = join(root, 'cmd');
339
+ symlinkSync(src, dest);
340
+ let unlinked = null;
341
+ const result = unlinkManaged(dest, src, root, { unlink: (p) => { unlinked = p; } });
342
+ assert.equal(result, 'unlinked');
343
+ assert.equal(unlinked, dest);
344
+ assert.equal(existsSync(dest), true); // the injected unlink did nothing
345
+ });
346
+ });
@@ -77,7 +77,9 @@ const readSkillVersion = (text) => {
77
77
 
78
78
  // Authoritative version source: package.json where one exists, else SKILL.md
79
79
  // frontmatter metadata.version. So a bridge (no package.json) can't drift from its SKILL.md.
80
- const readAuthoritativeVersion = (skillDir) => {
80
+ // Exported so the family registry (tools/family-registry.mjs) reports an INSTALLED member's
81
+ // version from the SAME authoritative source the validator checks — no second, drifting reader.
82
+ export const readAuthoritativeVersion = (skillDir) => {
81
83
  const pkgPath = join(skillDir, 'package.json');
82
84
  if (existsSync(pkgPath)) {
83
85
  try {
@@ -0,0 +1,144 @@
1
+ // Integration acceptance for the guarded uninstaller against the REAL filesystem — what the mocked
2
+ // unit test cannot prove: real validateManifest over real skill dirs, real removeTreeManaged deleting
3
+ // a real tree, real unlinkManaged removing OUR symlink while leaving a FOREIGN one, a real marker
4
+ // pre-commit hook removed, and user-authored docs/ai LEFT INTACT after a full --yes teardown. The
5
+ // git-backed fence unhide is delegated to hideFootprint (already covered by its own integration test),
6
+ // so it is injected here as a recording stub — this test owns the uninstaller's own fs mutations.
7
+
8
+ import { describe, it, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, existsSync, lstatSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { buildPlan, executePlan, SAFE_REMOVE, MANAGED_MARKER, REPORT_ONLY, STOP } from './uninstall.mjs';
14
+ import { surveyFamily, surveyProject } from './family-registry.mjs';
15
+ import { START_MARKER } from './hide-footprint.mjs';
16
+
17
+ const made = [];
18
+ const mkdtemp = (tag) => { const d = mkdtempSync(join(tmpdir(), tag)); made.push(d); return d; };
19
+ afterEach(() => { while (made.length) { try { rmSync(made.pop(), { recursive: true, force: true }); } catch { /* best effort */ } } });
20
+
21
+ const writeFile = (p, s) => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, s); };
22
+
23
+ // A minimal but VALID family skill dir (passes the real validateManifest: family/schema/kind/version
24
+ // match + SKILL.md metadata.version == capability.json version + role sources exist).
25
+ const makeMemorySkill = (skillsRoot) => {
26
+ const dir = join(skillsRoot, 'agent-workflow-memory');
27
+ writeFile(join(dir, 'SKILL.md'), "---\nname: agent-workflow-memory\nmetadata:\n version: '1.1.1'\n---\n# memory\n");
28
+ writeFile(join(dir, 'capability.json'), JSON.stringify({
29
+ family: 'agent-workflow', schema: 1, name: 'agent-workflow-memory', kind: 'memory-substrate',
30
+ version: '1.1.1', provides: ['context'], roles: {},
31
+ detect: { installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' } },
32
+ }));
33
+ return dir;
34
+ };
35
+
36
+ const makeCodexBridge = (skillsRoot) => {
37
+ const dir = join(skillsRoot, 'codex-cli-bridge');
38
+ writeFile(join(dir, 'SKILL.md'), "---\nname: codex-cli-bridge\nmetadata:\n version: '1.0.0'\n---\n# codex\n");
39
+ writeFile(join(dir, 'bin', 'codex-exec.sh'), '#!/bin/sh\n');
40
+ writeFile(join(dir, 'bin', 'codex-review.sh'), '#!/bin/sh\n');
41
+ writeFile(join(dir, 'capability.json'), JSON.stringify({
42
+ family: 'agent-workflow', schema: 1, name: 'codex-cli-bridge', kind: 'execution-backend',
43
+ version: '1.0.0', provides: ['execute', 'review'],
44
+ roles: {
45
+ execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' },
46
+ review: { cmd: 'codex-review', source: 'bin/codex-review.sh' },
47
+ },
48
+ detect: { installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' } },
49
+ }));
50
+ return dir;
51
+ };
52
+
53
+ describe('uninstall integration (real fs)', () => {
54
+ it('plans + applies a full guarded teardown: removes ours, keeps foreign + user-authored', () => {
55
+ const home = mkdtemp('aw-unh-home-');
56
+ const skills = mkdtemp('aw-unh-skills-');
57
+ const bindir = mkdtemp('aw-unh-bin-');
58
+ const proj = mkdtemp('aw-unh-proj-');
59
+ const foreignTarget = mkdtemp('aw-unh-foreign-');
60
+
61
+ const memorySkill = makeMemorySkill(skills);
62
+ const codexSkill = makeCodexBridge(skills);
63
+
64
+ // ~/.local/bin wrappers: codex-exec is OURS; codex-review is a FOREIGN symlink (must be kept).
65
+ symlinkSync(join(codexSkill, 'bin/codex-exec.sh'), join(bindir, 'codex-exec'));
66
+ writeFileSync(join(foreignTarget, 'codex-review'), '#!/bin/sh\n');
67
+ symlinkSync(join(foreignTarget, 'codex-review'), join(bindir, 'codex-review'));
68
+
69
+ // Project surfaces: a hidden fence, OUR marker pre-commit hook, and user-authored docs/ai.
70
+ writeFile(join(proj, '.git/info/exclude'), `# user rule\n${START_MARKER}\n/AGENTS.md\n# <<< agent-workflow-kit hidden mode <<<\n`);
71
+ writeFile(join(proj, '.git/hooks/pre-commit'), '#!/usr/bin/env bash\n# myproj:install-git-hooks.mjs\nset -e\nnode scripts/check-docs-size.mjs\n');
72
+ writeFile(join(proj, 'docs/ai/handover.md'), '# handover (USER-AUTHORED)\n');
73
+ writeFile(join(proj, 'docs/ai/.workflow-version'), '1.3.0\n');
74
+
75
+ // Resolve only memory + codex as installed (env-pointed); other members fall to <home>/.claude → absent.
76
+ const deps = {
77
+ getenv: { AGENT_WORKFLOW_MEMORY_DIR: memorySkill, CODEX_CLI_BRIDGE_DIR: codexSkill },
78
+ home,
79
+ };
80
+ const family = surveyFamily(deps);
81
+ const project = surveyProject(proj, deps);
82
+ assert.equal(project.hiddenFence, true);
83
+ assert.equal(family.find((m) => m.name === 'agent-workflow-memory').manifestState, 'ok');
84
+ assert.equal(family.find((m) => m.name === 'codex-cli-bridge').manifestState, 'ok');
85
+
86
+ const plan = buildPlan({ family, project, projectDir: proj, bindir }, deps);
87
+ const cls = (surface, pred) => plan.items.find((i) => i.surface === surface && pred(i));
88
+ assert.equal(cls('skill', (i) => i.member === 'agent-workflow-memory').class, SAFE_REMOVE);
89
+ assert.equal(cls('wrapper', (i) => i.path.endsWith('codex-exec')).class, MANAGED_MARKER);
90
+ assert.equal(cls('wrapper', (i) => i.path.endsWith('codex-review')).class, STOP); // foreign
91
+ assert.equal(cls('fence', () => true).class, MANAGED_MARKER);
92
+ assert.equal(cls('hook', () => true).class, MANAGED_MARKER);
93
+ assert.equal(cls('docs', (i) => i.path.endsWith('docs/ai')).class, REPORT_ONLY);
94
+
95
+ // Apply. Inject a recording fence-unhide (its real git path is covered by hide-footprint's own test).
96
+ const unhideCalls = [];
97
+ const r = executePlan(plan, { yes: true }, { ...deps, hideFootprint: (opts) => { unhideCalls.push(opts); return { action: 'unhidden' }; } });
98
+
99
+ // Ours is gone:
100
+ assert.equal(existsSync(memorySkill), false, 'memory skill dir removed');
101
+ assert.equal(existsSync(codexSkill), false, 'codex skill dir removed');
102
+ assert.equal(existsSync(join(bindir, 'codex-exec')), false, 'our wrapper symlink removed');
103
+ assert.equal(existsSync(join(proj, '.git/hooks/pre-commit')), false, 'marker hook removed');
104
+ assert.equal(r.unhidden, true);
105
+ // The fence is validated by a dry-run unhide in preflight, then unhidden for real in the mutate phase.
106
+ assert.deepEqual(unhideCalls, [{ dir: proj, unhide: true, dryRun: true }, { dir: proj, unhide: true }]);
107
+
108
+ // Foreign + user-authored is KEPT:
109
+ assert.equal(lstatSync(join(bindir, 'codex-review')).isSymbolicLink(), true, 'foreign wrapper kept');
110
+ assert.equal(existsSync(join(foreignTarget, 'codex-review')), true, 'foreign target untouched');
111
+ assert.equal(existsSync(join(proj, 'docs/ai/handover.md')), true, 'user-authored docs/ai NEVER deleted');
112
+ });
113
+
114
+ it('preflight refuses (zero mutation) when a skill dir turns foreign between plan and apply', () => {
115
+ const home = mkdtemp('aw-unh2-home-');
116
+ const skills = mkdtemp('aw-unh2-skills-');
117
+ const memorySkill = makeMemorySkill(skills);
118
+ const deps = { getenv: { AGENT_WORKFLOW_MEMORY_DIR: memorySkill }, home };
119
+
120
+ const family = surveyFamily(deps);
121
+ const plan = buildPlan({ family }, deps);
122
+ assert.equal(plan.items.find((i) => i.surface === 'skill').class, SAFE_REMOVE);
123
+
124
+ // Corrupt the manifest after planning → the preflight re-check must STOP, leaving the dir intact.
125
+ writeFileSync(join(memorySkill, 'capability.json'), '{ not json');
126
+ assert.throws(() => executePlan(plan, { yes: true }, deps), (err) => err.code === 'UNINSTALL_STOP');
127
+ assert.equal(existsSync(memorySkill), true, 'skill dir untouched after a refused preflight');
128
+ });
129
+
130
+ it('keeps a pre-commit hook whose marker was removed between plan and apply', () => {
131
+ const home = mkdtemp('aw-unh3-home-');
132
+ const proj = mkdtemp('aw-unh3-proj-');
133
+ writeFile(join(proj, '.git/hooks/pre-commit'), '#!/usr/bin/env bash\n# myproj:install-git-hooks.mjs\nset -e\n');
134
+ const deps = { getenv: {}, home };
135
+
136
+ const plan = buildPlan({ family: [], project: surveyProject(proj, deps), projectDir: proj }, deps);
137
+ assert.equal(plan.items.find((i) => i.surface === 'hook').class, MANAGED_MARKER);
138
+
139
+ // The user rewrites the hook (dropping our marker) before they apply the teardown.
140
+ writeFileSync(join(proj, '.git/hooks/pre-commit'), '#!/bin/sh\n# my own hook now\n');
141
+ assert.throws(() => executePlan(plan, { yes: true }, deps), (err) => err.code === 'UNINSTALL_STOP');
142
+ assert.equal(existsSync(join(proj, '.git/hooks/pre-commit')), true, 'a now-unmarked (user) hook is NEVER deleted');
143
+ });
144
+ });