@haposoft/cafekit 0.14.1 → 0.15.0
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 +86 -11
- package/bin/__tests__/codex-hooks.test.js +331 -0
- package/bin/__tests__/codex-native.test.js +458 -0
- package/bin/__tests__/installer-safety.test.js +259 -0
- package/bin/install.js +30 -8
- package/bin/lib/backup.js +82 -18
- package/bin/lib/codex-frontmatter.js +56 -0
- package/bin/lib/codex-install.js +195 -0
- package/bin/lib/context.js +65 -8
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/managed-writer.js +17 -2
- package/bin/lib/manifest.js +21 -4
- package/bin/lib/opencode-install.js +21 -19
- package/bin/lib/path-safety.js +40 -0
- package/bin/lib/version-check.js +25 -12
- package/bin/phases/claude-runtime.js +60 -9
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +98 -48
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +105 -66
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/code-auditor.md +1 -1
- package/src/claude/agents/debugger.md +1 -0
- package/src/claude/agents/god-developer.md +10 -1
- package/src/claude/agents/inspector.md +1 -0
- package/src/claude/agents/spec-maker.md +1 -1
- package/src/claude/agents/test-runner.md +2 -2
- package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
- package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
- package/src/claude/hooks/__tests__/state.test.js +65 -0
- package/src/claude/hooks/docs-sync.cjs +15 -21
- package/src/claude/hooks/inspect-block.cjs +1 -1
- package/src/claude/hooks/lib/config.cjs +0 -5
- package/src/claude/hooks/privacy-block.cjs +39 -62
- package/src/claude/hooks/spec-gate.cjs +18 -7
- package/src/claude/hooks/spec-state.cjs +4 -0
- package/src/claude/hooks/state.cjs +13 -3
- package/src/claude/hooks/usage.cjs +6 -0
- package/src/claude/rules/orchestrator.md +2 -2
- package/src/claude/rules/skill-domain-routing.md +27 -86
- package/src/claude/rules/skill-workflow-routing.md +11 -82
- package/src/claude/rules/state-sync.md +1 -1
- package/src/claude/runtime.json +7 -7
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- package/src/claude/scripts/validate-spec-output.cjs +6 -0
- package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
- package/src/claude/skills/develop/SKILL.md +26 -5
- package/src/claude/skills/develop/references/parallel-waves.md +112 -0
- package/src/claude/skills/develop/references/quality-gate.md +10 -2
- package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
- package/src/claude/skills/inspect/SKILL.md +42 -113
- package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
- package/src/claude/skills/specs/SKILL.md +134 -386
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/task.md +3 -1
- package/src/claude/skills/sync/SKILL.md +2 -2
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +1 -1
- package/src/claude/status.cjs +9 -6
- package/src/codex/AGENTS.md +30 -0
- package/src/codex/agents-gitignore +11 -0
- package/src/codex/gitignore +12 -0
- package/src/codex/hooks/agent.cjs +66 -0
- package/src/codex/hooks/docs-sync.cjs +66 -0
- package/src/codex/hooks/inspect-block.cjs +40 -0
- package/src/codex/hooks/lib/hook-context.cjs +93 -0
- package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
- package/src/codex/hooks/lib/privacy-state.cjs +199 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
- package/src/codex/hooks/lib/spec-utils.cjs +50 -0
- package/src/codex/hooks/lib/state-store.cjs +166 -0
- package/src/codex/hooks/privacy-approval.cjs +59 -0
- package/src/codex/hooks/privacy-block.cjs +182 -0
- package/src/codex/hooks/rules.cjs +79 -0
- package/src/codex/hooks/session.cjs +114 -0
- package/src/codex/hooks/spec-gate.cjs +80 -0
- package/src/codex/hooks/spec-state.cjs +73 -0
- package/src/codex/hooks/state.cjs +157 -0
- package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
- package/src/codex/hooks.json +122 -0
- package/src/codex/rules/hook-protocols.md +32 -0
- package/src/codex/rules/state-sync.md +41 -0
- package/src/codex/runtime.json +28 -0
- package/src/opencode/plugins/docs-sync.ts +10 -16
- package/src/claude/archive-command/code/SKILL.md +0 -55
- package/src/claude/archive-command/code/references/execution-loop.md +0 -21
- package/src/claude/archive-command/code.md +0 -17
- package/src/claude/archive-command/docs.md +0 -609
- package/src/claude/archive-command/review/codebase/parallel.md +0 -76
- package/src/claude/archive-command/spec-design.md +0 -247
- package/src/claude/archive-command/spec-init.md +0 -120
- package/src/claude/archive-command/spec-requirements.md +0 -138
- package/src/claude/archive-command/spec-status.md +0 -98
- package/src/claude/archive-command/spec-tasks.md +0 -173
- package/src/claude/archive-command/spec-validate.md +0 -118
- package/src/claude/archive-command/test.md +0 -8
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
|
|
9
|
+
const backup = require('../lib/backup');
|
|
10
|
+
const {
|
|
11
|
+
copyOpenCodeAgentsMdFile,
|
|
12
|
+
normalizeOpenCodeBody
|
|
13
|
+
} = require('../lib/opencode-install');
|
|
14
|
+
const { copyClaudeMdFile } = require('../phases/claude-runtime');
|
|
15
|
+
const { configureAddressing, patchRuntimeLocale } = require('../phases/post-install');
|
|
16
|
+
|
|
17
|
+
const START = '<!-- CAFEKIT CLAUDE START -->';
|
|
18
|
+
const END = '<!-- CAFEKIT CLAUDE END -->';
|
|
19
|
+
const TEMPLATE = fs.readFileSync(path.join(__dirname, '../../src/claude/CLAUDE.md'), 'utf8');
|
|
20
|
+
|
|
21
|
+
function inTempProject(run) {
|
|
22
|
+
const originalCwd = process.cwd();
|
|
23
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-installer-safety-'));
|
|
24
|
+
process.chdir(root);
|
|
25
|
+
try {
|
|
26
|
+
return run(root);
|
|
27
|
+
} finally {
|
|
28
|
+
process.chdir(originalCwd);
|
|
29
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function installContext(pruned) {
|
|
34
|
+
return {
|
|
35
|
+
dryRun: false,
|
|
36
|
+
options: { forceOverwrite: true },
|
|
37
|
+
ownership: { '.claude': { files: {} } },
|
|
38
|
+
trackers: {
|
|
39
|
+
claude: {
|
|
40
|
+
recorded: () => null,
|
|
41
|
+
record: () => assert.fail('root CLAUDE.md must not be recorded'),
|
|
42
|
+
prune: (target) => pruned.push(target)
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
ui: { detail() {}, warn() {}, success() {} },
|
|
46
|
+
results: {
|
|
47
|
+
copied: 0,
|
|
48
|
+
updated: 0,
|
|
49
|
+
unchanged: 0,
|
|
50
|
+
preserved: 0,
|
|
51
|
+
missingDependencies: 0,
|
|
52
|
+
preservedFiles: []
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('managed CLAUDE block replacement preserves user bytes under force overwrite', () => {
|
|
58
|
+
inTempProject(() => {
|
|
59
|
+
const before = '# User preface\n\n';
|
|
60
|
+
const after = '\n\n## User tail\nKeep this exactly.\n';
|
|
61
|
+
fs.writeFileSync('CLAUDE.md', `${before}${START}\nold managed text\n${END}${after}`);
|
|
62
|
+
|
|
63
|
+
const pruned = [];
|
|
64
|
+
copyClaudeMdFile(installContext(pruned), 'claude');
|
|
65
|
+
|
|
66
|
+
const content = fs.readFileSync('CLAUDE.md', 'utf8');
|
|
67
|
+
assert.ok(content.startsWith(before));
|
|
68
|
+
assert.ok(content.endsWith(after));
|
|
69
|
+
assert.ok(content.includes(TEMPLATE.trimEnd()));
|
|
70
|
+
assert.equal(content.match(new RegExp(START, 'g')).length, 1);
|
|
71
|
+
assert.deepEqual(pruned, ['../CLAUDE.md']);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('legacy pristine CLAUDE template migrates to one managed block', () => {
|
|
76
|
+
inTempProject(() => {
|
|
77
|
+
fs.writeFileSync('CLAUDE.md', TEMPLATE);
|
|
78
|
+
const pruned = [];
|
|
79
|
+
|
|
80
|
+
copyClaudeMdFile(installContext(pruned), 'claude');
|
|
81
|
+
|
|
82
|
+
const content = fs.readFileSync('CLAUDE.md', 'utf8');
|
|
83
|
+
assert.equal(content, `${START}\n${TEMPLATE.trimEnd()}\n${END}\n`);
|
|
84
|
+
assert.equal(content.match(new RegExp(START, 'g')).length, 1);
|
|
85
|
+
assert.deepEqual(pruned, ['../CLAUDE.md']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('addressing changes remain inside the managed CLAUDE block', () => {
|
|
90
|
+
inTempProject(() => {
|
|
91
|
+
const userSection = '## Addressing (Context Overflow Indicator)\n\nUser-owned wording.';
|
|
92
|
+
const managedSection = '## Addressing (Context Overflow Indicator)\n\nOld managed wording.';
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
'CLAUDE.md',
|
|
95
|
+
`${userSection}\n\n${START}\n${managedSection}\n${END}\n`
|
|
96
|
+
);
|
|
97
|
+
const ctx = {
|
|
98
|
+
platforms: ['claude'],
|
|
99
|
+
ui: { success() {}, warn() {} },
|
|
100
|
+
t: () => 'updated'
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
configureAddressing(ctx, 'anh');
|
|
104
|
+
|
|
105
|
+
const content = fs.readFileSync('CLAUDE.md', 'utf8');
|
|
106
|
+
assert.ok(content.startsWith(`${userSection}\n\n${START}`));
|
|
107
|
+
assert.ok(content.includes('Claude Code always addresses the user as "anh"'));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('OpenCode force refresh preserves user and Codex AGENTS blocks', () => {
|
|
112
|
+
inTempProject(() => {
|
|
113
|
+
const userContent = '# User instructions\n\nKeep this exact.\n';
|
|
114
|
+
const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
|
|
115
|
+
fs.writeFileSync('AGENTS.md', `${userContent}\n${codexBlock}`);
|
|
116
|
+
const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
|
|
117
|
+
|
|
118
|
+
copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
|
|
119
|
+
|
|
120
|
+
const content = fs.readFileSync('AGENTS.md', 'utf8');
|
|
121
|
+
assert.ok(content.startsWith(userContent));
|
|
122
|
+
assert.ok(content.includes(codexBlock.trim()));
|
|
123
|
+
assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
|
|
124
|
+
assert.equal(results.updated, 1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('legacy unmarked OpenCode AGENTS content is wrapped without duplication', () => {
|
|
129
|
+
inTempProject(() => {
|
|
130
|
+
const source = path.join(__dirname, '../../src/opencode/AGENTS.md');
|
|
131
|
+
const legacy = normalizeOpenCodeBody(fs.readFileSync(source, 'utf8'));
|
|
132
|
+
const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
|
|
133
|
+
fs.writeFileSync('AGENTS.md', `${legacy}\n${codexBlock}`);
|
|
134
|
+
const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
|
|
135
|
+
|
|
136
|
+
copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
|
|
137
|
+
|
|
138
|
+
const content = fs.readFileSync('AGENTS.md', 'utf8');
|
|
139
|
+
assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
|
|
140
|
+
assert.equal((content.match(/^# AGENTS\.md$/gm) || []).length, 1);
|
|
141
|
+
assert.ok(content.includes(codexBlock.trim()));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('locale patch does not claim ownership of a preserved user runtime', () => {
|
|
146
|
+
inTempProject(() => {
|
|
147
|
+
fs.mkdirSync('.codex', { recursive: true });
|
|
148
|
+
fs.writeFileSync(
|
|
149
|
+
'.codex/runtime.json',
|
|
150
|
+
`${JSON.stringify({ custom: true, locale: { responseLanguage: 'English' } })}\n`
|
|
151
|
+
);
|
|
152
|
+
const ctx = {
|
|
153
|
+
platforms: ['codex'],
|
|
154
|
+
locale: 'Tiếng Việt',
|
|
155
|
+
lang: 'vi',
|
|
156
|
+
trackers: {
|
|
157
|
+
codex: {
|
|
158
|
+
keyFor: () => '.codex/runtime.json',
|
|
159
|
+
recorded: () => null,
|
|
160
|
+
record: () => assert.fail('preserved runtime must not become installer-owned')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
patchRuntimeLocale(ctx);
|
|
166
|
+
|
|
167
|
+
const runtime = JSON.parse(fs.readFileSync('.codex/runtime.json', 'utf8'));
|
|
168
|
+
assert.equal(runtime.custom, true);
|
|
169
|
+
assert.equal(runtime.locale.responseLanguage, 'Tiếng Việt');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('backup restores existing targets and removes targets absent before the run', () => {
|
|
174
|
+
inTempProject(() => {
|
|
175
|
+
fs.mkdirSync('.claude/nested', { recursive: true });
|
|
176
|
+
fs.writeFileSync('.claude/nested/runtime.txt', 'before\n');
|
|
177
|
+
fs.writeFileSync('CLAUDE.md', 'user instructions\n');
|
|
178
|
+
|
|
179
|
+
const backupDir = backup.snapshot(
|
|
180
|
+
['.claude', 'CLAUDE.md', '.gitignore'],
|
|
181
|
+
'20260729-test'
|
|
182
|
+
);
|
|
183
|
+
const metadata = JSON.parse(
|
|
184
|
+
fs.readFileSync(path.join(backupDir, 'snapshot.json'), 'utf8')
|
|
185
|
+
);
|
|
186
|
+
assert.deepEqual(metadata.targets, [
|
|
187
|
+
{ target: '.claude', existed: true },
|
|
188
|
+
{ target: 'CLAUDE.md', existed: true },
|
|
189
|
+
{ target: '.gitignore', existed: false }
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
fs.writeFileSync('.claude/nested/runtime.txt', 'after\n');
|
|
193
|
+
fs.writeFileSync('CLAUDE.md', 'overwritten\n');
|
|
194
|
+
fs.writeFileSync('.gitignore', 'generated\n');
|
|
195
|
+
backup.restore(backupDir);
|
|
196
|
+
|
|
197
|
+
assert.equal(fs.readFileSync('.claude/nested/runtime.txt', 'utf8'), 'before\n');
|
|
198
|
+
assert.equal(fs.readFileSync('CLAUDE.md', 'utf8'), 'user instructions\n');
|
|
199
|
+
assert.equal(fs.existsSync('.gitignore'), false);
|
|
200
|
+
assert.equal(fs.existsSync(path.join(backupDir, 'snapshot.json')), true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('backup preserves nested dependency symlinks without following them', () => {
|
|
205
|
+
inTempProject(() => {
|
|
206
|
+
fs.mkdirSync('.agents/skills/demo/.venv/bin', { recursive: true });
|
|
207
|
+
fs.mkdirSync('toolchain', { recursive: true });
|
|
208
|
+
fs.writeFileSync('toolchain/python3', 'project-owned target\n');
|
|
209
|
+
const linkPath = '.agents/skills/demo/.venv/bin/python3';
|
|
210
|
+
const linkTarget = '../../../../../toolchain/python3';
|
|
211
|
+
fs.symlinkSync(linkTarget, linkPath);
|
|
212
|
+
|
|
213
|
+
const backupDir = backup.snapshot(['.agents'], '20260729-nested-symlink');
|
|
214
|
+
const backupLink = path.join(backupDir, 'data', linkPath);
|
|
215
|
+
assert.equal(fs.lstatSync(backupLink).isSymbolicLink(), true);
|
|
216
|
+
assert.equal(fs.readlinkSync(backupLink), linkTarget);
|
|
217
|
+
|
|
218
|
+
fs.rmSync('.agents', { recursive: true, force: true });
|
|
219
|
+
backup.restore(backupDir);
|
|
220
|
+
|
|
221
|
+
assert.equal(fs.lstatSync(linkPath).isSymbolicLink(), true);
|
|
222
|
+
assert.equal(fs.readlinkSync(linkPath), linkTarget);
|
|
223
|
+
assert.equal(fs.readFileSync(linkPath, 'utf8'), 'project-owned target\n');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('backup rejects traversal targets before snapshot or restore deletion', () => {
|
|
228
|
+
inTempProject(() => {
|
|
229
|
+
assert.throws(
|
|
230
|
+
() => backup.snapshot(['../outside'], '20260729-test'),
|
|
231
|
+
/Unsafe backup target/
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('managed targets and snapshots reject project symlink traversal', () => {
|
|
237
|
+
inTempProject((root) => {
|
|
238
|
+
fs.mkdirSync('external', { recursive: true });
|
|
239
|
+
fs.symlinkSync(
|
|
240
|
+
path.join(root, 'external'),
|
|
241
|
+
'.agents',
|
|
242
|
+
process.platform === 'win32' ? 'junction' : 'dir'
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const { createTracker } = require('../lib/manifest');
|
|
246
|
+
const tracker = createTracker('.codex', 'test', {
|
|
247
|
+
recordRoot: '.',
|
|
248
|
+
allowedRoots: ['.codex', '.agents']
|
|
249
|
+
});
|
|
250
|
+
assert.throws(
|
|
251
|
+
() => tracker.keyFor('.agents/skills/specs/SKILL.md'),
|
|
252
|
+
/Refusing to follow symlinked managed path/
|
|
253
|
+
);
|
|
254
|
+
assert.throws(
|
|
255
|
+
() => backup.snapshot(['.agents'], '20260729-symlink-test'),
|
|
256
|
+
/Refusing to follow symlinked managed path/
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
package/bin/install.js
CHANGED
|
@@ -32,6 +32,7 @@ const {
|
|
|
32
32
|
} = require('./phases/claude-runtime');
|
|
33
33
|
const { mergeClaudeSettings } = require('./phases/claude-settings');
|
|
34
34
|
const { installOpenCodeRuntime } = require('./phases/opencode-runtime');
|
|
35
|
+
const { installCodexRuntime } = require('./phases/codex-runtime');
|
|
35
36
|
const { writePlatformVersionMetadata } = require('./phases/write-metadata');
|
|
36
37
|
const { checkVersions } = require('./lib/version-check');
|
|
37
38
|
const { ensureGitignore } = require('./phases/root-config');
|
|
@@ -46,7 +47,11 @@ function installPlatform(ctx, platformKey) {
|
|
|
46
47
|
|
|
47
48
|
// Read ownership baseline + start a fresh tracker for this platform.
|
|
48
49
|
ctx.ownership[platform.folder] = manifestLib.read(platform.folder);
|
|
49
|
-
ctx.trackers[platformKey] = manifestLib.createTracker(
|
|
50
|
+
ctx.trackers[platformKey] = manifestLib.createTracker(
|
|
51
|
+
platform.folder,
|
|
52
|
+
packageJson.version,
|
|
53
|
+
platform.ownership
|
|
54
|
+
);
|
|
50
55
|
|
|
51
56
|
const before = { copied: ctx.results.copied, updated: ctx.results.updated, skills: ctx.results.installedSkills };
|
|
52
57
|
ctx.ui.startSpinner(ctx.t('installingPlatform', { name: platform.name }));
|
|
@@ -66,6 +71,10 @@ function installPlatform(ctx, platformKey) {
|
|
|
66
71
|
installOpenCodeRuntime(ctx, platformKey);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
if (platformKey === 'codex') {
|
|
75
|
+
installCodexRuntime(ctx, platformKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
writePlatformVersionMetadata(ctx, platformKey);
|
|
70
79
|
|
|
71
80
|
const wrote = (ctx.results.copied - before.copied) + (ctx.results.updated - before.updated);
|
|
@@ -74,7 +83,7 @@ function installPlatform(ctx, platformKey) {
|
|
|
74
83
|
name: platform.name, files: wrote, skills
|
|
75
84
|
}));
|
|
76
85
|
|
|
77
|
-
ctx.results.targets.push(platform.commandsDir);
|
|
86
|
+
ctx.results.targets.push(platform.commandsDir || platform.skillsDir || platform.folder);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
function printHelp() {
|
|
@@ -83,10 +92,11 @@ function printHelp() {
|
|
|
83
92
|
Usage: npx @haposoft/cafekit [options]
|
|
84
93
|
|
|
85
94
|
Installs CafeKit skills, agents, rules and runtime into the current project
|
|
86
|
-
|
|
95
|
+
for Claude Code, OpenCode, and/or Codex CLI. Re-runs are selective: managed files are updated,
|
|
87
96
|
your edits are preserved.
|
|
88
97
|
|
|
89
98
|
Options:
|
|
99
|
+
--platform <id[,id]> Select claude, opencode, or codex explicitly
|
|
90
100
|
--dry-run Preview changes; write nothing
|
|
91
101
|
--force-overwrite Overwrite user-modified managed files (backup kept)
|
|
92
102
|
-u, --upgrade, -f, --force Alias of --force-overwrite
|
|
@@ -133,7 +143,7 @@ async function main() {
|
|
|
133
143
|
await resolvePlatforms(ctx);
|
|
134
144
|
if (ctx.cancelled) { lock.release(); process.exit(0); }
|
|
135
145
|
|
|
136
|
-
// Version check: same →
|
|
146
|
+
// Version check: same → selective refresh, downgrade → confirm, upgrade → picker
|
|
137
147
|
await checkVersions(ctx);
|
|
138
148
|
if (ctx.cancelled) { lock.release(); process.exit(0); }
|
|
139
149
|
|
|
@@ -158,8 +168,10 @@ async function main() {
|
|
|
158
168
|
// Capture platform folders AND the root files the pipeline mutates
|
|
159
169
|
// (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
|
|
160
170
|
if (!ctx.dryRun) {
|
|
161
|
-
const
|
|
162
|
-
|
|
171
|
+
const targets = ctx.platforms.flatMap((key) => (
|
|
172
|
+
PLATFORMS[key].backupTargets || [PLATFORMS[key].folder]
|
|
173
|
+
));
|
|
174
|
+
ctx.backupDir = backup.snapshot([...targets, '.gitignore'], ctx.runId);
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
for (const platformKey of ctx.platforms) {
|
|
@@ -169,12 +181,22 @@ async function main() {
|
|
|
169
181
|
ensureGitignore(ctx);
|
|
170
182
|
await runPostInstall(ctx);
|
|
171
183
|
await setupSkillDeps(ctx);
|
|
172
|
-
|
|
184
|
+
if (ctx.platforms.includes('claude')) {
|
|
185
|
+
await setupRtk(ctx);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Phase handlers may report recoverable-looking errors through counters
|
|
189
|
+
// instead of throwing. Treat them as transactional failure so the snapshot
|
|
190
|
+
// is restored before any success summary or backup pruning occurs.
|
|
191
|
+
if (ctx.results.errors > 0) {
|
|
192
|
+
throw new Error(`Installer reported ${ctx.results.errors} error${ctx.results.errors === 1 ? '' : 's'}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
173
195
|
printSummary(ctx);
|
|
174
196
|
|
|
175
197
|
if (!ctx.dryRun) backup.prune(3);
|
|
176
198
|
|
|
177
|
-
exitCode =
|
|
199
|
+
exitCode = 0;
|
|
178
200
|
} catch (error) {
|
|
179
201
|
const log = ctx && ctx.ui ? ctx.ui : { error: (m) => console.error(m) };
|
|
180
202
|
const t = ctx ? ctx.t : (k) => k;
|
package/bin/lib/backup.js
CHANGED
|
@@ -12,16 +12,54 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const { assertNoSymlinkPath } = require('./path-safety');
|
|
15
16
|
|
|
16
17
|
const BACKUP_ROOT = '.cafekit-backup';
|
|
18
|
+
const SNAPSHOT_METADATA = 'snapshot.json';
|
|
19
|
+
const SNAPSHOT_DATA = 'data';
|
|
20
|
+
|
|
21
|
+
function validateTarget(target) {
|
|
22
|
+
if (typeof target !== 'string' || !target || target.includes('\0')) {
|
|
23
|
+
throw new Error('Backup target must be a non-empty relative path');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Validate both separator styles so a snapshot created on one platform
|
|
27
|
+
// cannot become unsafe when restored on another.
|
|
28
|
+
const portable = target.replace(/\\/g, '/');
|
|
29
|
+
const parts = portable.split('/');
|
|
30
|
+
if (path.isAbsolute(target) || portable.startsWith('/') || parts.includes('..') || parts.includes('')) {
|
|
31
|
+
throw new Error(`Unsafe backup target: ${target}`);
|
|
32
|
+
}
|
|
33
|
+
if (portable === '.' || portable === BACKUP_ROOT || portable.startsWith(`${BACKUP_ROOT}/`)) {
|
|
34
|
+
throw new Error(`Unsafe backup target: ${target}`);
|
|
35
|
+
}
|
|
36
|
+
return parts.join(path.sep);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateRunId(runId) {
|
|
40
|
+
if (typeof runId !== 'string' || !runId || runId === '.' || runId === '..' || /[\\/]/.test(runId)) {
|
|
41
|
+
throw new Error('Backup runId must be a single path segment');
|
|
42
|
+
}
|
|
43
|
+
return runId;
|
|
44
|
+
}
|
|
17
45
|
|
|
18
46
|
/**
|
|
19
47
|
* Recursively copy a directory tree (verbatim, no transforms). Used for
|
|
20
48
|
* snapshot and restore. Skips if the source does not exist.
|
|
21
49
|
*/
|
|
22
50
|
function copyTree(src, dest) {
|
|
23
|
-
|
|
24
|
-
|
|
51
|
+
let stat;
|
|
52
|
+
try {
|
|
53
|
+
stat = fs.lstatSync(src);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') return;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (stat.isSymbolicLink()) {
|
|
59
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
60
|
+
fs.symlinkSync(fs.readlinkSync(src), dest);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
25
63
|
if (stat.isDirectory()) {
|
|
26
64
|
fs.mkdirSync(dest, { recursive: true });
|
|
27
65
|
for (const entry of fs.readdirSync(src)) {
|
|
@@ -40,42 +78,68 @@ function copyTree(src, dest) {
|
|
|
40
78
|
}
|
|
41
79
|
|
|
42
80
|
/**
|
|
43
|
-
* Snapshot
|
|
44
|
-
*
|
|
45
|
-
* null if nothing existed to back up (fresh install → no rollback target).
|
|
81
|
+
* Snapshot explicit targets (relative to cwd, e.g. ['.claude', 'CLAUDE.md']).
|
|
82
|
+
* Every target is recorded, including targets absent before the run.
|
|
46
83
|
*
|
|
47
84
|
* @param {string[]} folders
|
|
48
85
|
* @param {string} runId unique id for this run (timestamp-based)
|
|
49
86
|
*/
|
|
50
87
|
function snapshot(folders, runId) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
const targets = [...new Set(folders.map(validateTarget))];
|
|
89
|
+
const backupDir = path.join(BACKUP_ROOT, validateRunId(runId));
|
|
90
|
+
const dataDir = path.join(backupDir, SNAPSHOT_DATA);
|
|
91
|
+
assertNoSymlinkPath(BACKUP_ROOT);
|
|
55
92
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
|
|
94
|
+
const records = [];
|
|
95
|
+
for (const target of targets) {
|
|
96
|
+
assertNoSymlinkPath(target);
|
|
97
|
+
const existed = fs.existsSync(target);
|
|
98
|
+
records.push({ target, existed });
|
|
99
|
+
if (existed) copyTree(target, path.join(dataDir, target));
|
|
59
100
|
}
|
|
101
|
+
fs.writeFileSync(
|
|
102
|
+
path.join(backupDir, SNAPSHOT_METADATA),
|
|
103
|
+
`${JSON.stringify({ schemaVersion: 1, targets: records }, null, 2)}\n`,
|
|
104
|
+
'utf8'
|
|
105
|
+
);
|
|
60
106
|
|
|
61
107
|
return backupDir;
|
|
62
108
|
}
|
|
63
109
|
|
|
64
110
|
/**
|
|
65
|
-
* Restore
|
|
66
|
-
*
|
|
67
|
-
* snapshot, returning the tree to its pre-run state.
|
|
111
|
+
* Restore every explicit target to its pre-run state. Existing targets are
|
|
112
|
+
* replaced from backup; targets absent before the run are removed.
|
|
68
113
|
*
|
|
69
114
|
* @param {string} backupDir path returned by snapshot()
|
|
70
115
|
*/
|
|
71
116
|
function restore(backupDir) {
|
|
72
117
|
if (!backupDir || !fs.existsSync(backupDir)) return;
|
|
73
118
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
119
|
+
const metadataPath = path.join(backupDir, SNAPSHOT_METADATA);
|
|
120
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
|
121
|
+
if (metadata?.schemaVersion !== 1 || !Array.isArray(metadata.targets)) {
|
|
122
|
+
throw new Error(`Invalid backup metadata: ${metadataPath}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const record of metadata.targets) {
|
|
126
|
+
if (!record || typeof record.existed !== 'boolean') {
|
|
127
|
+
throw new Error(`Invalid backup target metadata: ${metadataPath}`);
|
|
128
|
+
}
|
|
129
|
+
const target = validateTarget(record.target);
|
|
130
|
+
const source = path.join(backupDir, SNAPSHOT_DATA, target);
|
|
131
|
+
let sourceExists = true;
|
|
132
|
+
try {
|
|
133
|
+
fs.lstatSync(source);
|
|
134
|
+
} 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}`);
|
|
140
|
+
}
|
|
77
141
|
fs.rmSync(target, { recursive: true, force: true });
|
|
78
|
-
copyTree(source, target);
|
|
142
|
+
if (record.existed) copyTree(source, target);
|
|
79
143
|
}
|
|
80
144
|
}
|
|
81
145
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function unquote(value) {
|
|
4
|
+
if (
|
|
5
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
6
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
7
|
+
) {
|
|
8
|
+
return value.slice(1, -1);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function foldedScalar(lines, literal) {
|
|
14
|
+
const nonBlank = lines.filter((line) => line.trim());
|
|
15
|
+
const indent = nonBlank.length
|
|
16
|
+
? Math.min(...nonBlank.map((line) => line.match(/^\s*/)[0].length))
|
|
17
|
+
: 0;
|
|
18
|
+
const normalized = lines.map((line) => line.slice(Math.min(indent, line.length)).trimEnd());
|
|
19
|
+
if (literal) return normalized.join('\n').trim();
|
|
20
|
+
return normalized.join('\n').replace(/\n(?=\S)/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseHeader(header) {
|
|
24
|
+
const frontmatter = {};
|
|
25
|
+
const lines = header.split(/\r?\n/);
|
|
26
|
+
|
|
27
|
+
for (let index = 0; index < lines.length; index++) {
|
|
28
|
+
const match = lines[index].match(/^([a-zA-Z0-9_-]+):(?:\s*(.*))?$/);
|
|
29
|
+
if (!match) continue;
|
|
30
|
+
const [, key, raw = ''] = match;
|
|
31
|
+
|
|
32
|
+
if (/^[>|][+-]?$/.test(raw)) {
|
|
33
|
+
const continuation = [];
|
|
34
|
+
while (index + 1 < lines.length && (/^\s/.test(lines[index + 1]) || !lines[index + 1])) {
|
|
35
|
+
continuation.push(lines[++index]);
|
|
36
|
+
}
|
|
37
|
+
frontmatter[key] = foldedScalar(continuation, raw.startsWith('|'));
|
|
38
|
+
} else {
|
|
39
|
+
frontmatter[key] = unquote(raw.trim());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return frontmatter;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function splitFrontmatter(content) {
|
|
47
|
+
const source = String(content);
|
|
48
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
49
|
+
if (!match) return { frontmatter: {}, body: source };
|
|
50
|
+
return {
|
|
51
|
+
frontmatter: parseHeader(match[1]),
|
|
52
|
+
body: source.slice(match[0].length)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { splitFrontmatter };
|