@khanhcan148/mk 0.1.16 → 0.1.18
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 +4 -2
- package/package.json +1 -1
- package/src/commands/init.js +13 -7
- package/src/commands/update.js +455 -436
- package/src/lib/auth.js +7 -1
- package/src/lib/checksum.js +21 -6
- package/src/lib/concurrency.js +36 -0
- package/src/lib/constants.js +8 -0
- package/src/lib/copy.js +335 -331
package/src/lib/copy.js
CHANGED
|
@@ -1,331 +1,335 @@
|
|
|
1
|
-
import { join, relative } from 'node:path';
|
|
2
|
-
import { statSync, lstatSync, existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
3
|
-
import fsExtra from 'fs-extra';
|
|
4
|
-
import { KIT_SUBDIRS, COPY_FILTER_PATTERNS, WINDOWS_PATH_WARN_LENGTH } from './constants.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Check if a path should be filtered out during copy.
|
|
8
|
-
* @param {string} filePath
|
|
9
|
-
* @returns {boolean} true if the file should be excluded
|
|
10
|
-
*/
|
|
11
|
-
function shouldFilter(filePath) {
|
|
12
|
-
const normalized = filePath.replace(/\\/g, '/');
|
|
13
|
-
for (const pattern of COPY_FILTER_PATTERNS) {
|
|
14
|
-
if (normalized.includes(pattern)) return true;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* @
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { statSync, lstatSync, existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
3
|
+
import fsExtra from 'fs-extra';
|
|
4
|
+
import { KIT_SUBDIRS, COPY_FILTER_PATTERNS, KIT_INTERNAL_SKILLS, WINDOWS_PATH_WARN_LENGTH } from './constants.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if a path should be filtered out during copy.
|
|
8
|
+
* @param {string} filePath
|
|
9
|
+
* @returns {boolean} true if the file should be excluded
|
|
10
|
+
*/
|
|
11
|
+
function shouldFilter(filePath) {
|
|
12
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
13
|
+
for (const pattern of COPY_FILTER_PATTERNS) {
|
|
14
|
+
if (normalized.includes(pattern)) return true;
|
|
15
|
+
}
|
|
16
|
+
// Exclude internal skills — see KIT_INTERNAL_SKILLS JSDoc in constants.js.
|
|
17
|
+
for (const skillName of KIT_INTERNAL_SKILLS) {
|
|
18
|
+
if (normalized.includes(`/skills/${skillName}/`)) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collect all file paths recursively in a directory.
|
|
25
|
+
* @param {string} dir
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
function collectFiles(dir) {
|
|
29
|
+
const results = [];
|
|
30
|
+
function walk(current) {
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = fsExtra.readdirSync(current);
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const fullPath = join(current, entry);
|
|
39
|
+
try {
|
|
40
|
+
// Use lstatSync (does not follow symlinks) to detect and skip symlinks.
|
|
41
|
+
// Symlinks inside the bundled .claude/ could otherwise redirect writes
|
|
42
|
+
// to arbitrary locations on the user's filesystem.
|
|
43
|
+
const lstat = lstatSync(fullPath);
|
|
44
|
+
if (lstat.isSymbolicLink()) continue;
|
|
45
|
+
if (lstat.isDirectory()) {
|
|
46
|
+
walk(fullPath);
|
|
47
|
+
} else {
|
|
48
|
+
results.push(fullPath);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// skip inaccessible files
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
walk(dir);
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Collect all kit-managed files currently on disk under targetDir (.claude/).
|
|
61
|
+
* Only walks KIT_SUBDIRS (agents/, skills/, workflows/).
|
|
62
|
+
* Returns relative paths in the form `.claude/agents/foo.md`.
|
|
63
|
+
* Applies shouldFilter to exclude filtered patterns.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} targetDir - Absolute path to target .claude/
|
|
66
|
+
* @returns {string[]} relative paths (relative to project root, e.g. `.claude/agents/foo.md`)
|
|
67
|
+
*/
|
|
68
|
+
export function collectDiskFiles(targetDir) {
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const subdir of KIT_SUBDIRS) {
|
|
71
|
+
const subdirAbs = join(targetDir, subdir);
|
|
72
|
+
if (!existsSync(subdirAbs)) continue;
|
|
73
|
+
const files = collectFiles(subdirAbs);
|
|
74
|
+
for (const absPath of files) {
|
|
75
|
+
if (shouldFilter(absPath)) continue;
|
|
76
|
+
const relFromSubdir = relative(subdirAbs, absPath);
|
|
77
|
+
const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
|
|
78
|
+
results.push(relPath);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Copy kit files from sourceDir (.claude/) to targetDir (.claude/).
|
|
86
|
+
* Only copies KIT_SUBDIRS (agents/, skills/, workflows/).
|
|
87
|
+
*
|
|
88
|
+
* @param {string} sourceDir - Absolute path to source .claude/
|
|
89
|
+
* @param {string} targetDir - Absolute path to target .claude/
|
|
90
|
+
* @param {{ dryRun: boolean }} options
|
|
91
|
+
* @returns {Array<{ relativePath: string, absolutePath: string, sourceAbsPath: string, size: number }>}
|
|
92
|
+
* @remarks Naming convention: `absolutePath` is the destination (under targetDir),
|
|
93
|
+
* `sourceAbsPath` is the source (under sourceDir). The asymmetry is intentional —
|
|
94
|
+
* renaming would break consumers (update.js). See DEBT-016.
|
|
95
|
+
*/
|
|
96
|
+
export function copyKitFiles(sourceDir, targetDir, options = {}) {
|
|
97
|
+
const { dryRun = false } = options;
|
|
98
|
+
const fileList = [];
|
|
99
|
+
const warnings = [];
|
|
100
|
+
|
|
101
|
+
for (const subdir of KIT_SUBDIRS) {
|
|
102
|
+
const srcSubdir = join(sourceDir, subdir);
|
|
103
|
+
if (!existsSync(srcSubdir)) continue;
|
|
104
|
+
|
|
105
|
+
// Collect files in this subdir
|
|
106
|
+
const files = collectFiles(srcSubdir);
|
|
107
|
+
|
|
108
|
+
for (const absPath of files) {
|
|
109
|
+
if (shouldFilter(absPath)) continue;
|
|
110
|
+
|
|
111
|
+
const relFromSubdir = relative(srcSubdir, absPath);
|
|
112
|
+
const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
|
|
113
|
+
const destAbs = join(targetDir, subdir, relFromSubdir);
|
|
114
|
+
|
|
115
|
+
// Windows path length check
|
|
116
|
+
if (destAbs.length > WINDOWS_PATH_WARN_LENGTH) {
|
|
117
|
+
warnings.push(`Warning: Long path (${destAbs.length} chars): ${destAbs}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let size = 0;
|
|
121
|
+
try {
|
|
122
|
+
size = statSync(absPath).size;
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fileList.push({ relativePath: relPath, absolutePath: destAbs, sourceAbsPath: absPath, size });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Print warnings
|
|
132
|
+
for (const w of warnings) {
|
|
133
|
+
process.stderr.write(w + '\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (dryRun) {
|
|
137
|
+
return fileList;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Actually copy each subdir using fs-extra
|
|
141
|
+
for (const subdir of KIT_SUBDIRS) {
|
|
142
|
+
const srcSubdir = join(sourceDir, subdir);
|
|
143
|
+
if (!existsSync(srcSubdir)) continue;
|
|
144
|
+
const destSubdir = join(targetDir, subdir);
|
|
145
|
+
|
|
146
|
+
fsExtra.copySync(srcSubdir, destSubdir, {
|
|
147
|
+
overwrite: true,
|
|
148
|
+
filter: (src) => {
|
|
149
|
+
if (shouldFilter(src)) return false;
|
|
150
|
+
try {
|
|
151
|
+
// Skip symlinks — prevents traversal to paths outside destSubdir
|
|
152
|
+
if (lstatSync(src).isSymbolicLink()) return false;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return fileList;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Create a timestamped backup of a file.
|
|
166
|
+
* @param {string} filePath - Absolute path to the file to back up
|
|
167
|
+
* @returns {string|null} Backup path, or null if source doesn't exist
|
|
168
|
+
*/
|
|
169
|
+
function backupFile(filePath) {
|
|
170
|
+
if (!existsSync(filePath)) return null;
|
|
171
|
+
const now = new Date();
|
|
172
|
+
const ts = [
|
|
173
|
+
now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'),
|
|
174
|
+
String(now.getDate()).padStart(2, '0'), '-',
|
|
175
|
+
String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0')
|
|
176
|
+
].join('');
|
|
177
|
+
const backupPath = `${filePath}.${ts}.bak`;
|
|
178
|
+
copyFileSync(filePath, backupPath);
|
|
179
|
+
return backupPath;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Returns true if siblingHooks already has an entry for the given event+matcher.
|
|
184
|
+
* @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
|
|
185
|
+
* @param {string} event - Hook event name (e.g. 'PostToolUse')
|
|
186
|
+
* @param {string} matcher - Hook matcher (e.g. '*', 'Task')
|
|
187
|
+
*/
|
|
188
|
+
function isInSibling(siblingHooks, event, matcher) {
|
|
189
|
+
return !!siblingHooks?.[event]?.some(s => (s.matcher || '*') === matcher);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Filter kitEntries removing any whose event+matcher already exists in siblingHooks.
|
|
194
|
+
* Emits a stderr info message per skipped entry.
|
|
195
|
+
* @param {object[]} kitEntries - Hook entries from kit source
|
|
196
|
+
* @param {object|null} siblingHooks - Parsed hooks from settings.local.json, or null
|
|
197
|
+
* @param {string} event - Hook event name
|
|
198
|
+
* @returns {object[]} Entries not present in sibling
|
|
199
|
+
*/
|
|
200
|
+
function dedupeAgainstSibling(kitEntries, siblingHooks, event) {
|
|
201
|
+
if (!siblingHooks?.[event]) return kitEntries;
|
|
202
|
+
return kitEntries.filter(ke => {
|
|
203
|
+
const m = ke.matcher || '*';
|
|
204
|
+
if (isInSibling(siblingHooks, event, m)) {
|
|
205
|
+
process.stderr.write(`mk: hook ${event}[${m}] skipped (exists in settings.local.json)\n`);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Merge kit's settings.json into user's existing settings.json.
|
|
214
|
+
* Strategy: merge "hooks" key from kit source; preserve all other user keys.
|
|
215
|
+
* When a matcher already exists, the kit's hooks REPLACE the user's hooks for
|
|
216
|
+
* that matcher — this ensures updated hook commands propagate on update instead
|
|
217
|
+
* of accumulating stale duplicates.
|
|
218
|
+
* If user has no settings.json, create one with hooks only.
|
|
219
|
+
* If kit source has no settings.json, do nothing.
|
|
220
|
+
* A timestamped backup is created before any write to an existing file.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} sourceDir - Absolute path to source .claude/
|
|
223
|
+
* @param {string} targetDir - Absolute path to target .claude/
|
|
224
|
+
* @param {{ dryRun: boolean }} options
|
|
225
|
+
* @returns {{ action: 'created'|'merged'|'skipped', merged?: string[], backup?: string }}
|
|
226
|
+
*/
|
|
227
|
+
export function mergeSettingsJson(sourceDir, targetDir, options = {}) {
|
|
228
|
+
const { dryRun = false } = options;
|
|
229
|
+
const srcPath = join(sourceDir, 'settings.json');
|
|
230
|
+
const destPath = join(targetDir, 'settings.json');
|
|
231
|
+
|
|
232
|
+
if (!existsSync(srcPath)) return { action: 'skipped' };
|
|
233
|
+
|
|
234
|
+
let kitSettings;
|
|
235
|
+
try {
|
|
236
|
+
kitSettings = JSON.parse(readFileSync(srcPath, 'utf-8'));
|
|
237
|
+
} catch {
|
|
238
|
+
return { action: 'skipped' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Read settings.local.json once for sibling dedup guard.
|
|
242
|
+
// Applies to both the 'created' and 'merged' paths — Claude Code loads hooks from
|
|
243
|
+
// all settings files simultaneously; if a user already has a hook in settings.local.json,
|
|
244
|
+
// writing the same event+matcher to settings.json would cause both to fire.
|
|
245
|
+
let siblingHooks = null;
|
|
246
|
+
try {
|
|
247
|
+
const siblingPath = join(targetDir, 'settings.local.json');
|
|
248
|
+
siblingHooks = JSON.parse(readFileSync(siblingPath, 'utf-8')).hooks || null;
|
|
249
|
+
} catch {
|
|
250
|
+
// Missing or malformed sibling — proceed as if no sibling exists.
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// No existing user settings — create with hooks only (not permissions or other keys).
|
|
254
|
+
// Copying the full kit settings.json would duplicate permissions.deny entries when both
|
|
255
|
+
// global (~/.claude/settings.json) and project (.claude/settings.json) are initialised.
|
|
256
|
+
if (!existsSync(destPath)) {
|
|
257
|
+
if (!dryRun) {
|
|
258
|
+
const hooksOnly = {};
|
|
259
|
+
if (kitSettings.hooks) {
|
|
260
|
+
for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
|
|
261
|
+
const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
|
|
262
|
+
if (deduped.length > 0) hooksOnly[event] = deduped;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
mkdirSync(targetDir, { recursive: true });
|
|
266
|
+
const payload = Object.keys(hooksOnly).length > 0 ? { hooks: hooksOnly } : {};
|
|
267
|
+
writeFileSync(destPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
268
|
+
}
|
|
269
|
+
return { action: 'created' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Existing user settings — merge hooks only, preserve everything else
|
|
273
|
+
let userSettings;
|
|
274
|
+
try {
|
|
275
|
+
userSettings = JSON.parse(readFileSync(destPath, 'utf-8'));
|
|
276
|
+
} catch {
|
|
277
|
+
// Malformed user settings — skip to avoid data loss
|
|
278
|
+
return { action: 'skipped' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const merged = [];
|
|
282
|
+
|
|
283
|
+
// Merge hooks: kit entries are added or replaced by matcher; user-only matchers preserved.
|
|
284
|
+
// Replace strategy: when the kit ships an updated hook command for an existing matcher,
|
|
285
|
+
// the old command is replaced instead of appended — preventing stale duplicates.
|
|
286
|
+
if (kitSettings.hooks) {
|
|
287
|
+
if (!userSettings.hooks) userSettings.hooks = {};
|
|
288
|
+
for (const [event, kitEntries] of Object.entries(kitSettings.hooks)) {
|
|
289
|
+
if (!userSettings.hooks[event]) {
|
|
290
|
+
const deduped = dedupeAgainstSibling(kitEntries, siblingHooks, event);
|
|
291
|
+
if (deduped.length > 0) {
|
|
292
|
+
userSettings.hooks[event] = deduped;
|
|
293
|
+
merged.push(event);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
for (const kitEntry of kitEntries) {
|
|
297
|
+
const kitMatcher = kitEntry.matcher || '*';
|
|
298
|
+
// Dedup guard: skip if sibling already has this event+matcher
|
|
299
|
+
if (isInSibling(siblingHooks, event, kitMatcher)) {
|
|
300
|
+
process.stderr.write(`mk: hook ${event}[${kitMatcher}] skipped (exists in settings.local.json)\n`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const idx = userSettings.hooks[event].findIndex(
|
|
304
|
+
e => (e.matcher || '*') === kitMatcher
|
|
305
|
+
);
|
|
306
|
+
if (idx === -1) {
|
|
307
|
+
// New matcher — add it
|
|
308
|
+
userSettings.hooks[event].push(kitEntry);
|
|
309
|
+
merged.push(`${event}[${kitMatcher}]:added`);
|
|
310
|
+
} else {
|
|
311
|
+
// Existing matcher — replace with kit version if hooks differ
|
|
312
|
+
const userEntry = userSettings.hooks[event][idx];
|
|
313
|
+
const userCmds = (userEntry.hooks || []).map(h => h.command).sort();
|
|
314
|
+
const kitCmds = (kitEntry.hooks || []).map(h => h.command).sort();
|
|
315
|
+
const same = userCmds.length === kitCmds.length &&
|
|
316
|
+
userCmds.every((c, i) => c === kitCmds[i]);
|
|
317
|
+
if (!same) {
|
|
318
|
+
userSettings.hooks[event][idx] = kitEntry;
|
|
319
|
+
merged.push(`${event}[${kitMatcher}]:replaced`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (merged.length === 0) return { action: 'skipped' };
|
|
328
|
+
|
|
329
|
+
if (!dryRun) {
|
|
330
|
+
const backup = backupFile(destPath);
|
|
331
|
+
writeFileSync(destPath, JSON.stringify(userSettings, null, 2) + '\n', 'utf-8');
|
|
332
|
+
return { action: 'merged', merged, backup };
|
|
333
|
+
}
|
|
334
|
+
return { action: 'merged', merged };
|
|
335
|
+
}
|