@haposoft/cafekit 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/adv-skill.test.js +370 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/codex-hook-runtime-fixture.cjs +26 -0
  7. package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
  8. package/bin/__tests__/codex-hooks.test.js +87 -85
  9. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  10. package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
  11. package/bin/__tests__/codex-native.test.js +136 -2298
  12. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  13. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  14. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  15. package/bin/__tests__/codex-quiet-hooks.test.js +2 -8
  16. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  17. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  18. package/bin/__tests__/codex-skill-projections.test.js +101 -0
  19. package/bin/__tests__/develop-contract.test.js +100 -874
  20. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  21. package/bin/__tests__/migrate-apply.test.js +373 -0
  22. package/bin/__tests__/migrate-cli.test.js +152 -0
  23. package/bin/__tests__/migrate-merged.test.js +269 -0
  24. package/bin/__tests__/migrate-plan.test.js +271 -0
  25. package/bin/__tests__/omp-bridge.test.js +81 -5
  26. package/bin/__tests__/omp-hooks.test.js +24 -0
  27. package/bin/__tests__/orca-session.test.js +2 -16
  28. package/bin/__tests__/package-inventory.test.js +228 -2044
  29. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  30. package/bin/__tests__/privacy-paths.test.js +184 -0
  31. package/bin/__tests__/secret-scanner.test.js +73 -0
  32. package/bin/__tests__/skill-routing-source.test.js +6 -0
  33. package/bin/__tests__/toaa-skill.test.js +195 -0
  34. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  35. package/bin/__tests__/validator-grounding.test.js +0 -198
  36. package/bin/__tests__/vibe-run.test.js +215 -0
  37. package/bin/__tests__/vibe-skill.test.js +548 -0
  38. package/bin/__tests__/website-docs.test.js +58 -0
  39. package/bin/install.js +99 -1
  40. package/bin/lib/backup.js +43 -24
  41. package/bin/lib/codex-install.js +12 -3
  42. package/bin/lib/context.js +5 -0
  43. package/bin/lib/lock.js +98 -18
  44. package/bin/lib/managed-writer.js +16 -10
  45. package/bin/lib/migrate-apply.js +202 -0
  46. package/bin/lib/migrate-merged.js +342 -0
  47. package/bin/lib/migrate-plan.js +419 -0
  48. package/bin/lib/plugin-inventory.js +33 -0
  49. package/bin/phases/claude-runtime.js +5 -3
  50. package/bin/phases/codex-hooks.js +38 -0
  51. package/bin/phases/codex-runtime.js +106 -74
  52. package/bin/phases/copy-payload.js +49 -1
  53. package/bin/phases/omp-runtime.js +4 -26
  54. package/bin/phases/root-config.js +5 -2
  55. package/package.json +1 -1
  56. package/src/claude/CLAUDE.md +1 -1
  57. package/src/claude/agents/advisor.md +55 -0
  58. package/src/claude/agents/debugger.md +2 -3
  59. package/src/claude/agents/deployer.md +2 -1
  60. package/src/claude/agents/docs-keeper.md +12 -0
  61. package/src/claude/agents/inspector.md +1 -1
  62. package/src/claude/agents/project-manager.md +2 -2
  63. package/src/claude/agents/researcher.md +0 -1
  64. package/src/claude/agents/spec-maker.md +4 -1
  65. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  66. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  67. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  68. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  69. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -35
  70. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  71. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
  72. package/src/claude/hooks/__tests__/state.test.js +21 -0
  73. package/src/claude/hooks/agent.cjs +5 -2
  74. package/src/claude/hooks/inspect-block.cjs +3 -1
  75. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  76. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  77. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  78. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  79. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  80. package/src/claude/hooks/plugin-context.cjs +80 -0
  81. package/src/claude/hooks/privacy-block.cjs +2 -1
  82. package/src/claude/hooks/rules.cjs +5 -2
  83. package/src/claude/hooks/session.cjs +4 -1
  84. package/src/claude/hooks/spec-state.cjs +5 -4
  85. package/src/claude/hooks/state.cjs +27 -1
  86. package/src/claude/migration-manifest.json +7 -1
  87. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  88. package/src/claude/plugin/core.md +71 -0
  89. package/src/claude/rules/manage-docs.md +1 -2
  90. package/src/claude/rules/review-audit-self-decision.md +1 -1
  91. package/src/claude/rules/skill-workflow-routing.md +1 -1
  92. package/src/claude/rules/state-sync.md +14 -0
  93. package/src/claude/rules/workflow.md +1 -1
  94. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  95. package/src/claude/scripts/provenance.cjs +40 -16
  96. package/src/claude/scripts/scan-staged-secrets.cjs +49 -3
  97. package/src/claude/scripts/setup-statusline.cjs +88 -0
  98. package/src/claude/skills/adv/SKILL.md +139 -0
  99. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  100. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  101. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  102. package/src/claude/skills/code-review/SKILL.md +4 -1
  103. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  104. package/src/claude/skills/develop/SKILL.md +11 -3
  105. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  106. package/src/claude/skills/fix/SKILL.md +2 -2
  107. package/src/claude/skills/git/SKILL.md +4 -2
  108. package/src/claude/skills/git/references/merge-pr.md +125 -0
  109. package/src/claude/skills/specs/SKILL.md +2 -2
  110. package/src/claude/skills/specs/references/review.md +1 -1
  111. package/src/claude/skills/specs/references/templates.md +2 -0
  112. package/src/claude/skills/sync/SKILL.md +4 -0
  113. package/src/claude/skills/toaa/SKILL.md +82 -0
  114. package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
  115. package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -0
  116. package/src/claude/skills/vibe/SKILL.md +69 -0
  117. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  118. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  119. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  120. package/src/codex/AGENTS.md +1 -1
  121. package/src/codex/hooks/inspect-block.cjs +7 -4
  122. package/src/codex/hooks/lib/hook-context.cjs +119 -4
  123. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  124. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  125. package/src/codex/hooks/lib/state-store.cjs +31 -6
  126. package/src/codex/hooks/precompact.cjs +9 -8
  127. package/src/codex/hooks/privacy-approval.cjs +2 -2
  128. package/src/codex/hooks/privacy-block.cjs +5 -4
  129. package/src/codex/hooks/rules.cjs +12 -6
  130. package/src/codex/hooks/session.cjs +22 -10
  131. package/src/codex/plugin/core.md +76 -0
  132. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  133. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  134. package/src/codex/scripts/setup-agents.cjs +124 -0
  135. package/src/common/AGENTS.md +1 -1
  136. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  137. package/src/omp/hooks/privacy-block.cjs +2 -1
  138. package/bin/__tests__/codex-research-projection.test.js +0 -70
  139. package/bin/__tests__/codex-specs-projection.test.js +0 -76
  140. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
  141. package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
@@ -4,7 +4,7 @@
4
4
  // review authority and the scaffold guard) are gone from every runtime. Each case pins one way
5
5
  // they could survive: a fresh install, an upgrade on Claude (settings prune matches by substring,
6
6
  // so a user's own hook with the same file name must stay), an upgrade on Codex (whose tracker
7
- // keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp, an omp bridge
7
+ // keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp (hash-checked like Codex), an omp bridge
8
8
  // that still lists the removed gates, and the source lists themselves.
9
9
 
10
10
  const assert = require('node:assert/strict');
@@ -21,7 +21,7 @@ const SRC = path.join(PACKAGE_ROOT, 'src');
21
21
  const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
22
22
  const MANIFEST = readJson(path.join(SRC, 'claude/migration-manifest.json'));
23
23
  const SOURCE_SETTINGS = readJson(path.join(SRC, 'claude/settings/settings.json'));
24
- const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.mjs');
24
+ const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.js');
25
25
 
26
26
  const NAMES = [
27
27
  'completion-authority.cjs',
@@ -70,7 +70,7 @@ test('fresh installs ship and register none of the five legacy hooks', () => {
70
70
  const claude = commands(readJson(path.join(root, '.claude', 'settings.json')));
71
71
  const codex = commands(readJson(path.join(root, '.codex', 'hooks.json')));
72
72
  for (const command of [...claude, ...codex]) assert.deepEqual(mentionsName(command), [], command);
73
- const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs'), 'utf8');
73
+ const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.js'), 'utf8');
74
74
  assert.deepEqual(mentionsName(bridge), []);
75
75
  });
76
76
  });
@@ -166,9 +166,10 @@ test('omp upgrade removes the legacy hooks and their records', () => {
166
166
  const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
167
167
  const manifest = readJson(manifestPath);
168
168
  // omp records keys relative to the project root (recordRoot '.').
169
+ const shipped = '// shipped by an older CafeKit\n';
169
170
  for (const name of NAMES) {
170
- fs.writeFileSync(path.join(root, '.omp', 'hooks', name), '// shipped by an older CafeKit\n');
171
- manifest.files[`.omp/hooks/${name}`] = { sha256: '0'.repeat(64) };
171
+ fs.writeFileSync(path.join(root, '.omp', 'hooks', name), shipped);
172
+ manifest.files[`.omp/hooks/${name}`] = { sha256: crypto.createHash('sha256').update(shipped).digest('hex') };
172
173
  }
173
174
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
174
175
 
@@ -181,28 +182,77 @@ test('omp upgrade removes the legacy hooks and their records', () => {
181
182
  });
182
183
  });
183
184
 
184
- test('omp upgrade replaces a pristine old bridge that still lists the removed gates', () => {
185
+ test('omp upgrade preserves edited legacy hooks and prunes only absent records', () => {
185
186
  inTempProject((root) => {
186
187
  install(root, ['omp']);
187
- const bridgePath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
188
+ const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
189
+ const manifest = readJson(manifestPath);
190
+ // A record whose file is already gone must leave the manifest too.
191
+ for (const name of NAMES) {
192
+ if (name !== 'completion-authority-state.cjs') fs.writeFileSync(path.join(root, '.omp', 'hooks', name), '// user edit\n');
193
+ manifest.files[`.omp/hooks/${name}`] = { sha256: '0'.repeat(64) };
194
+ }
195
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
196
+
197
+ const upgrade = install(root, ['omp']);
198
+ assert.match(`${upgrade.stdout}\n${upgrade.stderr}`, new RegExp(`Preserved ${NAMES.length - 1} obsolete runtime item`));
199
+ const after = readJson(manifestPath);
200
+ for (const name of NAMES) {
201
+ if (name === 'completion-authority-state.cjs') {
202
+ assert.equal(after.files[`.omp/hooks/${name}`], undefined, `record ${name}`);
203
+ continue;
204
+ }
205
+ assert.equal(fs.readFileSync(path.join(root, '.omp', 'hooks', name), 'utf8'), '// user edit\n', name);
206
+ assert.equal(after.files[`.omp/hooks/${name}`].sha256, '0'.repeat(64), `record ${name}`);
207
+ }
208
+ });
209
+ });
210
+
211
+ test('omp upgrade removes a pristine old .mjs bridge, which omp never loads', () => {
212
+ inTempProject((root) => {
213
+ install(root, ['omp']);
214
+ const oldPath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
188
215
  const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
189
216
  const old = fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8')
190
- .replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']")
191
- .replace("'rules.cjs', 'spec-state.cjs'", "'rules.cjs', 'completion-authority.cjs', 'spec-state.cjs'")
192
- .replace("'spec-gate.cjs', 'state.cjs'", "'spec-gate.cjs', 'completion-authority.cjs', 'state.cjs'");
193
- assert.deepEqual(mentionsName(old).sort(), ['completion-authority.cjs', 'task-scaffold-guard.cjs'], 'the fixture lists the removed gates');
194
- fs.writeFileSync(bridgePath, old);
217
+ .replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']");
218
+ fs.writeFileSync(oldPath, old);
195
219
  const manifest = readJson(manifestPath);
196
220
  manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: crypto.createHash('sha256').update(old).digest('hex') };
197
221
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
198
222
 
199
223
  install(root, ['omp']);
200
- const installed = fs.readFileSync(bridgePath, 'utf8');
224
+ assert.equal(fs.existsSync(oldPath), false, 'the pristine .mjs bridge is removed');
225
+ assert.equal(readJson(manifestPath).files['.omp/extensions/cafekit-bridge.mjs'], undefined, 'and its record');
226
+ const installed = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.js'), 'utf8');
201
227
  assert.deepEqual(mentionsName(installed), []);
202
228
  assert.equal(installed, fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8'));
203
229
  });
204
230
  });
205
231
 
232
+ test('omp upgrade keeps an edited, untracked or symlinked old .mjs bridge and says so', () => {
233
+ for (const shape of ['edited', 'untracked', 'symlink']) {
234
+ inTempProject((root) => {
235
+ install(root, ['omp']);
236
+ const oldPath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
237
+ const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
238
+ const manifest = readJson(manifestPath);
239
+ if (shape === 'symlink') {
240
+ const target = path.join(root, 'my-bridge.mjs');
241
+ fs.writeFileSync(target, '// mine\n');
242
+ fs.symlinkSync(target, oldPath);
243
+ } else {
244
+ fs.writeFileSync(oldPath, '// edited by the user\n');
245
+ if (shape === 'edited') manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: '0'.repeat(64) };
246
+ }
247
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
248
+
249
+ const result = install(root, ['omp']);
250
+ assert.equal(fs.lstatSync(oldPath).isFile() || fs.lstatSync(oldPath).isSymbolicLink(), true, `${shape} .mjs is kept`);
251
+ assert.match(`${result.stdout}\n${result.stderr}`, /Preserved \d+ obsolete runtime item/, `${shape} is reported`);
252
+ });
253
+ }
254
+ });
255
+
206
256
  test('no source ships, registers or bridges the five legacy hooks', () => {
207
257
  for (const name of NAMES) {
208
258
  assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks', name)), false, `claude ${name}`);
@@ -0,0 +1,373 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const { spawn, spawnSync } = require('node:child_process');
10
+
11
+ const { planMigration, planSteps } = require('../lib/migrate-apply');
12
+ const { sha256 } = require('../lib/manifest');
13
+
14
+ const INSTALLER = path.join(__dirname, '../install.js');
15
+ const LIB = path.join(__dirname, '../lib');
16
+
17
+ // Runs applyMigration (or lock.acquire) in its own process so its cwd can be the
18
+ // temp repository; an injected fs counts, slows or fails the apply writes.
19
+ const RUNNER = `
20
+ 'use strict';
21
+ const fs = require('fs');
22
+ const [lib, mode, raw] = process.argv.slice(2);
23
+ const opts = JSON.parse(raw);
24
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
25
+ if (mode === 'lock') {
26
+ const lock = require(lib + '/lock.js');
27
+ process.stdout.write('READY\\n');
28
+ while (!fs.existsSync(opts.go)) sleep(1);
29
+ const got = lock.acquire(opts.dir);
30
+ process.stdout.write('RESULT ' + JSON.stringify(got) + '\\n');
31
+ sleep(300);
32
+ process.exit(0);
33
+ }
34
+ const { applyMigration, planMigration } = require(lib + '/migrate-apply.js');
35
+ const APPLY_WRITES = new Set(['unlinkSync', 'writeFileSync', 'renameSync', 'rmdirSync']);
36
+ let writes = 0;
37
+ const io = { ...fs };
38
+ for (const name of Object.keys(fs)) {
39
+ if (typeof fs[name] !== 'function') continue;
40
+ io[name] = (...args) => {
41
+ if (APPLY_WRITES.has(name)) {
42
+ writes++;
43
+ if (writes === 1 && opts.editDuringApply) fs.appendFileSync(opts.repo + '/' + opts.editDuringApply, '\\nedited during apply\\n');
44
+ if (opts.slowMs) sleep(opts.slowMs);
45
+ if (opts.failAt === writes) throw Object.assign(new Error('injected write failure'), { code: 'EIO' });
46
+ }
47
+ if (name === 'writeFileSync' && opts.failTempWriteOf && String(args[0]).includes('/' + opts.failTempWriteOf + '.cafekit-migrate-')) {
48
+ fs.writeFileSync(args[0], '', { flag: 'wx' });
49
+ throw Object.assign(new Error('injected disk full'), { code: 'ENOSPC' });
50
+ }
51
+ if (name === 'renameSync' && opts.failRenameOf && String(args[1]).endsWith('/' + opts.failRenameOf)) {
52
+ throw Object.assign(new Error('injected rename failure'), { code: 'EPERM' });
53
+ }
54
+ if (name === 'rmSync' && opts.failRestoreTarget && args[0] === opts.failRestoreTarget) {
55
+ throw Object.assign(new Error('injected restore failure'), { code: 'EIO' });
56
+ }
57
+ return fs[name](...args);
58
+ };
59
+ }
60
+ const plan = planMigration(opts.repo);
61
+ if (opts.editAfterPlan) fs.appendFileSync(opts.repo + '/' + opts.editAfterPlan, '\\nedited after the plan\\n');
62
+ let announced = false;
63
+ applyMigration(opts.repo, {
64
+ plan,
65
+ fs: io,
66
+ onStep: () => { if (!announced) { announced = true; process.stdout.write('STEP\\n'); } }
67
+ }).then((result) => {
68
+ process.stdout.write('RESULT ' + JSON.stringify(result) + '\\n');
69
+ process.exit(result.ok ? 0 : 1);
70
+ });
71
+ `;
72
+
73
+ let work;
74
+ let pristine;
75
+ let runner;
76
+ let counter = 0;
77
+
78
+ function copyRepo(name = `repo-${++counter}`) {
79
+ const target = path.join(work, name, 'repo');
80
+ fs.cpSync(pristine, target, { recursive: true, verbatimSymlinks: true });
81
+ return target;
82
+ }
83
+
84
+ function treeHash(root, skip = ['.cafekit-backup', '.cafekit.lock']) {
85
+ const hash = crypto.createHash('sha256');
86
+ const visit = (rel) => {
87
+ const abs = path.join(root, rel);
88
+ const stat = fs.lstatSync(abs);
89
+ if (stat.isSymbolicLink()) hash.update(`L ${rel} ${fs.readlinkSync(abs)}\n`);
90
+ else if (stat.isDirectory()) {
91
+ for (const name of fs.readdirSync(abs).sort()) {
92
+ if (rel === '.' && skip.includes(name)) continue;
93
+ visit(path.join(rel, name));
94
+ }
95
+ } else hash.update(`F ${rel} ${sha256(fs.readFileSync(abs))}\n`);
96
+ };
97
+ visit('.');
98
+ return hash.digest('hex');
99
+ }
100
+
101
+ function runApply(repo, opts = {}, cwd = repo) {
102
+ const run = spawnSync(process.execPath, [runner, LIB, 'apply', JSON.stringify({ repo, ...opts })], { cwd, encoding: 'utf8' });
103
+ const line = run.stdout.split('\n').find((item) => item.startsWith('RESULT '));
104
+ assert.ok(line, `no result: ${run.stderr}`);
105
+ return { status: run.status, result: JSON.parse(line.slice(7)) };
106
+ }
107
+
108
+ before(() => {
109
+ work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-migrate-apply-')));
110
+ runner = path.join(work, 'runner.cjs');
111
+ fs.writeFileSync(runner, RUNNER);
112
+ fs.mkdirSync(path.join(work, 'home'));
113
+ pristine = path.join(work, 'pristine', 'repo');
114
+ fs.mkdirSync(pristine, { recursive: true });
115
+ spawnSync('git', ['init', '-q'], { cwd: pristine });
116
+ // The installer writes into its cwd, so it must run inside the temp repository.
117
+ const run = spawnSync(process.execPath, [INSTALLER, '--platform', 'claude,codex', '-y'], {
118
+ cwd: pristine,
119
+ env: { ...process.env, HOME: path.join(work, 'home'), CI: '1' },
120
+ encoding: 'utf8'
121
+ });
122
+ assert.equal(run.status, 0, run.stderr || run.stdout);
123
+ const agents = path.join(pristine, 'AGENTS.md');
124
+ fs.writeFileSync(agents, `# Team rules\n\n${fs.readFileSync(agents, 'utf8')}`);
125
+ fs.writeFileSync(path.join(pristine, '.claude/.env'), 'TOKEN=[redacted]\n');
126
+ });
127
+
128
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
129
+
130
+ test('apply-removes-planned, kept-bytes-equal, merged-match-plan, backup-holds-pre-run, second-run-nothing', async (t) => {
131
+ const repo = copyRepo();
132
+ const plan = planMigration(repo);
133
+ const removed = plan.manifest.entries.filter((entry) => entry.action === 'remove').map((entry) => entry.path);
134
+ const kept = [...plan.manifest.entries.filter((entry) => entry.action === 'keep').map((entry) => entry.path), '.claude/.env'];
135
+ const keptHashes = new Map(kept.map((rel) => [rel, sha256(fs.readFileSync(path.join(repo, rel)))]));
136
+ const preBytes = new Map([...removed, 'AGENTS.md'].map((rel) => [rel, fs.readFileSync(path.join(repo, rel))]));
137
+ assert.ok(removed.length > 100);
138
+
139
+ const { status, result } = runApply(repo);
140
+ assert.equal(status, 0, JSON.stringify(result));
141
+ assert.equal(result.ok, true);
142
+
143
+ await t.test('apply-removes-planned', () => {
144
+ for (const rel of removed) assert.ok(!fs.existsSync(path.join(repo, rel)), rel);
145
+ });
146
+ await t.test('kept-bytes-equal', () => {
147
+ for (const [rel, hash] of keptHashes) assert.equal(sha256(fs.readFileSync(path.join(repo, rel))), hash, rel);
148
+ });
149
+ await t.test('merged-match-plan', () => {
150
+ for (const file of plan.merged.files) {
151
+ const abs = path.join(repo, file.path);
152
+ if (file.action === 'remove') assert.ok(!fs.existsSync(abs), file.path);
153
+ else assert.equal(fs.readFileSync(abs, 'utf8'), file.after, file.path);
154
+ }
155
+ assert.ok(plan.merged.files.some((file) => file.action === 'write' && file.path === 'AGENTS.md'));
156
+ });
157
+ await t.test('backup-holds-pre-run', () => {
158
+ for (const [rel, bytes] of preBytes) {
159
+ assert.deepEqual(fs.readFileSync(path.join(repo, result.backupDir, 'data', rel)), bytes, rel);
160
+ }
161
+ });
162
+ await t.test('second-run-nothing', () => {
163
+ const again = planMigration(repo);
164
+ assert.equal(again.manifest.entries.filter((entry) => entry.action === 'remove').length, 0);
165
+ assert.deepEqual(planSteps(again), []);
166
+ assert.equal(runApply(repo).result.nothing, true);
167
+ });
168
+ });
169
+
170
+ test('injected-failure-restores: a failed fifth write puts every byte back', () => {
171
+ const repo = copyRepo();
172
+ const beforeHash = treeHash(repo);
173
+ const { status, result } = runApply(repo, { failAt: 5 });
174
+ assert.notEqual(status, 0);
175
+ assert.equal(result.ok, false);
176
+ assert.equal(result.removed, 4);
177
+ assert.deepEqual(result.restoreErrors, []);
178
+ assert.equal(treeHash(repo), beforeHash);
179
+ assert.ok(!fs.existsSync(path.join(repo, '.cafekit.lock')));
180
+ });
181
+
182
+ test('temp-file-cleaned: a failed rename of a root-level merged file leaves no temp file behind', () => {
183
+ const repo = copyRepo();
184
+ const beforeHash = treeHash(repo);
185
+ const { status, result } = runApply(repo, { failRenameOf: 'AGENTS.md' });
186
+ assert.notEqual(status, 0);
187
+ assert.equal(result.reason, 'EPERM');
188
+ assert.match(result.message, /Rolled back .*AGENTS\.md/);
189
+ assert.deepEqual(fs.readdirSync(repo).filter((name) => name.includes('cafekit-migrate')), []);
190
+ assert.equal(treeHash(repo), beforeHash);
191
+ });
192
+
193
+ test('partial-temp-cleaned: a temp file created but not fully written is removed too', () => {
194
+ const repo = copyRepo();
195
+ const beforeHash = treeHash(repo);
196
+ const { status, result } = runApply(repo, { failTempWriteOf: 'AGENTS.md' });
197
+ assert.notEqual(status, 0);
198
+ assert.equal(result.reason, 'ENOSPC');
199
+ assert.deepEqual(fs.readdirSync(repo).filter((name) => name.includes('cafekit-migrate')), []);
200
+ assert.equal(treeHash(repo), beforeHash);
201
+ });
202
+
203
+ test('merged-keeps-mode: a rewritten merged file keeps its permissions', () => {
204
+ const repo = copyRepo();
205
+ fs.chmodSync(path.join(repo, 'AGENTS.md'), 0o600);
206
+ const { status } = runApply(repo);
207
+ assert.equal(status, 0);
208
+ assert.equal(fs.statSync(path.join(repo, 'AGENTS.md')).mode & 0o777, 0o600);
209
+ });
210
+
211
+ test('restore-continues-past-failure: one target that cannot be restored does not stop the others', () => {
212
+ const repo = copyRepo();
213
+ const beforeHash = treeHash(repo, ['.cafekit-backup', '.cafekit.lock', '.claude']);
214
+ const { status, result } = runApply(repo, { failAt: 5, failRestoreTarget: '.claude' });
215
+ assert.notEqual(status, 0);
216
+ assert.deepEqual(result.restoreErrors.map((item) => item.target), ['.claude']);
217
+ assert.doesNotMatch(result.message.split('Rolled back')[1], /\.claude\b/);
218
+ assert.ok(result.manualSteps[0].includes(path.join(result.backupDir, 'data', '.claude')));
219
+ // Every other target (.codex, .agents, AGENTS.md, CLAUDE.md, .gitignore) is back.
220
+ assert.equal(treeHash(repo, ['.cafekit-backup', '.cafekit.lock', '.claude']), beforeHash);
221
+ });
222
+
223
+ test('sigint-restores: an interrupt mid-apply puts every byte back', async () => {
224
+ const repo = copyRepo();
225
+ const beforeHash = treeHash(repo);
226
+ const child = spawn(process.execPath, [runner, LIB, 'apply', JSON.stringify({ repo, slowMs: 20 })], { cwd: repo });
227
+ let stdout = '';
228
+ const exit = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
229
+ child.stdout.on('data', (chunk) => {
230
+ stdout += chunk;
231
+ if (stdout.includes('STEP\n') && !child.killed) child.kill('SIGINT');
232
+ });
233
+ const code = await exit;
234
+ const line = stdout.split('\n').find((item) => item.startsWith('RESULT '));
235
+ assert.ok(line, stdout);
236
+ const result = JSON.parse(line.slice(7));
237
+ assert.notEqual(code, 0);
238
+ assert.equal(result.reason, 'interrupted');
239
+ assert.ok(result.removed > 0);
240
+ assert.equal(treeHash(repo), beforeHash);
241
+ });
242
+
243
+ test('stale-plan-refused: a planned file edited after the plan stops apply before any change', () => {
244
+ const repo = copyRepo();
245
+ const twin = copyRepo();
246
+ fs.appendFileSync(path.join(twin, '.claude/agents/debugger.md'), '\nedited after the plan\n');
247
+ const { status, result } = runApply(repo, { editAfterPlan: '.claude/agents/debugger.md' });
248
+ assert.notEqual(status, 0);
249
+ assert.equal(result.reason, 'stale');
250
+ assert.equal(result.removed, 0);
251
+ assert.equal(treeHash(repo), treeHash(twin));
252
+ });
253
+
254
+ test('stale-during-apply-refused: a planned file edited while apply runs is caught by the per-file re-check', () => {
255
+ const repo = copyRepo();
256
+ const beforeHash = treeHash(repo);
257
+ const plan = planMigration(repo);
258
+ const last = plan.manifest.entries.filter((entry) => entry.action === 'remove').at(-1).path;
259
+ const { status, result } = runApply(repo, { editDuringApply: last });
260
+ assert.notEqual(status, 0);
261
+ assert.equal(result.reason, 'stale');
262
+ assert.match(result.message, new RegExp(last.replace(/[.]/g, '\\.')));
263
+ // The rollback restores the snapshot, so the tree is back to its state before apply.
264
+ assert.equal(treeHash(repo), beforeHash);
265
+ });
266
+
267
+ test('wrong-cwd-refused: apply from another directory changes nothing', () => {
268
+ const repo = copyRepo();
269
+ const beforeHash = treeHash(repo, []);
270
+ const { status, result } = runApply(repo, {}, work);
271
+ assert.notEqual(status, 0);
272
+ assert.equal(result.reason, 'wrong-cwd');
273
+ assert.equal(treeHash(repo, []), beforeHash);
274
+ });
275
+
276
+ async function raceAcquire(dir) {
277
+ const go = path.join(dir, `go-${++counter}`);
278
+ const children = [0, 1].map(() => spawn(process.execPath, [runner, LIB, 'lock', JSON.stringify({ dir, go })]));
279
+ const outputs = children.map(() => '');
280
+ const ready = children.map((child, index) => new Promise((resolve) => child.stdout.on('data', (chunk) => {
281
+ outputs[index] += chunk;
282
+ if (outputs[index].includes('READY')) resolve();
283
+ })));
284
+ const exits = children.map((child) => new Promise((resolve) => child.on('exit', resolve)));
285
+ await Promise.all(ready);
286
+ fs.writeFileSync(go, '');
287
+ await Promise.all(exits);
288
+ return outputs.map((output) => JSON.parse(output.split('\n').find((line) => line.startsWith('RESULT ')).slice(7)));
289
+ }
290
+
291
+ function deadPid() {
292
+ const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' });
293
+ return Number(child.stdout);
294
+ }
295
+
296
+ test('lock-exclusive: two concurrent acquires never both succeed', async () => {
297
+ const dir = fs.mkdtempSync(path.join(work, 'lock-'));
298
+ for (let round = 0; round < 8; round++) {
299
+ const results = await raceAcquire(dir);
300
+ assert.equal(results.filter((result) => result.acquired).length, 1, JSON.stringify(results));
301
+ fs.rmSync(path.join(dir, '.cafekit.lock'), { force: true });
302
+ }
303
+ });
304
+
305
+ test('stale-lock-exclusive: two runs reclaiming the same stale lock never both succeed', async () => {
306
+ const dir = fs.mkdtempSync(path.join(work, 'stale-'));
307
+ for (let round = 0; round < 8; round++) {
308
+ fs.writeFileSync(path.join(dir, '.cafekit.lock'), JSON.stringify({ pid: deadPid(), since: 'earlier' }));
309
+ const results = await raceAcquire(dir);
310
+ // The reclaimer may delete the stale lock and then lose the `wx` create to the other
311
+ // run's first attempt; either way exactly one run holds the lock.
312
+ assert.equal(results.filter((result) => result.acquired).length, 1, JSON.stringify(results));
313
+ assert.ok(!fs.existsSync(path.join(dir, '.cafekit.lock.reclaim')));
314
+ fs.rmSync(path.join(dir, '.cafekit.lock'), { force: true });
315
+ }
316
+
317
+ // Deterministic half: while another run holds the reclaim guard, a stale lock is not taken.
318
+ fs.writeFileSync(path.join(dir, '.cafekit.lock'), JSON.stringify({ pid: deadPid(), since: 'earlier' }));
319
+ fs.writeFileSync(path.join(dir, '.cafekit.lock.reclaim'), JSON.stringify({ pid: process.pid, since: 'now' }));
320
+ const lock = require('../lib/lock');
321
+ const blocked = lock.acquire(dir);
322
+ assert.equal(blocked.acquired, false);
323
+ assert.equal(blocked.reason, 'reclaiming');
324
+ assert.ok(fs.existsSync(path.join(dir, '.cafekit.lock.reclaim')), 'a guard held by another run is left alone');
325
+ fs.rmSync(path.join(dir, '.cafekit.lock.reclaim'));
326
+
327
+ // A lock file is never visible without its owner's pid, so a reader cannot mistake a
328
+ // lock being created for a stale one.
329
+ fs.rmSync(path.join(dir, '.cafekit.lock'));
330
+ const realLink = fs.linkSync;
331
+ let seenAtLink = null;
332
+ fs.linkSync = (from, to) => {
333
+ seenAtLink = fs.existsSync(to) ? 'present' : JSON.parse(fs.readFileSync(from, 'utf8')).pid;
334
+ return realLink(from, to);
335
+ };
336
+ try {
337
+ assert.equal(lock.acquire(dir).acquired, true);
338
+ } finally {
339
+ fs.linkSync = realLink;
340
+ }
341
+ assert.equal(seenAtLink, process.pid);
342
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dir, '.cafekit.lock'), 'utf8')).pid, process.pid);
343
+ assert.deepEqual(fs.readdirSync(dir).filter((name) => name.startsWith('.cafekit.lock.')), []);
344
+
345
+ // A full disk while writing the lock's temp file is an error, and leaves no lock behind.
346
+ const realWrite = fs.writeFileSync;
347
+ fs.writeFileSync = (file, ...rest) => {
348
+ if (String(file).includes('.cafekit.lock.')) {
349
+ realWrite(file, '', { flag: 'wx' });
350
+ throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
351
+ }
352
+ return realWrite(file, ...rest);
353
+ };
354
+ fs.rmSync(path.join(dir, '.cafekit.lock'));
355
+ try {
356
+ assert.throws(() => lock.acquire(dir), { code: 'ENOSPC' });
357
+ } finally {
358
+ fs.writeFileSync = realWrite;
359
+ }
360
+ assert.deepEqual(fs.readdirSync(dir).filter((name) => name.startsWith('.cafekit.lock')), []);
361
+ realWrite(path.join(dir, '.cafekit.lock'), JSON.stringify({ pid: process.pid }));
362
+
363
+ // Without hard links (exFAT, some shares) the lock still works through a plain `wx` create.
364
+ fs.rmSync(path.join(dir, '.cafekit.lock'));
365
+ fs.linkSync = () => { throw Object.assign(new Error('operation not permitted, link'), { code: 'EPERM' }); };
366
+ try {
367
+ assert.equal(lock.acquire(dir).acquired, true);
368
+ assert.equal(lock.acquire(dir).acquired, false);
369
+ } finally {
370
+ fs.linkSync = realLink;
371
+ }
372
+ assert.deepEqual(fs.readdirSync(dir).filter((name) => name.startsWith('.cafekit.lock.')), []);
373
+ });
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const { spawnSync } = require('node:child_process');
10
+
11
+ const { sha256 } = require('../lib/manifest');
12
+
13
+ const INSTALLER = path.join(__dirname, '../install.js');
14
+ const BUILD = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
15
+
16
+ let work;
17
+ let repo;
18
+ let tree;
19
+ let privacyCommand;
20
+
21
+ // The installer writes into its cwd, so every run uses a temp repository as cwd.
22
+ function cafekit(cwd, ...args) {
23
+ return spawnSync(process.execPath, [INSTALLER, ...args], {
24
+ cwd,
25
+ env: { ...process.env, HOME: path.join(work, 'home'), CI: '1' },
26
+ encoding: 'utf8'
27
+ });
28
+ }
29
+
30
+ function gitRepo(name) {
31
+ const dir = path.join(work, name);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ spawnSync('git', ['init', '-q'], { cwd: dir });
34
+ return fs.realpathSync(dir);
35
+ }
36
+
37
+ function treeHash(root) {
38
+ const hash = crypto.createHash('sha256');
39
+ const visit = (rel) => {
40
+ const abs = path.join(root, rel);
41
+ const stat = fs.lstatSync(abs);
42
+ if (stat.isSymbolicLink()) hash.update(`L ${rel} ${fs.readlinkSync(abs)}\n`);
43
+ else if (stat.isDirectory()) for (const name of fs.readdirSync(abs).sort()) visit(path.join(rel, name));
44
+ else hash.update(`F ${rel} ${sha256(fs.readFileSync(abs))}\n`);
45
+ };
46
+ visit('.');
47
+ return hash.digest('hex');
48
+ }
49
+
50
+ // Runs the built Codex plugin's privacy hook through its generated launcher, as Codex does.
51
+ function pluginPrivacy(cwd) {
52
+ return spawnSync('sh', ['-c', privacyCommand], {
53
+ cwd,
54
+ input: JSON.stringify({ session_id: 'migrate-cli', hook_event_name: 'PreToolUse', cwd, tool_name: 'Bash', tool_input: { command: 'cat .env' } }),
55
+ encoding: 'utf8',
56
+ timeout: 20000,
57
+ env: { ...process.env, HOME: path.join(work, 'home'), PLUGIN_ROOT: tree, CLAUDE_PLUGIN_ROOT: tree, PLUGIN_DATA: path.join(work, 'data'), CLAUDE_PLUGIN_DATA: path.join(work, 'data') }
58
+ });
59
+ }
60
+
61
+ before(async () => {
62
+ work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-migrate-cli-')));
63
+ fs.mkdirSync(path.join(work, 'home'));
64
+ const lib = await import(BUILD);
65
+ tree = lib.build(path.join(work, 'plugin', 'cf-codex'));
66
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
67
+ privacyCommand = hooks.PreToolUse.flatMap((group) => group.hooks).find((handler) => handler.command.includes('/hooks/privacy-block.cjs"')).command;
68
+
69
+ repo = gitRepo('both');
70
+ fs.writeFileSync(path.join(repo, '.env'), 'EXAMPLE=placeholder\n');
71
+ const install = cafekit(repo, '--platform', 'claude,codex', '-y');
72
+ assert.equal(install.status, 0, install.stderr || install.stdout);
73
+ });
74
+
75
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
76
+
77
+ test('dry-run-default: migrate prints the plan, exits 0 and writes nothing', () => {
78
+ const beforeHash = treeHash(repo);
79
+ const run = cafekit(repo, 'migrate');
80
+ assert.equal(run.status, 0, run.stderr);
81
+ assert.match(run.stdout, /dry run \(nothing is written\)/);
82
+ assert.match(run.stdout, /Remove \(\d+ files\):/);
83
+ assert.match(run.stdout, /migrate --apply/);
84
+ assert.equal(treeHash(repo), beforeHash);
85
+ });
86
+
87
+ test('plugin-stands-down-before: beside the npx Codex hooks the plugin hook stays silent', () => {
88
+ const result = pluginPrivacy(repo);
89
+ assert.equal(result.status, 0, result.stderr);
90
+ assert.equal(result.stdout, '');
91
+ });
92
+
93
+ test('apply-exits-zero: migrate --apply removes the npx install and exits 0', () => {
94
+ const run = cafekit(repo, 'migrate', '--apply');
95
+ assert.equal(run.status, 0, run.stderr || run.stdout);
96
+ assert.match(run.stdout, /✓ Removed \d+ files/);
97
+ assert.ok(!fs.existsSync(path.join(repo, '.codex/hooks.json')));
98
+ assert.ok(fs.existsSync(path.join(repo, '.codex/runtime.json')));
99
+ const again = cafekit(repo, 'migrate');
100
+ assert.equal(again.status, 0);
101
+ assert.match(again.stdout, /Nothing to migrate/);
102
+ });
103
+
104
+ test('plugin-gates-after-apply: once the npx hooks are gone the plugin privacy gate acts', () => {
105
+ const result = pluginPrivacy(repo);
106
+ assert.equal(result.status, 0, result.stderr);
107
+ assert.match(result.stdout, /"permissionDecision":"deny"/);
108
+ });
109
+
110
+ test('unknown-flag-two: an unknown flag after migrate exits 2 and writes nothing', () => {
111
+ const dir = gitRepo('flag');
112
+ const run = cafekit(dir, 'migrate', '--bogus');
113
+ assert.equal(run.status, 2);
114
+ assert.match(run.stderr, /Unknown argument for migrate: --bogus/);
115
+ assert.deepEqual(fs.readdirSync(dir), ['.git']);
116
+ });
117
+
118
+ test('unknown-positional-two: an unknown positional argument exits 2 before any install', () => {
119
+ const dir = gitRepo('positional');
120
+ const run = cafekit(dir, 'bogus', '-y');
121
+ assert.equal(run.status, 2);
122
+ assert.match(run.stderr, /Unknown argument: "bogus"/);
123
+ assert.deepEqual(fs.readdirSync(dir), ['.git']);
124
+ const misplaced = cafekit(dir, '-y', 'migrate');
125
+ assert.equal(misplaced.status, 2);
126
+ assert.match(misplaced.stderr, /migrate must be the first argument/);
127
+ assert.deepEqual(fs.readdirSync(dir), ['.git']);
128
+ });
129
+
130
+ test('platform-refused-exits-one: an install migrate cannot read stops the whole run with exit 1', () => {
131
+ const dir = gitRepo('refused');
132
+ const install = cafekit(dir, '--platform', 'claude,codex', '-y');
133
+ assert.equal(install.status, 0, install.stderr || install.stdout);
134
+ fs.writeFileSync(path.join(dir, '.codex/cafekit-manifest.json'), '{ "files": ');
135
+ const beforeHash = treeHash(dir);
136
+ for (const args of [['migrate'], ['migrate', '--apply']]) {
137
+ const run = cafekit(dir, ...args);
138
+ assert.equal(run.status, 1, args.join(' '));
139
+ assert.match(run.stderr, /Codex CLI cannot be migrated: the install manifest does not parse/);
140
+ assert.match(run.stderr, /Nothing was changed/);
141
+ assert.doesNotMatch(run.stdout, /✓ Removed/);
142
+ }
143
+ assert.equal(treeHash(dir), beforeHash);
144
+ });
145
+
146
+ test('install-unchanged: a plain install in another repository still succeeds', () => {
147
+ const dir = gitRepo('plain');
148
+ const run = cafekit(dir, '--platform', 'codex', '-y');
149
+ assert.equal(run.status, 0, run.stderr || run.stdout);
150
+ assert.ok(fs.existsSync(path.join(dir, '.codex/hooks.json')));
151
+ assert.ok(!fs.existsSync(path.join(dir, '.cafekit.lock')));
152
+ });