@haposoft/cafekit 0.18.2 → 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 (118) hide show
  1. package/README.md +1 -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__/codex-hooks.test.js +38 -6
  7. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  8. package/bin/__tests__/codex-native.test.js +15 -8
  9. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  10. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  11. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  12. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  13. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  14. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  15. package/bin/__tests__/migrate-apply.test.js +373 -0
  16. package/bin/__tests__/migrate-cli.test.js +152 -0
  17. package/bin/__tests__/migrate-merged.test.js +269 -0
  18. package/bin/__tests__/migrate-plan.test.js +271 -0
  19. package/bin/__tests__/omp-bridge.test.js +81 -5
  20. package/bin/__tests__/omp-hooks.test.js +24 -0
  21. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  22. package/bin/__tests__/privacy-paths.test.js +184 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  24. package/bin/__tests__/vibe-run.test.js +215 -0
  25. package/bin/__tests__/vibe-skill.test.js +549 -0
  26. package/bin/install.js +99 -1
  27. package/bin/lib/backup.js +43 -24
  28. package/bin/lib/codex-install.js +7 -0
  29. package/bin/lib/context.js +5 -0
  30. package/bin/lib/lock.js +98 -18
  31. package/bin/lib/managed-writer.js +16 -10
  32. package/bin/lib/migrate-apply.js +202 -0
  33. package/bin/lib/migrate-merged.js +342 -0
  34. package/bin/lib/migrate-plan.js +419 -0
  35. package/bin/lib/plugin-inventory.js +33 -0
  36. package/bin/phases/claude-runtime.js +5 -3
  37. package/bin/phases/codex-hooks.js +38 -0
  38. package/bin/phases/codex-runtime.js +106 -74
  39. package/bin/phases/copy-payload.js +49 -1
  40. package/bin/phases/omp-runtime.js +4 -26
  41. package/bin/phases/root-config.js +5 -2
  42. package/package.json +1 -1
  43. package/src/claude/CLAUDE.md +1 -1
  44. package/src/claude/agents/advisor.md +55 -0
  45. package/src/claude/agents/debugger.md +2 -3
  46. package/src/claude/agents/deployer.md +2 -1
  47. package/src/claude/agents/docs-keeper.md +12 -0
  48. package/src/claude/agents/inspector.md +1 -1
  49. package/src/claude/agents/project-manager.md +2 -2
  50. package/src/claude/agents/researcher.md +0 -1
  51. package/src/claude/agents/spec-maker.md +4 -1
  52. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  53. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  54. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  55. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  56. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
  57. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  58. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
  59. package/src/claude/hooks/agent.cjs +5 -2
  60. package/src/claude/hooks/inspect-block.cjs +3 -1
  61. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  62. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  63. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  64. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  65. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  66. package/src/claude/hooks/plugin-context.cjs +80 -0
  67. package/src/claude/hooks/privacy-block.cjs +2 -1
  68. package/src/claude/hooks/rules.cjs +5 -2
  69. package/src/claude/hooks/session.cjs +4 -1
  70. package/src/claude/hooks/spec-state.cjs +5 -4
  71. package/src/claude/hooks/state.cjs +5 -1
  72. package/src/claude/migration-manifest.json +6 -1
  73. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  74. package/src/claude/plugin/core.md +71 -0
  75. package/src/claude/rules/manage-docs.md +1 -2
  76. package/src/claude/rules/review-audit-self-decision.md +1 -1
  77. package/src/claude/rules/skill-workflow-routing.md +1 -1
  78. package/src/claude/rules/state-sync.md +14 -0
  79. package/src/claude/rules/workflow.md +1 -1
  80. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  81. package/src/claude/scripts/provenance.cjs +40 -16
  82. package/src/claude/scripts/setup-statusline.cjs +88 -0
  83. package/src/claude/skills/adv/SKILL.md +139 -0
  84. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  85. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  86. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  87. package/src/claude/skills/code-review/SKILL.md +4 -1
  88. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  89. package/src/claude/skills/develop/SKILL.md +5 -3
  90. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  91. package/src/claude/skills/fix/SKILL.md +2 -2
  92. package/src/claude/skills/git/SKILL.md +4 -2
  93. package/src/claude/skills/git/references/merge-pr.md +125 -0
  94. package/src/claude/skills/specs/SKILL.md +2 -2
  95. package/src/claude/skills/specs/references/review.md +1 -1
  96. package/src/claude/skills/specs/references/templates.md +2 -0
  97. package/src/claude/skills/vibe/SKILL.md +69 -0
  98. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  99. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  100. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  101. package/src/codex/AGENTS.md +1 -1
  102. package/src/codex/hooks/inspect-block.cjs +7 -4
  103. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  104. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  105. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  106. package/src/codex/hooks/lib/state-store.cjs +5 -4
  107. package/src/codex/hooks/precompact.cjs +9 -8
  108. package/src/codex/hooks/privacy-approval.cjs +2 -2
  109. package/src/codex/hooks/privacy-block.cjs +5 -4
  110. package/src/codex/hooks/rules.cjs +12 -6
  111. package/src/codex/hooks/session.cjs +22 -10
  112. package/src/codex/plugin/core.md +76 -0
  113. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  114. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  115. package/src/codex/scripts/setup-agents.cjs +124 -0
  116. package/src/common/AGENTS.md +1 -1
  117. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  118. package/src/omp/hooks/privacy-block.cjs +2 -1
package/bin/install.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  const { spawnSync } = require('child_process');
19
19
  const fs = require('fs');
20
- const { buildContext, PLATFORMS, packageJson } = require('./lib/context');
20
+ const { buildContext, parseInstallerArgs, PLATFORMS, packageJson } = require('./lib/context');
21
21
  const lock = require('./lib/lock');
22
22
  const backup = require('./lib/backup');
23
23
  const manifestLib = require('./lib/manifest');
@@ -120,6 +120,7 @@ function printHelp() {
120
120
  console.log(`CafeKit installer (cafekit) v${packageJson.version}
121
121
 
122
122
  Usage: npx @haposoft/cafekit [options]
123
+ npx @haposoft/cafekit@latest migrate [--apply]
123
124
 
124
125
  Installs CafeKit skills, agents, rules and runtime into the current project
125
126
  for Claude Code and/or Codex CLI. Re-runs are selective: managed files are updated,
@@ -139,12 +140,22 @@ Options:
139
140
  -h, --help Show this help
140
141
  -v, --version Print version
141
142
 
143
+ Commands:
144
+ migrate Preview removing this repository's npx install so the
145
+ CafeKit Claude Code and Codex plugins take over; edited
146
+ files and runtime.json stay. Writes nothing.
147
+ migrate --apply Remove it, with a backup in .cafekit-backup/ that is
148
+ restored if anything fails.
149
+
142
150
  Docs: https://github.com/haposoft/cafekit`);
143
151
  }
144
152
 
145
153
  async function main() {
146
154
  // Info flags short-circuit before any side effects.
147
155
  const argv = process.argv.slice(2);
156
+ if (argv[0] === 'migrate') {
157
+ process.exit(await runMigrate(argv.slice(1)));
158
+ }
148
159
  if (argv.includes('--help') || argv.includes('-h')) {
149
160
  printHelp();
150
161
  process.exit(0);
@@ -154,6 +165,15 @@ async function main() {
154
165
  process.exit(0);
155
166
  }
156
167
 
168
+ // Reject a mistyped argument before the lock and before anything is written.
169
+ try {
170
+ parseInstallerArgs(process.argv);
171
+ } catch (error) {
172
+ console.error(`✗ ${error.message}`);
173
+ console.error(' Run npx @haposoft/cafekit --help for the options.');
174
+ process.exit(error.exitCode || 1);
175
+ }
176
+
157
177
  // ── Concurrency guard (before context; plain output) ────
158
178
  const got = lock.acquire();
159
179
  if (!got.acquired) {
@@ -251,4 +271,82 @@ async function main() {
251
271
  process.exit(exitCode);
252
272
  }
253
273
 
274
+ function printMigrateHelp(stream = console.log) {
275
+ stream(`Usage: npx @haposoft/cafekit@latest migrate [--apply]
276
+
277
+ Removes this repository's CafeKit npx install so the Claude Code and Codex
278
+ plugins take over. Without --apply it only prints the plan.
279
+
280
+ Removed: installed files that still match their install hash and have a plugin
281
+ equivalent, CafeKit hook entries, and the CafeKit instruction blocks.
282
+ Kept: edited files, runtime.json, files you added, skills no plugin ships yet,
283
+ and every user entry in settings.json, hooks.json, AGENTS.md and CLAUDE.md.
284
+ A repository with an .omp/ install is refused.`);
285
+ }
286
+
287
+ /** `migrate [--apply]`: returns the exit code (0 done or nothing to do, 1 failed or refused, 2 bad usage). */
288
+ async function runMigrate(args) {
289
+ let apply = false;
290
+ for (const arg of args) {
291
+ if (arg === '--apply') apply = true;
292
+ else if (arg === '--help' || arg === '-h') {
293
+ printMigrateHelp();
294
+ return 0;
295
+ } else {
296
+ console.error(`✗ Unknown argument for migrate: ${arg}`);
297
+ printMigrateHelp(console.error);
298
+ return 2;
299
+ }
300
+ }
301
+
302
+ const { planMigration, planSteps, applyMigration } = require('./lib/migrate-apply');
303
+ const { renderManifestPlan } = require('./lib/migrate-plan');
304
+ const { renderMergedPlan } = require('./lib/migrate-merged');
305
+ const root = process.cwd();
306
+ let plan;
307
+ try {
308
+ plan = planMigration(root);
309
+ } catch (error) {
310
+ console.error(`✗ migrate could not read this repository: ${error.message}`);
311
+ return 1;
312
+ }
313
+ const steps = planSteps(plan);
314
+ console.log(`CafeKit migrate — ${apply ? 'apply' : 'dry run (nothing is written)'}\n`);
315
+ // The merged-file planner may mark hook scripts kept, so the manifest plan renders after it.
316
+ const manifestText = renderManifestPlan(plan.manifest);
317
+ if (manifestText) console.log(manifestText);
318
+ const mergedText = renderMergedPlan(plan.merged);
319
+ if (mergedText) console.log(`\nMerged files:\n${mergedText}`);
320
+
321
+ // An install migrate cannot read (refused) or does not manage (unmanaged) would be left
322
+ // behind while the rest goes, so nothing is changed until it is fixed.
323
+ const blocked = Object.values(plan.manifest.platforms).filter((platform) => ['refused', 'unmanaged'].includes(platform.status));
324
+ if (plan.merged.refused || blocked.length > 0) {
325
+ if (plan.merged.refused) console.error(`\n✗ Refused: ${plan.merged.refused}`);
326
+ for (const platform of blocked) console.error(`\n✗ ${platform.name} cannot be migrated: ${platform.reason}`);
327
+ console.error(' Nothing was changed. Fix or remove that install by hand, then run migrate again.');
328
+ return 1;
329
+ }
330
+ if (steps.length === 0) {
331
+ console.log('\nNothing to migrate: no CafeKit npx file here is planned for removal.');
332
+ return 0;
333
+ }
334
+ if (!apply) {
335
+ console.log(`\n${steps.length} changes planned. Folders left empty are removed too.`);
336
+ console.log('Run `npx @haposoft/cafekit@latest migrate --apply` to apply them, with a backup.');
337
+ return 0;
338
+ }
339
+
340
+ const result = await applyMigration(root, { plan });
341
+ if (result.ok) {
342
+ console.log(`\n✓ Removed ${result.removed} files and rewrote ${result.written}. Backup: ${result.backupDir}`);
343
+ console.log(' Install the plugins if you have not: see the CafeKit README.');
344
+ return 0;
345
+ }
346
+ console.error(`\n✗ migrate stopped (${result.reason}): ${result.message}`);
347
+ if (result.backupDir) console.error(` Backup: ${result.backupDir}`);
348
+ for (const step of result.manualSteps || []) console.error(` ${step}`);
349
+ return 1;
350
+ }
351
+
254
352
  main();
package/bin/lib/backup.js CHANGED
@@ -47,30 +47,30 @@ function validateRunId(runId) {
47
47
  * Recursively copy a directory tree (verbatim, no transforms). Used for
48
48
  * snapshot and restore. Skips if the source does not exist.
49
49
  */
50
- function copyTree(src, dest) {
50
+ function copyTree(src, dest, io = fs) {
51
51
  let stat;
52
52
  try {
53
- stat = fs.lstatSync(src);
53
+ stat = io.lstatSync(src);
54
54
  } catch (error) {
55
55
  if (error.code === 'ENOENT') return;
56
56
  throw error;
57
57
  }
58
58
  if (stat.isSymbolicLink()) {
59
- fs.mkdirSync(path.dirname(dest), { recursive: true });
60
- fs.symlinkSync(fs.readlinkSync(src), dest);
59
+ io.mkdirSync(path.dirname(dest), { recursive: true });
60
+ io.symlinkSync(io.readlinkSync(src), dest);
61
61
  return;
62
62
  }
63
63
  if (stat.isDirectory()) {
64
- fs.mkdirSync(dest, { recursive: true });
65
- for (const entry of fs.readdirSync(src)) {
66
- copyTree(path.join(src, entry), path.join(dest, entry));
64
+ io.mkdirSync(dest, { recursive: true });
65
+ for (const entry of io.readdirSync(src)) {
66
+ copyTree(path.join(src, entry), path.join(dest, entry), io);
67
67
  }
68
68
  } else {
69
- fs.mkdirSync(path.dirname(dest), { recursive: true });
70
- fs.copyFileSync(src, dest);
69
+ io.mkdirSync(path.dirname(dest), { recursive: true });
70
+ io.copyFileSync(src, dest);
71
71
  // Preserve restrictive perms (e.g. .env at 0o600) on the backup copy.
72
72
  try {
73
- fs.chmodSync(dest, stat.mode);
73
+ io.chmodSync(dest, stat.mode);
74
74
  } catch {
75
75
  /* best-effort: chmod may fail on some filesystems */
76
76
  }
@@ -111,35 +111,54 @@ function snapshot(folders, runId) {
111
111
  * Restore every explicit target to its pre-run state. Existing targets are
112
112
  * replaced from backup; targets absent before the run are removed.
113
113
  *
114
+ * A target that fails does not stop the others: every target is attempted, and
115
+ * the failures are thrown together at the end as `error.restoreErrors`
116
+ * ([{ target, message }]) so the caller can name what to put back by hand.
117
+ *
114
118
  * @param {string} backupDir path returned by snapshot()
119
+ * @param {{fs?: object}} options file-system implementation (tests inject failures)
115
120
  */
116
- function restore(backupDir) {
117
- if (!backupDir || !fs.existsSync(backupDir)) return;
121
+ function restore(backupDir, options = {}) {
122
+ const io = options.fs || fs;
123
+ if (!backupDir || !io.existsSync(backupDir)) return;
118
124
 
119
125
  const metadataPath = path.join(backupDir, SNAPSHOT_METADATA);
120
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
126
+ const metadata = JSON.parse(io.readFileSync(metadataPath, 'utf8'));
121
127
  if (metadata?.schemaVersion !== 1 || !Array.isArray(metadata.targets)) {
122
128
  throw new Error(`Invalid backup metadata: ${metadataPath}`);
123
129
  }
124
-
125
130
  for (const record of metadata.targets) {
126
131
  if (!record || typeof record.existed !== 'boolean') {
127
132
  throw new Error(`Invalid backup target metadata: ${metadataPath}`);
128
133
  }
134
+ validateTarget(record.target);
135
+ }
136
+
137
+ const restoreErrors = [];
138
+ for (const record of metadata.targets) {
129
139
  const target = validateTarget(record.target);
130
- const source = path.join(backupDir, SNAPSHOT_DATA, target);
131
- let sourceExists = true;
132
140
  try {
133
- fs.lstatSync(source);
141
+ const source = path.join(backupDir, SNAPSHOT_DATA, target);
142
+ let sourceExists = true;
143
+ try {
144
+ io.lstatSync(source);
145
+ } catch (error) {
146
+ if (error.code === 'ENOENT') sourceExists = false;
147
+ else throw error;
148
+ }
149
+ if (record.existed && !sourceExists) {
150
+ throw new Error(`Backup data missing for target: ${target}`);
151
+ }
152
+ io.rmSync(target, { recursive: true, force: true });
153
+ if (record.existed) copyTree(source, target, io);
134
154
  } catch (error) {
135
- if (error.code === 'ENOENT') sourceExists = false;
136
- else throw error;
137
- }
138
- if (record.existed && !sourceExists) {
139
- throw new Error(`Backup data missing for target: ${target}`);
155
+ restoreErrors.push({ target, message: error.message });
140
156
  }
141
- fs.rmSync(target, { recursive: true, force: true });
142
- if (record.existed) copyTree(source, target);
157
+ }
158
+ if (restoreErrors.length > 0) {
159
+ const error = new Error(`Could not restore ${restoreErrors.map((item) => item.target).join(', ')} from ${backupDir}`);
160
+ error.restoreErrors = restoreErrors;
161
+ throw error;
143
162
  }
144
163
  }
145
164
 
@@ -285,6 +285,10 @@ function normalizeRuntimePaths(content, sourcePath = '') {
285
285
  return next;
286
286
  }
287
287
 
288
+ // Matches the vibe invocation list across a hard line wrap.
289
+ const VIBE_INVOCATION_CLAUDE = new RegExp(
290
+ '\\(`/cf:vibe` from the plugin, `/vibe` from a project install, `\\$cf-vibe` on Codex\\)'.replace(/ /g, '\\s+'), 'g');
291
+
288
292
  function normalizeSkillNames(content) {
289
293
  return content
290
294
  .replace(/\/cf:([a-z0-9-]+)/gi, (_match, name) => `$cf-${name}`)
@@ -349,6 +353,9 @@ function normalizeCodexBody(content, sourcePath = '') {
349
353
  'It runs autonomously with high reasoning effort on the session model unless configuration selects another');
350
354
  }
351
355
 
356
+ // The vibe-run exception lists every way to invoke vibe; on Codex only the `$cf-vibe` mention exists, and the
357
+ // generic rename below would turn the Claude names into the false "`$cf-vibe` from the plugin".
358
+ next = next.replace(VIBE_INVOCATION_CLAUDE, () => '(`$cf-vibe`)');
352
359
  next = normalizeAgentInvocations(normalizeAgentNames(normalizeSkillNames(next)));
353
360
  next = next.replace(/`?CLAUDE_CODE_SUBAGENT_MODEL`?/g,
354
361
  'the configured agent model (`model` in `.codex/agents/<agent>.toml`, or `[agents].default_subagent_model` in native Codex configuration)');
@@ -245,6 +245,7 @@ function getCopyOptions(platformKey, baseOptions = {}) {
245
245
  * --upgrade / -u / --force / -f / --force-overwrite → forceOverwrite:true
246
246
  * (overwrite user-modified managed files, backing up the user copy first)
247
247
  * --dry-run → dryRun:true
248
+ * an unknown positional argument → throws with exitCode 2
248
249
  *
249
250
  * `upgrade` is kept as an alias of `forceOverwrite` for backward compatibility
250
251
  * with existing docs and muscle memory.
@@ -283,6 +284,10 @@ function parseInstallerArgs(argv) {
283
284
  args.lang = argv[++i];
284
285
  } else if (arg.startsWith('--lang=')) {
285
286
  args.lang = arg.slice('--lang='.length);
287
+ } else if (!arg.startsWith('-')) {
288
+ // A typo of a subcommand must not run an install.
289
+ const hint = arg === 'migrate' ? ' (migrate must be the first argument)' : '';
290
+ throw Object.assign(new Error(`Unknown argument: ${JSON.stringify(arg)}${hint}`), { exitCode: 2 });
286
291
  }
287
292
  }
288
293
 
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));