@sabaiway/agent-workflow-kit 1.15.0 → 1.15.2
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/CHANGELOG.md +43 -0
- package/SKILL.md +3 -2
- package/capability.json +1 -1
- package/package.json +5 -2
- package/references/contracts.md +2 -0
- package/bin/install.test.mjs +0 -347
- package/tools/delegation.test.mjs +0 -116
- package/tools/detect-backends.test.mjs +0 -444
- package/tools/engine-source.test.mjs +0 -280
- package/tools/family-registry.test.mjs +0 -279
- package/tools/fs-safe.test.mjs +0 -346
- package/tools/hide-footprint.integration.test.mjs +0 -168
- package/tools/hide-footprint.test.mjs +0 -463
- package/tools/inject-methodology.test.mjs +0 -689
- package/tools/known-footprint.test.mjs +0 -271
- package/tools/manifest/fixtures/bad-available/SKILL.md +0 -7
- package/tools/manifest/fixtures/bad-available/capability.json +0 -10
- package/tools/manifest/fixtures/detect-array/SKILL.md +0 -7
- package/tools/manifest/fixtures/detect-array/capability.json +0 -10
- package/tools/manifest/fixtures/malformed-json/capability.json +0 -1
- package/tools/manifest/fixtures/metadata-version/SKILL.md +0 -10
- package/tools/manifest/fixtures/metadata-version/capability.json +0 -9
- package/tools/manifest/fixtures/missing-key/SKILL.md +0 -7
- package/tools/manifest/fixtures/missing-key/capability.json +0 -8
- package/tools/manifest/fixtures/missing-source/SKILL.md +0 -7
- package/tools/manifest/fixtures/missing-source/capability.json +0 -11
- package/tools/manifest/fixtures/nested-version-decoy/SKILL.md +0 -10
- package/tools/manifest/fixtures/nested-version-decoy/capability.json +0 -9
- package/tools/manifest/fixtures/null-root/capability.json +0 -1
- package/tools/manifest/fixtures/provides-roles-mismatch/SKILL.md +0 -7
- package/tools/manifest/fixtures/provides-roles-mismatch/bin/run.sh +0 -2
- package/tools/manifest/fixtures/provides-roles-mismatch/capability.json +0 -11
- package/tools/manifest/fixtures/stub/capability.json +0 -10
- package/tools/manifest/fixtures/traversal-source/SKILL.md +0 -7
- package/tools/manifest/fixtures/traversal-source/capability.json +0 -11
- package/tools/manifest/fixtures/unknown-schema/capability.json +0 -9
- package/tools/manifest/fixtures/valid/SKILL.md +0 -10
- package/tools/manifest/fixtures/valid/bin/run.sh +0 -3
- package/tools/manifest/fixtures/valid/capability.json +0 -18
- package/tools/manifest/fixtures/version-mismatch/SKILL.md +0 -7
- package/tools/manifest/fixtures/version-mismatch/capability.json +0 -9
- package/tools/manifest/fixtures/win-absolute-source/SKILL.md +0 -7
- package/tools/manifest/fixtures/win-absolute-source/capability.json +0 -11
- package/tools/manifest/validate.test.mjs +0 -73
- package/tools/procedures.test.mjs +0 -303
- package/tools/recipes.test.mjs +0 -538
- package/tools/release-scan.test.mjs +0 -41
- package/tools/setup-backends.test.mjs +0 -500
- package/tools/uninstall.integration.test.mjs +0 -144
- package/tools/uninstall.test.mjs +0 -401
- package/tools/velocity-profile.test.mjs +0 -496
|
@@ -1,500 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
4
|
-
mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, readFileSync, existsSync, lstatSync,
|
|
5
|
-
} from 'node:fs';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import {
|
|
9
|
-
bindirOnPath,
|
|
10
|
-
deriveLinks,
|
|
11
|
-
placeSkill,
|
|
12
|
-
linkWrappers,
|
|
13
|
-
planFor,
|
|
14
|
-
main,
|
|
15
|
-
SETUP_STOP,
|
|
16
|
-
} from './setup-backends.mjs';
|
|
17
|
-
|
|
18
|
-
const eacces = () => Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
19
|
-
|
|
20
|
-
// ── fixtures ────────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
const CODEX_ROLES = {
|
|
23
|
-
execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' },
|
|
24
|
-
review: { cmd: 'codex-review', source: 'bin/codex-review.sh' },
|
|
25
|
-
};
|
|
26
|
-
const manifestOf = (name, roles) => ({
|
|
27
|
-
family: 'agent-workflow', schema: 1, name, kind: 'execution-backend', version: '1.0.0',
|
|
28
|
-
provides: Object.keys(roles), roles,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
// A bundle SKILL.md the REAL validator accepts (it now validates the bundle): metadata.version must
|
|
32
|
-
// match the manifest version (1.0.0). The frontmatter `name` is decorative — validateManifest reads
|
|
33
|
-
// the name from capability.json.
|
|
34
|
-
const skillMd = (name) => `---\nname: ${name}\nmetadata:\n version: '1.0.0'\n---\n# ${name} (bundled)\n`;
|
|
35
|
-
|
|
36
|
-
// Write a fake bundle at <root>/<name>/ with SKILL.md + capability.json + the wrapper scripts.
|
|
37
|
-
const writeBundle = (root, name = 'codex-cli-bridge', roles = CODEX_ROLES, extra = {}) => {
|
|
38
|
-
const dir = join(root, name);
|
|
39
|
-
mkdirSync(join(dir, 'bin'), { recursive: true });
|
|
40
|
-
writeFileSync(join(dir, 'SKILL.md'), extra.skill ?? skillMd(name));
|
|
41
|
-
writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifestOf(name, roles), null, 2));
|
|
42
|
-
for (const r of Object.values(roles)) writeFileSync(join(dir, r.source), '#!/bin/sh\necho hi\n');
|
|
43
|
-
if (extra.newFile) writeFileSync(join(dir, extra.newFile), 'bundled-only\n');
|
|
44
|
-
return dir;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const okValidate = (name = 'codex-cli-bridge') => () => ({ result: 'valid', name, kind: 'execution-backend', available: true });
|
|
48
|
-
const fakeDetect = (over = {}) => () => ({
|
|
49
|
-
name: 'codex-cli-bridge',
|
|
50
|
-
manifestState: 'ok',
|
|
51
|
-
cli: { bin: 'codex', state: 'present', path: '/usr/bin/codex' },
|
|
52
|
-
credentials: { state: 'present', path: '/c/auth.json' },
|
|
53
|
-
wrappers: [], readiness: 'ready', setupHint: { url: 'u' }, skillDir: '/s',
|
|
54
|
-
...over,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
let tmp;
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
tmp = mkdtempSync(join(tmpdir(), 'awf-setup-'));
|
|
60
|
-
});
|
|
61
|
-
afterEach(() => {
|
|
62
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// Build a deps object that points the codex backend at controllable skill/bundle/bindir dirs.
|
|
66
|
-
const baseDeps = (over = {}) => {
|
|
67
|
-
const bundleRoot = over.bundleRoot ?? join(tmp, 'bundles');
|
|
68
|
-
const skillDir = over.skillDir ?? join(tmp, 'skill');
|
|
69
|
-
return {
|
|
70
|
-
platform: 'linux',
|
|
71
|
-
home: join(tmp, 'home'),
|
|
72
|
-
getenv: { CODEX_CLI_BRIDGE_DIR: skillDir, PATH: '', ...(over.getenv ?? {}) },
|
|
73
|
-
bundleRoot,
|
|
74
|
-
bindir: over.bindir ?? join(tmp, 'bin'),
|
|
75
|
-
validate: over.validate,
|
|
76
|
-
detect: over.detect ?? fakeDetect(),
|
|
77
|
-
...over.rest,
|
|
78
|
-
};
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// ── bindirOnPath ────────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
describe('bindirOnPath', () => {
|
|
84
|
-
it('true when the dir is a PATH member (posix, normalised)', () => {
|
|
85
|
-
assert.equal(bindirOnPath('/home/u/.local/bin', { PATH: '/usr/bin:/home/u/.local/bin/' }, 'linux'), true);
|
|
86
|
-
});
|
|
87
|
-
it('false when absent', () => {
|
|
88
|
-
assert.equal(bindirOnPath('/home/u/.local/bin', { PATH: '/usr/bin:/bin' }, 'linux'), false);
|
|
89
|
-
});
|
|
90
|
-
it('win32 is case-insensitive and uses ; + Path', () => {
|
|
91
|
-
assert.equal(bindirOnPath('C:\\Tools\\Bin', { Path: 'c:\\tools\\bin;C:\\Windows' }, 'win32'), true);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// ── deriveLinks (pure manifest → links, untrusted-string validation) ──────────
|
|
96
|
-
|
|
97
|
-
describe('deriveLinks', () => {
|
|
98
|
-
const SK = '/skills/codex-cli-bridge';
|
|
99
|
-
|
|
100
|
-
it('dedupes a cmd shared by two roles with the SAME source (agy-run case)', () => {
|
|
101
|
-
const roles = { review: { cmd: 'agy-run', source: 'bin/agy.sh' }, probe: { cmd: 'agy-run', source: 'bin/agy.sh' } };
|
|
102
|
-
const links = deriveLinks(manifestOf('antigravity-cli-bridge', roles), SK);
|
|
103
|
-
assert.equal(links.length, 1);
|
|
104
|
-
assert.equal(links[0].cmd, 'agy-run');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('two distinct cmds → two links', () => {
|
|
108
|
-
assert.equal(deriveLinks(manifestOf('codex-cli-bridge', CODEX_ROLES), SK).length, 2);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
for (const bad of ['../evil', 'a/b', 'ab', '', 'a b']) {
|
|
112
|
-
it(`rejects cmd ${JSON.stringify(bad)} (allowlist)`, () => {
|
|
113
|
-
const roles = { execute: { cmd: bad, source: 'bin/x.sh' } };
|
|
114
|
-
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), (e) => e.code === SETUP_STOP);
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
it('STOPs when one cmd maps to two different sources', () => {
|
|
119
|
-
const roles = { execute: { cmd: 'dup', source: 'bin/a.sh' }, review: { cmd: 'dup', source: 'bin/b.sh' } };
|
|
120
|
-
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), /two sources/);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('STOPs when a source escapes the skill dir', () => {
|
|
124
|
-
const roles = { execute: { cmd: 'codex-exec', source: '../escape.sh' } };
|
|
125
|
-
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), /escapes/);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('STOPs when there are no wrapper roles', () => {
|
|
129
|
-
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', {}), SK), /no wrapper roles/);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
for (const reserved of ['.', '..']) {
|
|
133
|
-
it(`rejects the reserved cmd ${JSON.stringify(reserved)} (would resolve to bindir / its parent)`, () => {
|
|
134
|
-
const roles = { execute: { cmd: reserved, source: 'bin/x.sh' } };
|
|
135
|
-
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), /reserved path name/);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ── planFor: action selection by axes (read-only, no mutation) ────────────────
|
|
141
|
-
|
|
142
|
-
describe('planFor — action selection', () => {
|
|
143
|
-
it('not-installed (absent skill dir) → place + link, no fs mutation', () => {
|
|
144
|
-
writeBundle(join(tmp, 'bundles'));
|
|
145
|
-
const skillDir = join(tmp, 'skill');
|
|
146
|
-
const plan = planFor('codex', baseDeps({ skillDir }));
|
|
147
|
-
assert.equal(plan.outcome, 'ok');
|
|
148
|
-
assert.equal(plan.place.action, 'place');
|
|
149
|
-
assert.deepEqual(plan.links.map((l) => l.dstState).sort(), ['absent', 'absent']);
|
|
150
|
-
assert.equal(existsSync(skillDir), false, 'planFor must not create the skill dir');
|
|
151
|
-
assert.equal(existsSync(plan.bindir), false, 'planFor must not create the bindir');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('ok + proven-managed, one wrapper linked → refresh + relink the missing one', () => {
|
|
155
|
-
writeBundle(join(tmp, 'bundles'));
|
|
156
|
-
const skillDir = join(tmp, 'skill');
|
|
157
|
-
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
158
|
-
writeFileSync(join(skillDir, 'SKILL.md'), '# installed\n');
|
|
159
|
-
writeFileSync(join(skillDir, 'capability.json'), '{}');
|
|
160
|
-
const bindir = join(tmp, 'bin');
|
|
161
|
-
mkdirSync(bindir, { recursive: true });
|
|
162
|
-
symlinkSync(join(skillDir, 'bin', 'codex-exec.sh'), join(bindir, 'codex-exec')); // ours
|
|
163
|
-
const plan = planFor('codex', baseDeps({ skillDir, bindir, validate: okValidate() }));
|
|
164
|
-
assert.equal(plan.place.action, 'refresh');
|
|
165
|
-
const byCmd = Object.fromEntries(plan.links.map((l) => [l.cmd, l.dstState]));
|
|
166
|
-
assert.equal(byCmd['codex-exec'], 'ours');
|
|
167
|
-
assert.equal(byCmd['codex-review'], 'absent');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('ok + all wrappers linked + cli missing → outcome ok with a cli guide', () => {
|
|
171
|
-
writeBundle(join(tmp, 'bundles'));
|
|
172
|
-
const skillDir = join(tmp, 'skill');
|
|
173
|
-
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
174
|
-
writeFileSync(join(skillDir, 'SKILL.md'), '# installed\n');
|
|
175
|
-
const bindir = join(tmp, 'bin');
|
|
176
|
-
mkdirSync(bindir, { recursive: true });
|
|
177
|
-
symlinkSync(join(skillDir, 'bin', 'codex-exec.sh'), join(bindir, 'codex-exec'));
|
|
178
|
-
symlinkSync(join(skillDir, 'bin', 'codex-review.sh'), join(bindir, 'codex-review'));
|
|
179
|
-
const detect = fakeDetect({ cli: { bin: 'codex', state: 'missing', path: null } });
|
|
180
|
-
const plan = planFor('codex', baseDeps({ skillDir, bindir, validate: okValidate(), detect }));
|
|
181
|
-
assert.equal(plan.outcome, 'ok');
|
|
182
|
-
assert.deepEqual(plan.links.map((l) => l.dstState), ['ours', 'ours']);
|
|
183
|
-
assert.ok(plan.guides.some((g) => g.need === 'cli'));
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
for (const state of ['stub', 'foreign', 'invalid-manifest', 'unsupported-schema']) {
|
|
187
|
-
it(`${state} manifest → STOP, no mutation`, () => {
|
|
188
|
-
writeBundle(join(tmp, 'bundles'));
|
|
189
|
-
const skillDir = join(tmp, 'skill');
|
|
190
|
-
mkdirSync(skillDir, { recursive: true });
|
|
191
|
-
writeFileSync(join(skillDir, 'SKILL.md'), '# present\n');
|
|
192
|
-
const validate = () => ({
|
|
193
|
-
stub: { result: 'valid', name: 'codex-cli-bridge', kind: 'execution-backend', available: false },
|
|
194
|
-
foreign: { result: 'valid', name: 'other', kind: 'execution-backend', available: true },
|
|
195
|
-
'invalid-manifest': { result: 'invalid', errors: ['x'] },
|
|
196
|
-
'unsupported-schema': { result: 'unsupported', errors: ['x'] },
|
|
197
|
-
}[state]);
|
|
198
|
-
const plan = planFor('codex', baseDeps({ skillDir, validate }));
|
|
199
|
-
assert.equal(plan.outcome, 'stop');
|
|
200
|
-
assert.match(plan.reason, new RegExp(state));
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
it('marker unknown (EACCES on SKILL.md) → STOP, no write', () => {
|
|
205
|
-
writeBundle(join(tmp, 'bundles')); // a real, valid bundle (now validated); the skill dir is mocked
|
|
206
|
-
const skillDir = join(tmp, 'skill');
|
|
207
|
-
const deps = baseDeps({
|
|
208
|
-
skillDir,
|
|
209
|
-
rest: {
|
|
210
|
-
exists: () => true, // bundle marker + skill marker both "exist"
|
|
211
|
-
lstat: () => ({ isSymbolicLink: () => false, isDirectory: () => true, isFile: () => true }),
|
|
212
|
-
stat: () => { throw eacces(); }, // probing the marker fails non-ENOENT → unknown
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
const plan = planFor('codex', deps);
|
|
216
|
-
assert.equal(plan.outcome, 'stop');
|
|
217
|
-
assert.match(plan.reason, /cannot determine bridge skill state/);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('a foreign same-named wrapper elsewhere on PATH does NOT count as linked (per-bindir)', () => {
|
|
221
|
-
writeBundle(join(tmp, 'bundles'));
|
|
222
|
-
const skillDir = join(tmp, 'skill');
|
|
223
|
-
mkdirSync(skillDir, { recursive: true });
|
|
224
|
-
writeFileSync(join(skillDir, 'SKILL.md'), '# installed\n');
|
|
225
|
-
const elsewhere = join(tmp, 'elsewhere');
|
|
226
|
-
mkdirSync(elsewhere, { recursive: true });
|
|
227
|
-
writeFileSync(join(elsewhere, 'codex-exec'), 'foreign'); // same name, different dir on PATH
|
|
228
|
-
const bindir = join(tmp, 'bin');
|
|
229
|
-
const plan = planFor('codex', baseDeps({ skillDir, bindir, validate: okValidate(), getenv: { CODEX_CLI_BRIDGE_DIR: skillDir, PATH: elsewhere } }));
|
|
230
|
-
const exec = plan.links.find((l) => l.cmd === 'codex-exec');
|
|
231
|
-
assert.equal(exec.dstState, 'absent'); // judged at bindir/codex-exec, not PATH-wide
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// ── placeSkill (mutating) ─────────────────────────────────────────────────────
|
|
236
|
-
|
|
237
|
-
describe('placeSkill', () => {
|
|
238
|
-
it('refreshes a proven-managed dir (overwrites + adds bundled files)', () => {
|
|
239
|
-
writeBundle(join(tmp, 'bundles'), 'codex-cli-bridge', CODEX_ROLES, { newFile: 'NEW.txt' });
|
|
240
|
-
const skillDir = join(tmp, 'skill');
|
|
241
|
-
mkdirSync(skillDir, { recursive: true });
|
|
242
|
-
writeFileSync(join(skillDir, 'SKILL.md'), 'OLD\n');
|
|
243
|
-
writeFileSync(join(skillDir, 'capability.json'), '{}');
|
|
244
|
-
placeSkill('codex', baseDeps({ skillDir, validate: okValidate() }));
|
|
245
|
-
assert.equal(readFileSync(join(skillDir, 'SKILL.md'), 'utf8'), skillMd('codex-cli-bridge')); // overwritten
|
|
246
|
-
assert.ok(existsSync(join(skillDir, 'NEW.txt'))); // bundled-only file delivered
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('refuses a foreign bundle (valid JSON, wrong name) — corrupt kit, no place', () => {
|
|
250
|
-
const bundleRoot = join(tmp, 'bundles');
|
|
251
|
-
writeBundle(bundleRoot, 'codex-cli-bridge'); // create the expected slot…
|
|
252
|
-
const dir = join(bundleRoot, 'codex-cli-bridge'); // …then claim a foreign name in it
|
|
253
|
-
writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifestOf('not-codex', CODEX_ROLES), null, 2));
|
|
254
|
-
writeFileSync(join(dir, 'SKILL.md'), skillMd('not-codex'));
|
|
255
|
-
const skillDir = join(tmp, 'skill');
|
|
256
|
-
assert.throws(() => placeSkill('codex', baseDeps({ skillDir, bundleRoot })), /bundled bridge manifest/);
|
|
257
|
-
assert.equal(existsSync(join(skillDir, 'SKILL.md')), false); // never placed the foreign bridge
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it('refuses a non-empty foreign dir (no SKILL.md marker)', () => {
|
|
261
|
-
writeBundle(join(tmp, 'bundles'));
|
|
262
|
-
const skillDir = join(tmp, 'skill');
|
|
263
|
-
mkdirSync(skillDir, { recursive: true });
|
|
264
|
-
writeFileSync(join(skillDir, 'random.txt'), 'someone elses files\n');
|
|
265
|
-
assert.throws(() => placeSkill('codex', baseDeps({ skillDir })), (e) => e.code === SETUP_STOP);
|
|
266
|
-
assert.equal(existsSync(join(skillDir, 'SKILL.md')), false); // untouched
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('refuses to write through a symlinked skill dir', () => {
|
|
270
|
-
writeBundle(join(tmp, 'bundles'));
|
|
271
|
-
const real = join(tmp, 'real');
|
|
272
|
-
mkdirSync(real, { recursive: true });
|
|
273
|
-
const skillDir = join(tmp, 'skill-link');
|
|
274
|
-
symlinkSync(real, skillDir);
|
|
275
|
-
assert.throws(() => placeSkill('codex', baseDeps({ skillDir })), /symlink/i);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('fails loud when the bundle is missing (corrupt kit)', () => {
|
|
279
|
-
const skillDir = join(tmp, 'skill');
|
|
280
|
-
assert.throws(() => placeSkill('codex', baseDeps({ skillDir, bundleRoot: join(tmp, 'no-bundles') })), /bundled bridge missing/);
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// ── linkWrappers (mutating, preflight-then-mutate) ────────────────────────────
|
|
285
|
-
|
|
286
|
-
const placedSkill = (roles = CODEX_ROLES) => {
|
|
287
|
-
const skillDir = join(tmp, 'skill');
|
|
288
|
-
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
289
|
-
for (const r of Object.values(roles)) writeFileSync(join(skillDir, r.source), '#!/bin/sh\n');
|
|
290
|
-
return skillDir;
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
describe('linkWrappers', () => {
|
|
294
|
-
it('creates an absent bindir and links each wrapper (mkdir -p)', () => {
|
|
295
|
-
const skillDir = placedSkill();
|
|
296
|
-
const bindir = join(tmp, 'newbin', 'nested');
|
|
297
|
-
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
298
|
-
assert.deepEqual(res.links.map((l) => l.action).sort(), ['linked', 'linked']);
|
|
299
|
-
assert.equal(readlinkSync(join(bindir, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('links into a SYMLINKED bindir (common dotfiles setup) instead of refusing it', () => {
|
|
303
|
-
const skillDir = placedSkill();
|
|
304
|
-
const realBin = join(tmp, 'real-bin');
|
|
305
|
-
mkdirSync(realBin, { recursive: true });
|
|
306
|
-
const bindir = join(tmp, 'link-bin');
|
|
307
|
-
symlinkSync(realBin, bindir); // ~/.local/bin → elsewhere
|
|
308
|
-
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
309
|
-
assert.deepEqual(res.links.map((l) => l.action).sort(), ['linked', 'linked']);
|
|
310
|
-
// the wrapper lands in the real dir, reachable through the symlinked bindir, pointing at our source
|
|
311
|
-
assert.equal(readlinkSync(join(realBin, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
312
|
-
assert.equal(readlinkSync(join(bindir, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('is idempotent — a re-run is all noop', () => {
|
|
316
|
-
const skillDir = placedSkill();
|
|
317
|
-
const bindir = join(tmp, 'bin');
|
|
318
|
-
linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
319
|
-
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
320
|
-
assert.deepEqual(res.links.map((l) => l.action), ['noop', 'noop']);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('STOPs on a non-symlink dest (and does not clobber it)', () => {
|
|
324
|
-
const skillDir = placedSkill();
|
|
325
|
-
const bindir = join(tmp, 'bin');
|
|
326
|
-
mkdirSync(bindir, { recursive: true });
|
|
327
|
-
writeFileSync(join(bindir, 'codex-exec'), 'real file');
|
|
328
|
-
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir }), (e) => e.code === SETUP_STOP);
|
|
329
|
-
assert.equal(readFileSync(join(bindir, 'codex-exec'), 'utf8'), 'real file');
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('STOPs on a foreign symlink dest', () => {
|
|
333
|
-
const skillDir = placedSkill();
|
|
334
|
-
const bindir = join(tmp, 'bin');
|
|
335
|
-
mkdirSync(bindir, { recursive: true });
|
|
336
|
-
symlinkSync(join(tmp, 'somewhere-else'), join(bindir, 'codex-exec'));
|
|
337
|
-
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir }), /foreign symlink/);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it('preflight conflict on the 2nd wrapper → ZERO mutations (1st not linked)', () => {
|
|
341
|
-
const skillDir = placedSkill();
|
|
342
|
-
const bindir = join(tmp, 'bin');
|
|
343
|
-
mkdirSync(bindir, { recursive: true });
|
|
344
|
-
symlinkSync(join(tmp, 'elsewhere'), join(bindir, 'codex-review')); // 2nd wrapper conflicts
|
|
345
|
-
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir }), (e) => e.code === SETUP_STOP);
|
|
346
|
-
assert.equal(existsSync(join(bindir, 'codex-exec')), false, '1st wrapper must not be linked');
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it('STOPs when a source is a symlink (never links through a symlinked source)', () => {
|
|
350
|
-
const skillDir = join(tmp, 'skill');
|
|
351
|
-
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
352
|
-
writeFileSync(join(skillDir, 'bin', 'real.sh'), '#!/bin/sh\n');
|
|
353
|
-
symlinkSync(join(skillDir, 'bin', 'real.sh'), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
354
|
-
const roles = { execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' } };
|
|
355
|
-
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', roles), { bindir: join(tmp, 'bin') }), /symlink/i);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('STOPs when a source is missing (skill not placed)', () => {
|
|
359
|
-
const skillDir = join(tmp, 'skill'); // no bin/*.sh
|
|
360
|
-
mkdirSync(skillDir, { recursive: true });
|
|
361
|
-
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir: join(tmp, 'bin') }), /is missing/);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it('win32 → no mutation (POSIX-only wrappers)', () => {
|
|
365
|
-
const skillDir = placedSkill();
|
|
366
|
-
const bindir = join(tmp, 'bin');
|
|
367
|
-
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir, platform: 'win32' });
|
|
368
|
-
assert.equal(res.skipped, true);
|
|
369
|
-
assert.equal(existsSync(bindir), false);
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
// ── dry-run plan correctness before the skill is placed ───────────────────────
|
|
374
|
-
|
|
375
|
-
describe('planFor --dry-run shape (skill not placed yet)', () => {
|
|
376
|
-
it('derives links from the bundled manifest with dst/source set, mutates nothing', () => {
|
|
377
|
-
writeBundle(join(tmp, 'bundles'));
|
|
378
|
-
const skillDir = join(tmp, 'skill');
|
|
379
|
-
const bindir = join(tmp, 'bin');
|
|
380
|
-
const plan = planFor('codex', baseDeps({ skillDir, bindir }));
|
|
381
|
-
assert.equal(plan.place.action, 'place');
|
|
382
|
-
const exec = plan.links.find((l) => l.cmd === 'codex-exec');
|
|
383
|
-
assert.equal(exec.dst, join(bindir, 'codex-exec'));
|
|
384
|
-
assert.equal(exec.source, join(skillDir, 'bin', 'codex-exec.sh'));
|
|
385
|
-
assert.equal(existsSync(skillDir), false);
|
|
386
|
-
assert.equal(existsSync(bindir), false);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it('a dry-run STOPs if a bundle wrapper source is a symlink (validates, but not linkable) — faithful plan', () => {
|
|
390
|
-
const bundleDir = writeBundle(join(tmp, 'bundles')); // valid bundle…
|
|
391
|
-
// …then replace a wrapper source with a symlink (validateManifest follows it → still "valid",
|
|
392
|
-
// but linkWrappers would refuse it, so the dry-run must predict the STOP, not report ok).
|
|
393
|
-
const target = join(bundleDir, 'bin', 'real-exec.sh');
|
|
394
|
-
writeFileSync(target, '#!/bin/sh\n');
|
|
395
|
-
const src = join(bundleDir, 'bin', 'codex-exec.sh');
|
|
396
|
-
rmSync(src);
|
|
397
|
-
symlinkSync(target, src);
|
|
398
|
-
const plan = planFor('codex', baseDeps({ skillDir: join(tmp, 'skill') }));
|
|
399
|
-
assert.equal(plan.outcome, 'stop');
|
|
400
|
-
assert.match(plan.reason, /symlink/i);
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// ── main(): CLI contract + exit matrix ────────────────────────────────────────
|
|
405
|
-
|
|
406
|
-
const capturedMain = (argv, deps = {}) => {
|
|
407
|
-
const out = [];
|
|
408
|
-
const code = main(argv, { ...deps, log: (s) => out.push(s), errlog: (s) => out.push(s) });
|
|
409
|
-
return { code, text: out.join('\n') };
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
describe('main — CLI contract + exit matrix', () => {
|
|
413
|
-
it('--help → 0 + usage', () => {
|
|
414
|
-
const { code, text } = capturedMain(['--help']);
|
|
415
|
-
assert.equal(code, 0);
|
|
416
|
-
assert.match(text, /usage: setup-backends/);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('unknown flag → 2 + usage', () => {
|
|
420
|
-
assert.equal(capturedMain(['--bogus']).code, 2);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it('unknown backend → 2 + usage', () => {
|
|
424
|
-
const { code, text } = capturedMain(['nope']);
|
|
425
|
-
assert.equal(code, 2);
|
|
426
|
-
assert.match(text, /unknown backend: nope/);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('--bindir with a flag-like or missing value → 2 (never swallows --dry-run into a mutating run)', () => {
|
|
430
|
-
assert.equal(capturedMain(['--bindir', '--dry-run']).code, 2); // would have mutated into "./--dry-run"
|
|
431
|
-
assert.equal(capturedMain(['codex', '--bindir']).code, 2); // trailing --bindir
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it('dry-run on an ok plan with guides → 0 (guides never fail the command)', () => {
|
|
435
|
-
writeBundle(join(tmp, 'bundles'));
|
|
436
|
-
const skillDir = join(tmp, 'skill');
|
|
437
|
-
const detect = fakeDetect({ cli: { bin: 'codex', state: 'missing', path: null } });
|
|
438
|
-
const { code, text } = capturedMain(['codex', '--dry-run'], baseDeps({ skillDir, detect }));
|
|
439
|
-
assert.equal(code, 0);
|
|
440
|
-
assert.match(text, /DRY RUN/);
|
|
441
|
-
assert.match(text, /cli:/);
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
it('STOP (foreign skill dir) → non-zero, no mutation', () => {
|
|
445
|
-
writeBundle(join(tmp, 'bundles'));
|
|
446
|
-
const skillDir = join(tmp, 'skill');
|
|
447
|
-
mkdirSync(skillDir, { recursive: true });
|
|
448
|
-
writeFileSync(join(skillDir, 'SKILL.md'), '# present\n');
|
|
449
|
-
const bindir = join(tmp, 'bin');
|
|
450
|
-
const validate = () => ({ result: 'valid', name: 'other', kind: 'execution-backend', available: true });
|
|
451
|
-
const { code } = capturedMain(['codex'], baseDeps({ skillDir, bindir, validate }));
|
|
452
|
-
assert.notEqual(code, 0);
|
|
453
|
-
assert.equal(existsSync(bindir), false); // refused before any write
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it('missing bundle → non-zero with the bundle path in the message', () => {
|
|
457
|
-
const { code, text } = capturedMain(['codex'], baseDeps({ skillDir: join(tmp, 'skill'), bundleRoot: join(tmp, 'gone') }));
|
|
458
|
-
assert.notEqual(code, 0);
|
|
459
|
-
assert.match(text, /bundled bridge missing/);
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
it('native fs error preserves the underlying reason (EIO)', () => {
|
|
463
|
-
writeBundle(join(tmp, 'bundles')); // real valid bundle; the skill-dir lstat is what throws EIO
|
|
464
|
-
const deps = baseDeps({
|
|
465
|
-
skillDir: join(tmp, 'skill'),
|
|
466
|
-
rest: {
|
|
467
|
-
exists: () => true,
|
|
468
|
-
lstat: () => { throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }); },
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
const { code, text } = capturedMain(['codex'], deps);
|
|
472
|
-
assert.notEqual(code, 0);
|
|
473
|
-
assert.match(text, /EIO/);
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
it('win32 → 0, prints unsupported / WSL, mutates nothing', () => {
|
|
477
|
-
writeBundle(join(tmp, 'bundles'));
|
|
478
|
-
const bindir = join(tmp, 'bin');
|
|
479
|
-
const { code, text } = capturedMain(['codex'], baseDeps({ skillDir: join(tmp, 'skill'), bindir, rest: { platform: 'win32' } }));
|
|
480
|
-
assert.equal(code, 0);
|
|
481
|
-
assert.match(text, /unsupported|WSL/i);
|
|
482
|
-
assert.equal(existsSync(bindir), false);
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('real run: places the skill + links both wrappers, idempotent on re-run', () => {
|
|
486
|
-
writeBundle(join(tmp, 'bundles'));
|
|
487
|
-
const skillDir = join(tmp, 'skill');
|
|
488
|
-
const bindir = join(tmp, 'bin');
|
|
489
|
-
const deps = baseDeps({ skillDir, bindir });
|
|
490
|
-
const first = capturedMain(['codex'], deps);
|
|
491
|
-
assert.equal(first.code, 0);
|
|
492
|
-
assert.ok(existsSync(join(skillDir, 'SKILL.md')), 'skill placed');
|
|
493
|
-
assert.equal(readlinkSync(join(bindir, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
494
|
-
assert.equal(readlinkSync(join(bindir, 'codex-review')), join(skillDir, 'bin', 'codex-review.sh'));
|
|
495
|
-
// re-run: proven-managed refresh + all links already ours → still 0, no breakage
|
|
496
|
-
const second = capturedMain(['codex'], { ...deps, validate: okValidate() });
|
|
497
|
-
assert.equal(second.code, 0);
|
|
498
|
-
assert.match(second.text, /already linked/);
|
|
499
|
-
});
|
|
500
|
-
});
|
|
@@ -1,144 +0,0 @@
|
|
|
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
|
-
});
|