@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.
- package/README.md +1 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/codex-hooks.test.js +38 -6
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native.test.js +15 -8
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +81 -5
- package/bin/__tests__/omp-hooks.test.js +24 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +7 -0
- package/bin/lib/context.js +5 -0
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +5 -3
- package/bin/phases/codex-hooks.js +38 -0
- package/bin/phases/codex-runtime.js +106 -74
- package/bin/phases/copy-payload.js +49 -1
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +7 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
- package/src/claude/hooks/agent.cjs +5 -2
- package/src/claude/hooks/inspect-block.cjs +3 -1
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
- package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
- package/src/claude/hooks/lib/spec-touch.cjs +14 -1
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/privacy-block.cjs +2 -1
- package/src/claude/hooks/rules.cjs +5 -2
- package/src/claude/hooks/session.cjs +4 -1
- package/src/claude/hooks/spec-state.cjs +5 -4
- package/src/claude/hooks/state.cjs +5 -1
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
- package/src/claude/scripts/provenance.cjs +40 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +4 -2
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +1 -1
- package/src/codex/hooks/inspect-block.cjs +7 -4
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-state.cjs +18 -1
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/precompact.cjs +9 -8
- package/src/codex/hooks/privacy-approval.cjs +2 -2
- package/src/codex/hooks/privacy-block.cjs +5 -4
- package/src/codex/hooks/rules.cjs +12 -6
- package/src/codex/hooks/session.cjs +22 -10
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
- 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 =
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
for (const entry of
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
package/bin/lib/codex-install.js
CHANGED
|
@@ -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)');
|
package/bin/lib/context.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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 (!
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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));
|