@haposoft/cafekit 0.9.6 → 0.10.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/bin/install.js +93 -1252
- package/bin/lib/backup.js +111 -0
- package/bin/lib/context.js +273 -0
- package/bin/lib/lock.js +86 -0
- package/bin/lib/managed-writer.js +167 -0
- package/bin/lib/manifest.js +182 -0
- package/bin/lib/ui.js +108 -0
- package/bin/phases/claude-runtime.js +132 -0
- package/bin/phases/claude-settings.js +130 -0
- package/bin/phases/copy-payload.js +220 -0
- package/bin/phases/opencode-runtime.js +38 -0
- package/bin/phases/post-install.js +141 -0
- package/bin/phases/report.js +66 -0
- package/bin/phases/root-config.js +49 -0
- package/bin/phases/select-platform.js +94 -0
- package/bin/phases/summary.js +54 -0
- package/bin/phases/write-metadata.js +60 -0
- package/package.json +5 -1
- package/src/claude/CLAUDE.md +2 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CafeKit install manifest (P1 — ownership tracking)
|
|
3
|
+
*
|
|
4
|
+
* Records every managed file the installer wrote, keyed by SHA-256 of the
|
|
5
|
+
* payload at install time. On a later run we compare three hashes — the file
|
|
6
|
+
* on disk, the hash recorded here, and the new payload — to classify each
|
|
7
|
+
* file as pristine / user-modified / user-created. This is what lets update
|
|
8
|
+
* skip unchanged files and preserve user edits instead of blindly overwriting.
|
|
9
|
+
*
|
|
10
|
+
* The manifest lives at `<platformFolder>/cafekit-manifest.json`, a sibling of
|
|
11
|
+
* the existing `cafekit.json` metadata (which stays pure version metadata).
|
|
12
|
+
*
|
|
13
|
+
* Pure module: no console output, no process control. Side effects limited to
|
|
14
|
+
* reading/writing the manifest file itself.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const MANIFEST_FILE = 'cafekit-manifest.json';
|
|
22
|
+
const SCHEMA_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* SHA-256 of a buffer or string. Returns hex digest.
|
|
26
|
+
*/
|
|
27
|
+
function sha256(data) {
|
|
28
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SHA-256 of a file on disk, or null if it does not exist / cannot be read.
|
|
33
|
+
*/
|
|
34
|
+
function hashFile(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
return sha256(fs.readFileSync(filePath));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Absolute path to a platform's manifest file.
|
|
44
|
+
*/
|
|
45
|
+
function manifestPath(platformFolder) {
|
|
46
|
+
return path.join(platformFolder, MANIFEST_FILE);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a platform manifest. Returns a normalized object even when the file is
|
|
51
|
+
* absent or corrupt, so callers never have to null-check.
|
|
52
|
+
*
|
|
53
|
+
* Shape: { schemaVersion, version, files: { <relPath>: { sha256, version } } }
|
|
54
|
+
*/
|
|
55
|
+
function read(platformFolder) {
|
|
56
|
+
const empty = { schemaVersion: SCHEMA_VERSION, version: null, files: {} };
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(manifestPath(platformFolder), 'utf8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.files !== 'object') {
|
|
61
|
+
return empty;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
schemaVersion: parsed.schemaVersion || SCHEMA_VERSION,
|
|
65
|
+
version: parsed.version || null,
|
|
66
|
+
files: parsed.files || {}
|
|
67
|
+
};
|
|
68
|
+
} catch {
|
|
69
|
+
return empty;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a tracker that accumulates installed-file records during a run and
|
|
75
|
+
* writes them back as the new baseline. relPath keys are normalized to forward
|
|
76
|
+
* slashes and are relative to the platform folder.
|
|
77
|
+
*/
|
|
78
|
+
function createTracker(platformFolder, version) {
|
|
79
|
+
const files = {};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
/**
|
|
83
|
+
* Record a managed file by its on-disk path. Hashes the file as written.
|
|
84
|
+
* Skips silently if the file is missing (e.g. dry-run).
|
|
85
|
+
*/
|
|
86
|
+
record(absPath) {
|
|
87
|
+
const hash = hashFile(absPath);
|
|
88
|
+
if (hash === null) return;
|
|
89
|
+
const rel = path.relative(platformFolder, absPath).replace(/\\/g, '/');
|
|
90
|
+
files[rel] = { sha256: hash, version };
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/** Number of files recorded so far. */
|
|
94
|
+
count() {
|
|
95
|
+
return Object.keys(files).length;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/** Recorded hash for a relPath written earlier this run, or null. */
|
|
99
|
+
recorded(rel) {
|
|
100
|
+
const key = rel.replace(/\\/g, '/');
|
|
101
|
+
return files[key] ? files[key].sha256 : null;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Write the manifest to disk. Merges onto any prior manifest so files
|
|
106
|
+
* touched by a partial/selective run are not dropped from the baseline.
|
|
107
|
+
*/
|
|
108
|
+
write() {
|
|
109
|
+
const prior = read(platformFolder);
|
|
110
|
+
const merged = { ...prior.files, ...files };
|
|
111
|
+
const out = {
|
|
112
|
+
schemaVersion: SCHEMA_VERSION,
|
|
113
|
+
version,
|
|
114
|
+
files: merged
|
|
115
|
+
};
|
|
116
|
+
const target = manifestPath(platformFolder);
|
|
117
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
118
|
+
fs.writeFileSync(target, `${JSON.stringify(out, null, 2)}\n`, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Classify a managed file against the recorded baseline + the incoming payload.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} absPath destination path on disk
|
|
127
|
+
* @param {string} platformFolder root of the platform install (.claude/.opencode)
|
|
128
|
+
* @param {object} manifest result of read(platformFolder)
|
|
129
|
+
* @param {string} payloadHash sha256 of the content about to be written
|
|
130
|
+
* @returns {{state: string, diskHash: string|null, recordedHash: string|null, changed: boolean}}
|
|
131
|
+
* state: 'absent' | 'pristine' | 'user-modified' | 'user-created'
|
|
132
|
+
* changed: whether the payload differs from what's on disk (false → skip copy)
|
|
133
|
+
*/
|
|
134
|
+
function classify(absPath, platformFolder, manifest, payloadHash) {
|
|
135
|
+
const rel = path.relative(platformFolder, absPath).replace(/\\/g, '/');
|
|
136
|
+
const recorded = manifest.files[rel];
|
|
137
|
+
const recordedHash = recorded ? recorded.sha256 : null;
|
|
138
|
+
const diskHash = hashFile(absPath);
|
|
139
|
+
|
|
140
|
+
if (diskHash === null) {
|
|
141
|
+
return { state: 'absent', diskHash: null, recordedHash, changed: true };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// File exists but we never recorded installing it → user created it.
|
|
145
|
+
if (!recordedHash) {
|
|
146
|
+
return {
|
|
147
|
+
state: 'user-created',
|
|
148
|
+
diskHash,
|
|
149
|
+
recordedHash: null,
|
|
150
|
+
changed: diskHash !== payloadHash
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// On disk matches our last install → safe to update from payload.
|
|
155
|
+
if (diskHash === recordedHash) {
|
|
156
|
+
return {
|
|
157
|
+
state: 'pristine',
|
|
158
|
+
diskHash,
|
|
159
|
+
recordedHash,
|
|
160
|
+
changed: diskHash !== payloadHash
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// On disk diverged from our baseline → user edited a managed file.
|
|
165
|
+
return {
|
|
166
|
+
state: 'user-modified',
|
|
167
|
+
diskHash,
|
|
168
|
+
recordedHash,
|
|
169
|
+
changed: diskHash !== payloadHash
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
MANIFEST_FILE,
|
|
175
|
+
SCHEMA_VERSION,
|
|
176
|
+
sha256,
|
|
177
|
+
hashFile,
|
|
178
|
+
manifestPath,
|
|
179
|
+
read,
|
|
180
|
+
createTracker,
|
|
181
|
+
classify
|
|
182
|
+
};
|
package/bin/lib/ui.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI layer for the installer.
|
|
3
|
+
*
|
|
4
|
+
* In an interactive TTY it renders the @clack/prompts experience (framed
|
|
5
|
+
* intro/outro, spinner, ◆/│/└ prompts). In a non-interactive context (piped
|
|
6
|
+
* stdin, CI, --yes) it degrades to plain console output and returns provided
|
|
7
|
+
* fallbacks for any input prompt — so spawned/CI runs never hang and the
|
|
8
|
+
* self-test fixtures keep working unchanged.
|
|
9
|
+
*
|
|
10
|
+
* All @clack/prompts usage is funneled through here and gated by `interactive`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const p = require('@clack/prompts');
|
|
14
|
+
const pc = require('picocolors');
|
|
15
|
+
|
|
16
|
+
/** Cancel sentinel returned by input prompts when the user aborts (Ctrl+C). */
|
|
17
|
+
const CANCEL = Symbol('cafekit.cancel');
|
|
18
|
+
|
|
19
|
+
/** True only when we can drive an interactive terminal. */
|
|
20
|
+
function isInteractive() {
|
|
21
|
+
return Boolean(process.stdin.isTTY)
|
|
22
|
+
&& process.env.CI !== 'true'
|
|
23
|
+
&& process.env.NON_INTERACTIVE !== 'true';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a UI logger bound to an interactive flag.
|
|
28
|
+
*
|
|
29
|
+
* Interactive methods route to clack; non-interactive ones to console so output
|
|
30
|
+
* stays greppable/plain. `detail()` is for high-frequency per-file progress: it
|
|
31
|
+
* updates the active spinner line (interactive) or prints (non-interactive).
|
|
32
|
+
*/
|
|
33
|
+
function createUI(interactive) {
|
|
34
|
+
let spinner = null;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
interactive,
|
|
38
|
+
pc,
|
|
39
|
+
|
|
40
|
+
intro(msg) {
|
|
41
|
+
if (interactive) p.intro(msg);
|
|
42
|
+
else console.log(`\n${msg}\n`);
|
|
43
|
+
},
|
|
44
|
+
outro(msg) {
|
|
45
|
+
if (interactive) p.outro(msg);
|
|
46
|
+
else console.log(`\n${msg}`);
|
|
47
|
+
},
|
|
48
|
+
note(msg, title) {
|
|
49
|
+
if (interactive) p.note(msg, title);
|
|
50
|
+
else console.log(`\n${title ? `${title}\n` : ''}${msg}`);
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
startSpinner(msg) {
|
|
54
|
+
if (interactive) {
|
|
55
|
+
spinner = p.spinner();
|
|
56
|
+
spinner.start(msg);
|
|
57
|
+
} else {
|
|
58
|
+
console.log(msg);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
stopSpinner(msg) {
|
|
62
|
+
if (interactive && spinner) {
|
|
63
|
+
spinner.stop(msg);
|
|
64
|
+
spinner = null;
|
|
65
|
+
} else if (msg) {
|
|
66
|
+
console.log(msg);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
/** Ephemeral per-file progress (does not scroll the screen when interactive). */
|
|
70
|
+
detail(msg) {
|
|
71
|
+
if (interactive) {
|
|
72
|
+
if (spinner) spinner.message(msg.replace(/^\s+/, ''));
|
|
73
|
+
} else {
|
|
74
|
+
console.log(msg);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
success(msg) { interactive ? p.log.success(msg) : console.log(msg); },
|
|
79
|
+
info(msg) { interactive ? p.log.info(msg) : console.log(msg); },
|
|
80
|
+
warn(msg) { interactive ? p.log.warn(msg) : console.log(msg); },
|
|
81
|
+
error(msg) { interactive ? p.log.error(msg) : console.error(msg); },
|
|
82
|
+
|
|
83
|
+
async select(opts, fallback) {
|
|
84
|
+
if (!interactive) return fallback;
|
|
85
|
+
const r = await p.select(opts);
|
|
86
|
+
return p.isCancel(r) ? CANCEL : r;
|
|
87
|
+
},
|
|
88
|
+
async multiselect(opts, fallback) {
|
|
89
|
+
if (!interactive) return fallback;
|
|
90
|
+
const r = await p.multiselect(opts);
|
|
91
|
+
return p.isCancel(r) ? CANCEL : r;
|
|
92
|
+
},
|
|
93
|
+
async text(opts, fallback = '') {
|
|
94
|
+
if (!interactive) return fallback;
|
|
95
|
+
const r = await p.text(opts);
|
|
96
|
+
return p.isCancel(r) ? CANCEL : r;
|
|
97
|
+
},
|
|
98
|
+
async confirm(opts, fallback = false) {
|
|
99
|
+
if (!interactive) return fallback;
|
|
100
|
+
const r = await p.confirm(opts);
|
|
101
|
+
return p.isCancel(r) ? CANCEL : r;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
isCancel(v) { return v === CANCEL; }
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { isInteractive, createUI, CANCEL };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase: Claude/runtime file installation.
|
|
3
|
+
*
|
|
4
|
+
* ROUTING.md, runtime files (hooks/status/gitignore/runtime.json), obsolete
|
|
5
|
+
* runtime cleanup, CLAUDE.md, and the rules/ tree. All copies are
|
|
6
|
+
* ownership-aware — notably CLAUDE.md and rules/ no longer force-overwrite, so
|
|
7
|
+
* user edits survive re-installs (the latent wipe is fixed).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const {
|
|
13
|
+
PLATFORMS,
|
|
14
|
+
isClaudeCompatibleRuntime,
|
|
15
|
+
getRuntimeSupportTargetDir,
|
|
16
|
+
getCopyOptions
|
|
17
|
+
} = require('../lib/context');
|
|
18
|
+
const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
|
|
19
|
+
const { report, treeAction } = require('./report');
|
|
20
|
+
const { normalizeOpenCodeBody } = require('../lib/opencode-install');
|
|
21
|
+
|
|
22
|
+
const SRC = path.join(__dirname, '../../src');
|
|
23
|
+
|
|
24
|
+
/** Copy ROUTING.md (SKILLS_DIR substituted; OpenCode body normalized). */
|
|
25
|
+
function copyRoutingFile(ctx, platformKey) {
|
|
26
|
+
const platform = PLATFORMS[platformKey];
|
|
27
|
+
const src = path.join(SRC, platform.sourceDir, 'ROUTING.md');
|
|
28
|
+
const dest = path.join(platform.folder, 'ROUTING.md');
|
|
29
|
+
if (!fs.existsSync(src)) return;
|
|
30
|
+
|
|
31
|
+
const transform = (content) => {
|
|
32
|
+
let c = content.replace(/\{\{SKILLS_DIR\}\}/g, platform.skillsRef);
|
|
33
|
+
if (platformKey === 'opencode') c = normalizeOpenCodeBody(c);
|
|
34
|
+
return c;
|
|
35
|
+
};
|
|
36
|
+
const { action } = writeManagedFile({
|
|
37
|
+
src, dest, platformFolder: platform.folder, ctx, tracker: ctx.trackers[platformKey], transform
|
|
38
|
+
});
|
|
39
|
+
report(ctx, action, 'ROUTING.md');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Copy Claude runtime files declared in the migration manifest. Claude only. */
|
|
43
|
+
function copyClaudeRuntimeFiles(ctx, platformKey) {
|
|
44
|
+
if (platformKey !== 'claude') return;
|
|
45
|
+
const manifest = ctx.manifest;
|
|
46
|
+
if (!manifest?.runtime?.files) return;
|
|
47
|
+
|
|
48
|
+
const srcBase = path.join(SRC, 'claude');
|
|
49
|
+
const targetBase = PLATFORMS.claude.folder;
|
|
50
|
+
const tracker = ctx.trackers.claude;
|
|
51
|
+
|
|
52
|
+
manifest.runtime.files.forEach((relPath) => {
|
|
53
|
+
const srcPath = path.join(srcBase, relPath);
|
|
54
|
+
const targetRelPath = relPath === 'gitignore' ? '.gitignore' : relPath;
|
|
55
|
+
const targetPath = path.join(targetBase, targetRelPath);
|
|
56
|
+
|
|
57
|
+
if (!fs.existsSync(srcPath)) {
|
|
58
|
+
ctx.ui.warn(`Runtime file not found: ${relPath}`);
|
|
59
|
+
ctx.results.missingDependencies++;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { action } = writeManagedFile({
|
|
64
|
+
src: srcPath, dest: targetPath, platformFolder: targetBase, ctx, tracker
|
|
65
|
+
});
|
|
66
|
+
report(ctx, action, `Runtime: ${targetRelPath}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Delete runtime files marked obsolete by the manifest. Claude only. */
|
|
71
|
+
function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
|
|
72
|
+
if (platformKey !== 'claude') return;
|
|
73
|
+
|
|
74
|
+
const obsoleteFiles = ctx.manifest?.obsolete?.runtimeFiles || [];
|
|
75
|
+
const targetBase = PLATFORMS.claude.folder;
|
|
76
|
+
|
|
77
|
+
obsoleteFiles.forEach((relPath) => {
|
|
78
|
+
const targetPath = path.join(targetBase, relPath);
|
|
79
|
+
if (!fs.existsSync(targetPath)) return;
|
|
80
|
+
|
|
81
|
+
if (!ctx.dryRun) fs.rmSync(targetPath, { force: true });
|
|
82
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete runtime: ${relPath}`);
|
|
83
|
+
ctx.results.updated++;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Install CLAUDE.md at the project root (ownership-aware). Claude only. */
|
|
88
|
+
function copyClaudeMdFile(ctx, platformKey) {
|
|
89
|
+
if (platformKey !== 'claude') return;
|
|
90
|
+
|
|
91
|
+
const src = path.join(SRC, 'claude/CLAUDE.md');
|
|
92
|
+
const dest = 'CLAUDE.md'; // project root
|
|
93
|
+
if (!fs.existsSync(src)) {
|
|
94
|
+
ctx.ui.warn('CLAUDE.md template not found');
|
|
95
|
+
ctx.results.missingDependencies++;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { action } = writeManagedFile({
|
|
100
|
+
src, dest, platformFolder: PLATFORMS.claude.folder, ctx, tracker: ctx.trackers.claude
|
|
101
|
+
});
|
|
102
|
+
report(ctx, action, 'CLAUDE.md');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
|
|
106
|
+
function copyRulesDirectory(ctx, platformKey) {
|
|
107
|
+
if (!isClaudeCompatibleRuntime(platformKey)) return;
|
|
108
|
+
|
|
109
|
+
const src = path.join(SRC, 'claude/rules');
|
|
110
|
+
const dest = getRuntimeSupportTargetDir(platformKey, 'rules');
|
|
111
|
+
if (!fs.existsSync(src)) {
|
|
112
|
+
ctx.ui.warn('rules/ directory not found');
|
|
113
|
+
ctx.results.missingDependencies++;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const transform = platformKey === 'opencode'
|
|
118
|
+
? getCopyOptions('opencode', {}).transform
|
|
119
|
+
: undefined;
|
|
120
|
+
const agg = copyManagedTree({
|
|
121
|
+
src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
|
|
122
|
+
});
|
|
123
|
+
report(ctx, treeAction(agg), 'rules/ directory');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
copyRoutingFile,
|
|
128
|
+
copyClaudeRuntimeFiles,
|
|
129
|
+
removeObsoleteClaudeRuntimeFiles,
|
|
130
|
+
copyClaudeMdFile,
|
|
131
|
+
copyRulesDirectory
|
|
132
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase helper: Claude settings.json merge + obsolete-hook pruning.
|
|
3
|
+
*
|
|
4
|
+
* settings.json is merged (not a verbatim payload copy), so it stays outside
|
|
5
|
+
* the ownership model: managed statusLine/hooks are merged into the user's
|
|
6
|
+
* existing settings, and obsolete hook registrations are pruned. Honors dry-run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { PLATFORMS } = require('../lib/context');
|
|
12
|
+
|
|
13
|
+
const SRC_CLAUDE = path.join(__dirname, '../../src/claude');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Remove obsolete hook entries from a settings object whose command matches any
|
|
17
|
+
* manifest-listed substring (e.g. a retired router hook). Returns a possibly
|
|
18
|
+
* new settings object.
|
|
19
|
+
*/
|
|
20
|
+
function pruneObsoleteSettingsHooks(settings, ctx) {
|
|
21
|
+
const obsoleteSubstrings = ctx.manifest?.obsolete?.settingsHookCommandSubstrings || [];
|
|
22
|
+
|
|
23
|
+
if (!settings.hooks || obsoleteSubstrings.length === 0) {
|
|
24
|
+
return settings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let removedCount = 0;
|
|
28
|
+
const prunedHooks = {};
|
|
29
|
+
|
|
30
|
+
Object.entries(settings.hooks).forEach(([eventName, matcherEntries]) => {
|
|
31
|
+
if (!Array.isArray(matcherEntries)) {
|
|
32
|
+
prunedHooks[eventName] = matcherEntries;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const prunedEntries = [];
|
|
37
|
+
matcherEntries.forEach((entry) => {
|
|
38
|
+
if (!Array.isArray(entry?.hooks)) {
|
|
39
|
+
prunedEntries.push(entry);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const remainingHooks = entry.hooks.filter((hook) => {
|
|
43
|
+
const command = hook?.command || '';
|
|
44
|
+
const isObsolete = obsoleteSubstrings.some((substring) => command.includes(substring));
|
|
45
|
+
if (isObsolete) removedCount++;
|
|
46
|
+
return !isObsolete;
|
|
47
|
+
});
|
|
48
|
+
if (remainingHooks.length > 0) {
|
|
49
|
+
prunedEntries.push({ ...entry, hooks: remainingHooks });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (prunedEntries.length > 0) {
|
|
54
|
+
prunedHooks[eventName] = prunedEntries;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (removedCount > 0) {
|
|
59
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: removed ${removedCount} obsolete hook(s)`);
|
|
60
|
+
ctx.results.updated++;
|
|
61
|
+
return { ...settings, hooks: prunedHooks };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return settings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Merge CafeKit-managed statusLine + hooks into .claude/settings.json,
|
|
69
|
+
* preserving user-owned entries. Claude only.
|
|
70
|
+
*/
|
|
71
|
+
function mergeClaudeSettings(ctx, platformKey) {
|
|
72
|
+
if (platformKey !== 'claude') return;
|
|
73
|
+
|
|
74
|
+
const manifest = ctx.manifest;
|
|
75
|
+
if (!manifest?.settings?.template) return;
|
|
76
|
+
|
|
77
|
+
const templatePath = path.join(SRC_CLAUDE, manifest.settings.template);
|
|
78
|
+
const targetPath = path.join(PLATFORMS.claude.folder, 'settings.json');
|
|
79
|
+
|
|
80
|
+
if (!fs.existsSync(templatePath)) {
|
|
81
|
+
ctx.ui.warn(`Settings template not found: ${manifest.settings.template}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const managedSettings = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
|
|
86
|
+
let existingSettings = {};
|
|
87
|
+
if (fs.existsSync(targetPath)) {
|
|
88
|
+
existingSettings = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mergedSettings = pruneObsoleteSettingsHooks({ ...existingSettings }, ctx);
|
|
92
|
+
|
|
93
|
+
// statusLine: adopt managed value if upgrading, missing, or CafeKit-owned.
|
|
94
|
+
if (managedSettings.statusLine) {
|
|
95
|
+
const existingCommand = existingSettings.statusLine?.command || '';
|
|
96
|
+
const isCafeKitOwned = existingCommand.includes('status.cjs') || existingCommand.includes('statusline.cjs');
|
|
97
|
+
if (ctx.options.forceOverwrite || !existingSettings.statusLine || isCafeKitOwned) {
|
|
98
|
+
mergedSettings.statusLine = managedSettings.statusLine;
|
|
99
|
+
ctx.ui.detail(` ✓ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: statusLine merged`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// hooks: append managed hooks that aren't already present (dedupe by command).
|
|
104
|
+
if (managedSettings.hooks) {
|
|
105
|
+
mergedSettings.hooks = mergedSettings.hooks || {};
|
|
106
|
+
Object.keys(managedSettings.hooks).forEach((eventName) => {
|
|
107
|
+
const managedHooks = managedSettings.hooks[eventName];
|
|
108
|
+
const existingHooks = mergedSettings.hooks[eventName] || [];
|
|
109
|
+
const mergedHooks = [...existingHooks];
|
|
110
|
+
|
|
111
|
+
managedHooks.forEach((managedHook) => {
|
|
112
|
+
const managedCommand = managedHook.hooks?.[0]?.command || '';
|
|
113
|
+
const isDuplicate = mergedHooks.some((existingHook) =>
|
|
114
|
+
existingHook.hooks?.some((h) => h.command === managedCommand));
|
|
115
|
+
if (!isDuplicate) {
|
|
116
|
+
mergedHooks.push(managedHook);
|
|
117
|
+
ctx.ui.detail(` ✓ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook ${eventName} merged`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
mergedSettings.hooks[eventName] = mergedHooks;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!ctx.dryRun) {
|
|
126
|
+
fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { pruneObsoleteSettingsHooks, mergeClaudeSettings };
|