@haposoft/cafekit 0.18.1 → 0.18.3

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 (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -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__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
package/bin/lib/lock.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * results; the orchestrator decides what to log and whether to exit.
11
11
  */
12
12
 
13
+ const crypto = require('crypto');
13
14
  const fs = require('fs');
14
15
  const path = require('path');
15
16
 
@@ -30,36 +31,115 @@ function isProcessAlive(pid) {
30
31
  }
31
32
  }
32
33
 
34
+ /** Lock file text for this process. */
35
+ function lockPayload() {
36
+ return `${JSON.stringify({ pid: process.pid, since: new Date().toISOString() }, null, 2)}\n`;
37
+ }
38
+
39
+ /**
40
+ * Create `file` holding `data` only if it does not exist; false when another process got
41
+ * there first. The data is written to a private temp file and hard-linked into place, so
42
+ * the file never exists empty: a `wx` write opens it before writing, and a reader in that
43
+ * gap would see no pid and take a live lock for a stale one.
44
+ */
45
+ function createExclusive(file, data) {
46
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
47
+ let written = false;
48
+ try {
49
+ fs.writeFileSync(temp, data, { encoding: 'utf8', flag: 'wx' });
50
+ written = true;
51
+ fs.linkSync(temp, file);
52
+ return true;
53
+ } catch (err) {
54
+ // A failed temp write (a full disk) is a real error; never create the lock after it.
55
+ if (!written) throw err;
56
+ if (err.code === 'EEXIST') return false;
57
+ // exFAT, FAT32 and some network or VM shares have no hard links, and the error a
58
+ // failed link reports differs by platform. The temp write succeeded, so the folder is
59
+ // writable: fall back to a plain `wx` create, which still lets only one run win (with
60
+ // the brief empty-file gap back) and raises any real error itself.
61
+ return createWithoutLink(file, data);
62
+ } finally {
63
+ fs.rmSync(temp, { force: true });
64
+ }
65
+ }
66
+
67
+ function createWithoutLink(file, data) {
68
+ try {
69
+ fs.writeFileSync(file, data, { encoding: 'utf8', flag: 'wx' });
70
+ return true;
71
+ } catch (err) {
72
+ if (err.code === 'EEXIST') return false;
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ function readOrNull(file) {
78
+ try {
79
+ return fs.readFileSync(file, 'utf8');
80
+ } catch (err) {
81
+ if (err.code === 'ENOENT') return null;
82
+ throw err;
83
+ }
84
+ }
85
+
86
+ function parseLock(raw) {
87
+ try {
88
+ const info = JSON.parse(raw);
89
+ return info && typeof info === 'object' ? info : {};
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+
33
95
  /**
34
96
  * Attempt to acquire the lock.
35
97
  *
98
+ * The lock file is created with `wx`, so of two racing runs exactly one wins. A stale
99
+ * lock (its pid is dead) is reclaimed only by the run that also creates the
100
+ * `.cafekit.lock.reclaim` guard with `wx`: it re-reads the lock, and only when the
101
+ * bytes are still the stale ones it read does it delete the lock and create its own,
102
+ * again with `wx`. Checking and replacing cannot be one atomic step, so without the
103
+ * guard two reclaimers could each see the stale bytes and each believe it won.
104
+ *
36
105
  * @param {string} cwd project root (defaults to process.cwd())
37
106
  * @returns {{acquired: boolean, reason?: string, pid?: number, since?: string, reclaimed?: boolean}}
38
107
  * acquired:false + reason:'held' → another live process owns it (pid/since set)
108
+ * acquired:false + reason:'reclaiming' → another run is reclaiming a stale lock, or died doing so
39
109
  * acquired:true, reclaimed:true → a stale lock was taken over
40
110
  */
41
- function acquire(cwd = process.cwd()) {
111
+ function acquire(cwd = process.cwd(), attempt = 0) {
42
112
  const lockPath = path.join(cwd, LOCK_FILE);
43
- let reclaimed = false;
44
-
45
- if (fs.existsSync(lockPath)) {
46
- let info = {};
47
- try {
48
- info = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
49
- } catch {
50
- info = {};
51
- }
113
+ if (createExclusive(lockPath, lockPayload())) return { acquired: true, reclaimed: false };
52
114
 
53
- if (isProcessAlive(info.pid)) {
54
- return { acquired: false, reason: 'held', pid: info.pid, since: info.since };
55
- }
56
- // Stale lock from a dead process — safe to reclaim.
57
- reclaimed = true;
115
+ const raw = readOrNull(lockPath);
116
+ // The owner released it between our create and our read: try once more.
117
+ if (raw === null) return attempt < 1 ? acquire(cwd, attempt + 1) : { acquired: false, reason: 'held', pid: undefined };
118
+ const info = parseLock(raw);
119
+ if (isProcessAlive(info.pid)) {
120
+ return { acquired: false, reason: 'held', pid: info.pid, since: info.since };
58
121
  }
59
122
 
60
- const payload = { pid: process.pid, since: new Date().toISOString() };
61
- fs.writeFileSync(lockPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
62
- return { acquired: true, reclaimed };
123
+ const guardPath = `${lockPath}.reclaim`;
124
+ // The current owner, for a refusal that names who holds the lock.
125
+ const held = () => {
126
+ const owner = parseLock(readOrNull(lockPath) || '');
127
+ return { acquired: false, reason: 'held', pid: owner.pid, since: owner.since };
128
+ };
129
+ if (!createExclusive(guardPath, lockPayload())) {
130
+ // A guard left by a run that died while reclaiming blocks every later reclaim until
131
+ // it is deleted by hand; removing it here would reopen the race it closes.
132
+ const guard = parseLock(readOrNull(guardPath) || '');
133
+ return { acquired: false, reason: 'reclaiming', pid: guard.pid, since: guard.since, guard: guardPath };
134
+ }
135
+ try {
136
+ if (readOrNull(lockPath) !== raw) return held();
137
+ fs.rmSync(lockPath, { force: true });
138
+ if (!createExclusive(lockPath, lockPayload())) return held();
139
+ return { acquired: true, reclaimed: true };
140
+ } finally {
141
+ fs.rmSync(guardPath, { force: true });
142
+ }
63
143
  }
64
144
 
65
145
  /**
@@ -39,23 +39,29 @@ function readPayload(src, transform) {
39
39
  /**
40
40
  * Plan and (unless dry-run) perform the write for a single managed file.
41
41
  *
42
+ * `content` (a string) replaces `src` for a file the installer generates rather than copies; it is
43
+ * written as UTF-8 without an executable bit and goes through the same ownership decision.
44
+ *
42
45
  * @returns {{action: string, state: string}}
43
46
  * action: 'created' | 'updated' | 'unchanged' | 'preserved' | 'missing'
44
47
  */
45
48
  function writeManagedFile(opts) {
46
- const { src, dest, platformFolder, ctx, tracker, transform, sourceRoot } = opts;
49
+ const { src, dest, platformFolder, ctx, tracker, transform, sourceRoot, content } = opts;
50
+ const generated = typeof content === 'string';
47
51
 
48
- if (!fs.existsSync(src)) {
49
- return { action: 'missing', state: 'absent' };
50
- }
51
- const sourceStat = fs.lstatSync(src);
52
- if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
53
- throw new Error(`Managed source must be a non-symlink regular file: ${src}`);
52
+ if (!generated) {
53
+ if (!fs.existsSync(src)) {
54
+ return { action: 'missing', state: 'absent' };
55
+ }
56
+ const sourceStat = fs.lstatSync(src);
57
+ if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
58
+ throw new Error(`Managed source must be a non-symlink regular file: ${src}`);
59
+ }
60
+ if (sourceRoot) assertNoSymlinkPath(src, sourceRoot);
54
61
  }
55
- if (sourceRoot) assertNoSymlinkPath(src, sourceRoot);
56
62
  assertNoSymlinkPath(dest);
57
63
 
58
- const { data, utf8 } = readPayload(src, transform);
64
+ const { data, utf8 } = generated ? { data: content, utf8: true } : readPayload(src, transform);
59
65
  const payloadHash = manifest.sha256(data);
60
66
  // Ownership manifest for this platform (read at run start), NOT the migration manifest.
61
67
  const ownership = (ctx.ownership && ctx.ownership[platformFolder]) || { files: {} };
@@ -116,7 +122,7 @@ function writeManagedFile(opts) {
116
122
  } else {
117
123
  fs.writeFileSync(dest, data);
118
124
  }
119
- if (process.platform !== 'win32') {
125
+ if (process.platform !== 'win32' && !generated) {
120
126
  const sourceMode = fs.statSync(src).mode;
121
127
  const destinationMode = fs.statSync(dest).mode;
122
128
  fs.chmodSync(dest, (destinationMode & ~0o111) | (sourceMode & 0o111));
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Apply a `migrate` plan: remove exactly the planned files of an npx install and
5
+ * rewrite the merged files, with a backup that is restored on any failure.
6
+ *
7
+ * Order: cwd check → lock → snapshot → re-plan and compare → per-file re-check →
8
+ * writes → metadata → empty folders. Any error, or SIGINT between steps, restores
9
+ * every snapshotted target. No console output; the caller reports the result.
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { sha256 } = require('./manifest');
16
+ const backup = require('./backup');
17
+ const lock = require('./lock');
18
+ const { planManifestFiles } = require('./migrate-plan');
19
+ const { planMergedFiles } = require('./migrate-merged');
20
+
21
+ class MigrateError extends Error {
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ }
27
+
28
+ function planMigration(projectRoot) {
29
+ const manifest = planManifestFiles(projectRoot);
30
+ const merged = planMergedFiles(projectRoot, manifest);
31
+ return { manifest, merged };
32
+ }
33
+
34
+ /** Every change a plan makes, in apply order; two plans agree when their steps are equal. */
35
+ function planSteps(plan) {
36
+ const steps = [];
37
+ for (const entry of plan.manifest.entries) {
38
+ if (entry.action === 'remove') steps.push({ op: 'unlink', path: entry.path, sha256: entry.sha256 });
39
+ }
40
+ for (const file of plan.merged.files) {
41
+ if (file.action === 'write') steps.push({ op: 'write', path: file.path, sha256: sha256(file.before), content: file.after });
42
+ if (file.action === 'remove') steps.push({ op: 'unlink', path: file.path, sha256: sha256(file.before) });
43
+ }
44
+ // The install metadata goes last, and only once the install owns no file any more.
45
+ for (const platform of Object.values(plan.manifest.platforms)) {
46
+ if (platform.status !== 'planned') continue;
47
+ const owns = plan.manifest.entries.some((entry) => entry.platform === platform.platform && entry.action === 'keep');
48
+ if (!owns) for (const rel of platform.metadata) steps.push({ op: 'unlink-metadata', path: rel });
49
+ }
50
+ return steps;
51
+ }
52
+
53
+ /** Top-level paths the steps touch, which is what the backup snapshots. */
54
+ function snapshotTargets(steps) {
55
+ return [...new Set(steps.map((step) => step.path.split('/')[0]))].sort();
56
+ }
57
+
58
+ const nextTick = () => new Promise((resolve) => setImmediate(resolve));
59
+
60
+ /** Refuse to touch a path that changed since it was planned or that is not a plain file. */
61
+ function verifyStep(io, abs, step) {
62
+ if (step.op === 'unlink-metadata') return;
63
+ const stat = io.lstatSync(abs);
64
+ if (stat.isSymbolicLink() || !stat.isFile() || sha256(io.readFileSync(abs)) !== step.sha256) {
65
+ throw new MigrateError('stale', `${step.path} changed since it was planned`);
66
+ }
67
+ }
68
+
69
+ function performStep(io, abs, step) {
70
+ if (step.op === 'write') {
71
+ // Write beside the target and rename over it, so the file is never half written. The
72
+ // temp name is unpredictable and created with `wx`, so a planted symlink is never
73
+ // followed; it keeps the target's mode, and it is removed again if the write fails,
74
+ // because a temp file beside a root-level file lies outside every snapshot target.
75
+ const temp = `${abs}.cafekit-migrate-${crypto.randomBytes(6).toString('hex')}`;
76
+ const mode = io.lstatSync(abs).mode & 0o7777;
77
+ let opened = false;
78
+ try {
79
+ io.writeFileSync(temp, step.content, { flag: 'wx', mode });
80
+ opened = true;
81
+ io.chmodSync(temp, mode);
82
+ io.renameSync(temp, abs);
83
+ } catch (error) {
84
+ // writeFileSync creates the file before writing it, so a failed write (ENOSPC)
85
+ // still leaves a temp behind; only an EEXIST from the create means the name is
86
+ // someone else's.
87
+ if (opened || error.code !== 'EEXIST') {
88
+ try {
89
+ io.unlinkSync(temp);
90
+ } catch {
91
+ // Never created, or already gone.
92
+ }
93
+ }
94
+ throw error;
95
+ }
96
+ return;
97
+ }
98
+ if (step.op === 'unlink-metadata' && !io.existsSync(abs)) return;
99
+ io.unlinkSync(abs);
100
+ }
101
+
102
+ /** Remove folders the plan emptied, deepest first; a folder that still holds anything stays. */
103
+ function removeEmptyFolders(io, root, steps) {
104
+ const folders = new Set();
105
+ for (const step of steps) {
106
+ let dir = path.posix.dirname(step.path);
107
+ while (dir !== '.') {
108
+ folders.add(dir);
109
+ dir = path.posix.dirname(dir);
110
+ }
111
+ }
112
+ const deepestFirst = [...folders].sort((a, b) => b.split('/').length - a.split('/').length || a.localeCompare(b));
113
+ for (const rel of deepestFirst) {
114
+ try {
115
+ io.rmdirSync(path.join(root, rel));
116
+ } catch (error) {
117
+ if (!['ENOTEMPTY', 'EEXIST', 'ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param {string} projectRoot repository root; must equal the current working directory
124
+ * @param {{plan?: object, fs?: object, runId?: string, onStep?: Function}} options
125
+ * plan: the plan the user saw (re-planned and compared before any change)
126
+ * fs: file-system implementation for apply and restore (tests inject failures)
127
+ * @returns {Promise<object>} { ok, reason?, message?, backupDir?, removed, written, restoreErrors?, manualSteps? }
128
+ */
129
+ async function applyMigration(projectRoot, options = {}) {
130
+ const io = options.fs || fs;
131
+ const root = fs.realpathSync(projectRoot);
132
+ // backup.js and lock.js resolve paths from the working directory.
133
+ if (fs.realpathSync(process.cwd()) !== root) {
134
+ return { ok: false, reason: 'wrong-cwd', message: `Run migrate from the repository root: ${root}` };
135
+ }
136
+ const given = options.plan || planMigration(root);
137
+ if (given.merged.refused) return { ok: false, reason: 'refused', message: given.merged.refused };
138
+ const steps = planSteps(given);
139
+ if (steps.length === 0) return { ok: true, nothing: true, removed: 0, written: 0 };
140
+
141
+ const got = lock.acquire(root);
142
+ if (!got.acquired) {
143
+ const hint = got.reason === 'reclaiming'
144
+ ? `; if no CafeKit run is active, delete ${lock.LOCK_FILE} and ${lock.LOCK_FILE}.reclaim`
145
+ : '';
146
+ return { ok: false, reason: 'locked', message: `Another CafeKit run holds ${lock.LOCK_FILE}${got.pid ? ` (pid ${got.pid})` : ''}${hint}` };
147
+ }
148
+
149
+ let interrupted = false;
150
+ const onSigint = () => { interrupted = true; };
151
+ process.on('SIGINT', onSigint);
152
+ let backupDir = null;
153
+ let touched = false;
154
+ const done = { removed: 0, written: 0 };
155
+ try {
156
+ backupDir = backup.snapshot(snapshotTargets(steps), options.runId || `${Date.now()}-migrate`);
157
+ const fresh = planMigration(root);
158
+ if (fresh.merged.refused || JSON.stringify(planSteps(fresh)) !== JSON.stringify(steps)) {
159
+ throw new MigrateError('stale', 'The repository changed since the plan was shown; run migrate again to see the new plan');
160
+ }
161
+ for (const step of steps) {
162
+ await nextTick();
163
+ if (interrupted) throw new MigrateError('interrupted', 'Interrupted');
164
+ const abs = path.join(root, step.path);
165
+ verifyStep(io, abs, step);
166
+ touched = true;
167
+ performStep(io, abs, step);
168
+ if (step.op === 'write') done.written++;
169
+ else done.removed++;
170
+ if (typeof options.onStep === 'function') options.onStep(step);
171
+ }
172
+ await nextTick();
173
+ if (interrupted) throw new MigrateError('interrupted', 'Interrupted');
174
+ removeEmptyFolders(io, root, steps);
175
+ return { ok: true, backupDir, ...done };
176
+ } catch (error) {
177
+ let restoreErrors = [];
178
+ if (touched) {
179
+ try {
180
+ backup.restore(backupDir, { fs: io });
181
+ } catch (restoreError) {
182
+ restoreErrors = restoreError.restoreErrors || [{ target: '*', message: restoreError.message }];
183
+ }
184
+ }
185
+ const manualSteps = restoreErrors.map(({ target }) => (target === '*'
186
+ ? `Restore every folder and file under ${path.join(backupDir, 'data')} by hand`
187
+ : `Copy ${path.join(backupDir, 'data', target)} back to ${target}`));
188
+ // The rollback puts back the snapshot taken when apply started, so a change made to
189
+ // those folders while apply ran is not kept either.
190
+ const failed = new Set(restoreErrors.map((item) => item.target));
191
+ const rolledBack = snapshotTargets(steps).filter((target) => !failed.has(target) && !failed.has('*'));
192
+ const note = touched && rolledBack.length > 0
193
+ ? ` Rolled back ${rolledBack.join(', ')} to the snapshot taken when migrate started; edits made there while it ran were not kept.`
194
+ : '';
195
+ return { ok: false, reason: error.code || 'failed', message: `${error.message.replace(/\.$/, '')}.${note}`, backupDir, restored: touched, restoreErrors, manualSteps, ...done };
196
+ } finally {
197
+ process.removeListener('SIGINT', onSigint);
198
+ lock.release(root);
199
+ }
200
+ }
201
+
202
+ module.exports = { applyMigration, planMigration, planSteps };