@khanhcan148/mk 0.1.14 → 0.1.16
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 +5 -4
- package/package.json +1 -1
- package/src/commands/update.js +436 -397
- package/src/lib/auth.js +3 -1
- package/src/lib/copy.js +331 -271
- package/src/lib/download.js +32 -3
- package/src/lib/releases.js +9 -0
package/src/commands/update.js
CHANGED
|
@@ -1,397 +1,436 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import { createInterface } from 'node:readline';
|
|
3
|
-
import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync, rmdirSync } from 'node:fs';
|
|
4
|
-
import { join, dirname, resolve, sep } from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { readManifest, updateManifest, diffManifest } from '../lib/manifest.js';
|
|
7
|
-
import { computeChecksum } from '../lib/checksum.js';
|
|
8
|
-
import { copyKitFiles, collectDiskFiles, mergeSettingsJson } from '../lib/copy.js';
|
|
9
|
-
import { resolveSourceDir, resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePath } from '../lib/paths.js';
|
|
10
|
-
import { resolveTokenOrLogin } from '../lib/auth.js';
|
|
11
|
-
import { writeToken, readStoredToken } from '../lib/config.js';
|
|
12
|
-
import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
|
|
13
|
-
import { fetchLatestRelease, compareVersions } from '../lib/releases.js';
|
|
14
|
-
import { isEmptyDir } from '../lib/fs-utils.js';
|
|
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
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
const
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync, rmdirSync } from 'node:fs';
|
|
4
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { readManifest, updateManifest, diffManifest } from '../lib/manifest.js';
|
|
7
|
+
import { computeChecksum } from '../lib/checksum.js';
|
|
8
|
+
import { copyKitFiles, collectDiskFiles, mergeSettingsJson } from '../lib/copy.js';
|
|
9
|
+
import { resolveSourceDir, resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePath } from '../lib/paths.js';
|
|
10
|
+
import { resolveTokenOrLogin } from '../lib/auth.js';
|
|
11
|
+
import { writeToken, readStoredToken } from '../lib/config.js';
|
|
12
|
+
import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
|
|
13
|
+
import { fetchLatestRelease, compareVersions } from '../lib/releases.js';
|
|
14
|
+
import { isEmptyDir } from '../lib/fs-utils.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Security: strip terminal escape sequences from untrusted content
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strip terminal escape sequences from a string to prevent terminal injection
|
|
22
|
+
* when printing content sourced from the GitHub API (e.g. release notes).
|
|
23
|
+
*
|
|
24
|
+
* Removes:
|
|
25
|
+
* - CSI sequences: ESC [ ... <letter> (e.g. color codes, cursor movement, screen clear)
|
|
26
|
+
* - OSC sequences: ESC ] ... BEL/ST (e.g. window title manipulation)
|
|
27
|
+
* - Fe two-character sequences: ESC <char> (e.g. ESC c = RIS terminal reset, ESC P = DCS)
|
|
28
|
+
* - Raw C0 control characters (0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f) excluding
|
|
29
|
+
* printable whitespace (\t, \n, \r which are 0x09, 0x0a, 0x0d)
|
|
30
|
+
*
|
|
31
|
+
* @param {string} str
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function stripTerminalEscapes(str) {
|
|
35
|
+
return str
|
|
36
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
37
|
+
.replace(/\x1b\].*?(\x07|\x1b\\)/gs, '') // OSC sequences (dotAll for multiline)
|
|
38
|
+
.replace(/\x1b[^[\]]/g, '') // Fe two-char sequences (ESC c, ESC P, etc.)
|
|
39
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // raw control chars (preserve \t \n \r)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Prompt helper
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prompt the user with a yes/no question and return true for yes, false for no.
|
|
48
|
+
* Reads from stdin; defaults to "no" on empty/non-y input.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} question
|
|
51
|
+
* @returns {Promise<boolean>}
|
|
52
|
+
*/
|
|
53
|
+
async function defaultPromptUser(question) {
|
|
54
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
rl.question(question, (answer) => {
|
|
57
|
+
rl.close();
|
|
58
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run the update command.
|
|
65
|
+
*
|
|
66
|
+
* @param {{
|
|
67
|
+
* sourceDir?: string,
|
|
68
|
+
* targetDir?: string,
|
|
69
|
+
* manifestPath?: string,
|
|
70
|
+
* force?: boolean,
|
|
71
|
+
* version?: string - Kit version to store in manifest (defaults to pkg.version when omitted)
|
|
72
|
+
* }} params
|
|
73
|
+
* @returns {Promise<{ updated: string[], added: string[], removed: string[], conflicts: string[], unchanged: string[], upToDate: boolean }>}
|
|
74
|
+
*/
|
|
75
|
+
export async function runUpdate(params = {}) {
|
|
76
|
+
const {
|
|
77
|
+
sourceDir = resolveSourceDir(),
|
|
78
|
+
targetDir = resolveTargetDir({ global: false }),
|
|
79
|
+
manifestPath = resolveManifestPath({ global: false }),
|
|
80
|
+
force = false,
|
|
81
|
+
version: explicitVersion
|
|
82
|
+
} = params;
|
|
83
|
+
|
|
84
|
+
// Read existing manifest
|
|
85
|
+
let manifest;
|
|
86
|
+
try {
|
|
87
|
+
manifest = readManifest(manifestPath);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
throw new Error(`Not installed (no manifest found). Run 'mk init' first. (${err.message})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Derive project root from manifest scope (global: homedir, project: dirname(manifestPath))
|
|
93
|
+
const projectRoot = deriveProjectRoot(manifest, manifestPath);
|
|
94
|
+
const claudeRoot = resolve(join(projectRoot, '.claude'));
|
|
95
|
+
const sourceFileList = copyKitFiles(sourceDir, targetDir, { dryRun: true });
|
|
96
|
+
// Fix 11-12 (performance): Build a Map for O(1) lookups in applyCopy.
|
|
97
|
+
// Previously sourceFileList.find() in applyCopy was O(n) per call, causing O(n²)
|
|
98
|
+
// behaviour when many files need updating. The Map is built once in O(n).
|
|
99
|
+
const sourceFileMap = new Map(sourceFileList.map(e => [e.relativePath, e]));
|
|
100
|
+
const sourceFiles = {};
|
|
101
|
+
for (const entry of sourceFileList) {
|
|
102
|
+
const checksum = computeChecksum(entry.sourceAbsPath);
|
|
103
|
+
sourceFiles[entry.relativePath] = { checksum, size: entry.size };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Get disk checksums for files currently in manifest
|
|
107
|
+
const diskChecksums = {};
|
|
108
|
+
for (const relPath of Object.keys(manifest.files)) {
|
|
109
|
+
// relPath is like '.claude/agents/foo.md' — relative to project root
|
|
110
|
+
const absPath = join(projectRoot, relPath);
|
|
111
|
+
// Bounds check: manifest keys must not escape .claude/ subtree
|
|
112
|
+
try {
|
|
113
|
+
assertSafePath(absPath, claudeRoot, `manifest key "${relPath}"`);
|
|
114
|
+
} catch {
|
|
115
|
+
process.stderr.write(`Warning: Skipping unsafe path in manifest: ${relPath}\n`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (existsSync(absPath)) {
|
|
119
|
+
diskChecksums[relPath] = computeChecksum(absPath);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Three-way diff
|
|
124
|
+
const diff = diffManifest(manifest, sourceFiles, diskChecksums);
|
|
125
|
+
|
|
126
|
+
const upToDate =
|
|
127
|
+
diff.updated.length === 0 &&
|
|
128
|
+
diff.added.length === 0 &&
|
|
129
|
+
diff.removed.length === 0 &&
|
|
130
|
+
diff.conflicts.length === 0;
|
|
131
|
+
|
|
132
|
+
// Read package version (fileURLToPath handles Windows drive-letter prefix correctly)
|
|
133
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
|
|
134
|
+
|
|
135
|
+
if (upToDate) {
|
|
136
|
+
// Always merge settings.json hooks even when kit files are unchanged.
|
|
137
|
+
// A user who installed before hooks were added (or whose settings.json was
|
|
138
|
+
// edited/reset) would otherwise never receive hook updates via `mk update`.
|
|
139
|
+
mergeSettingsJson(sourceDir, targetDir);
|
|
140
|
+
// Files are unchanged but we may still need to record the release version.
|
|
141
|
+
// Without this, manifest.version stays at the old value and the next `mk update`
|
|
142
|
+
// will always report "Update available" even though nothing changed on disk.
|
|
143
|
+
if (explicitVersion && explicitVersion !== manifest.version) {
|
|
144
|
+
updateManifest(manifestPath, manifest.files, explicitVersion);
|
|
145
|
+
}
|
|
146
|
+
return { ...diff, upToDate: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const newFiles = { ...manifest.files };
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Copy a source file entry to its destination.
|
|
153
|
+
* Fix 11-12: Uses O(1) Map lookup instead of O(n) Array.find to eliminate O(n²) worst case.
|
|
154
|
+
* @param {string} relPath
|
|
155
|
+
*/
|
|
156
|
+
function applyCopy(relPath) {
|
|
157
|
+
const entry = sourceFileMap.get(relPath);
|
|
158
|
+
if (!entry) return;
|
|
159
|
+
const destAbs = join(projectRoot, relPath);
|
|
160
|
+
// Bounds check: destination must stay inside .claude/ subtree
|
|
161
|
+
assertSafePath(destAbs, claudeRoot, `copy destination for "${relPath}"`);
|
|
162
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
163
|
+
copyFileSync(entry.sourceAbsPath, destAbs);
|
|
164
|
+
// Reuse source checksum — copy is bit-for-bit identical, no need to re-hash
|
|
165
|
+
newFiles[relPath] = { checksum: sourceFiles[relPath].checksum, size: entry.size };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Apply safe updates
|
|
169
|
+
for (const relPath of diff.updated) {
|
|
170
|
+
applyCopy(relPath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle conflicts
|
|
174
|
+
const skippedConflicts = [];
|
|
175
|
+
for (const relPath of diff.conflicts) {
|
|
176
|
+
if (force) {
|
|
177
|
+
applyCopy(relPath);
|
|
178
|
+
} else {
|
|
179
|
+
skippedConflicts.push(relPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add new files
|
|
184
|
+
for (const relPath of diff.added) {
|
|
185
|
+
applyCopy(relPath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Remove deleted kit files
|
|
189
|
+
for (const relPath of diff.removed) {
|
|
190
|
+
const absPath = join(projectRoot, relPath);
|
|
191
|
+
// Bounds check: removal must stay inside .claude/ subtree
|
|
192
|
+
try {
|
|
193
|
+
assertSafePath(absPath, claudeRoot, `removal target "${relPath}"`);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
process.stderr.write(`Warning: Skipping unsafe removal path: ${err.message}\n`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
unlinkSync(absPath);
|
|
200
|
+
} catch {
|
|
201
|
+
// Already missing — skip
|
|
202
|
+
}
|
|
203
|
+
delete newFiles[relPath];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Orphan cleanup: files on disk (in kit subdirs) that are not in the new source
|
|
207
|
+
const diskFiles = collectDiskFiles(targetDir);
|
|
208
|
+
const orphans = [];
|
|
209
|
+
const orphanParentDirs = new Set();
|
|
210
|
+
for (const relPath of diskFiles) {
|
|
211
|
+
if (relPath in sourceFiles) continue; // present in new source — keep
|
|
212
|
+
const absPath = join(projectRoot, relPath);
|
|
213
|
+
try {
|
|
214
|
+
assertSafePath(absPath, claudeRoot, `orphan "${relPath}"`);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
process.stderr.write(`Warning: Skipping unsafe orphan path: ${err.message}\n`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
unlinkSync(absPath);
|
|
221
|
+
orphans.push(relPath);
|
|
222
|
+
orphanParentDirs.add(dirname(absPath));
|
|
223
|
+
} catch {
|
|
224
|
+
// Already missing — skip
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Clean up empty directories bottom-up after orphan deletion
|
|
229
|
+
const resolvedTarget = resolve(targetDir);
|
|
230
|
+
const sortedOrphanDirs = [...orphanParentDirs].sort((a, b) => {
|
|
231
|
+
return b.split(/[/\\]/).length - a.split(/[/\\]/).length;
|
|
232
|
+
});
|
|
233
|
+
for (const dir of sortedOrphanDirs) {
|
|
234
|
+
if (isEmptyDir(dir)) {
|
|
235
|
+
try {
|
|
236
|
+
rmdirSync(dir);
|
|
237
|
+
let current = dirname(dir);
|
|
238
|
+
let prev = dir;
|
|
239
|
+
while (current !== prev && isEmptyDir(current)) {
|
|
240
|
+
const resolvedCurrent = resolve(current);
|
|
241
|
+
if (resolvedCurrent === resolvedTarget || !resolvedCurrent.startsWith(resolvedTarget + sep)) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
rmdirSync(current);
|
|
246
|
+
} catch {
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
prev = current;
|
|
250
|
+
current = dirname(current);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Non-empty or permission error — skip
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Merge settings.json hooks — additive merge, never overwrites user keys
|
|
259
|
+
mergeSettingsJson(sourceDir, targetDir);
|
|
260
|
+
|
|
261
|
+
// Update manifest with new file map.
|
|
262
|
+
// Use explicitVersion when provided (e.g. release.version from updateAction);
|
|
263
|
+
// fall back to pkg.version for direct runUpdate calls or main-branch fallback downloads.
|
|
264
|
+
updateManifest(manifestPath, newFiles, explicitVersion ?? pkg.version);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
updated: diff.updated,
|
|
268
|
+
added: diff.added,
|
|
269
|
+
removed: diff.removed,
|
|
270
|
+
conflicts: skippedConflicts,
|
|
271
|
+
unchanged: diff.unchanged,
|
|
272
|
+
orphans,
|
|
273
|
+
upToDate: false
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* CLI action handler for 'mk update'.
|
|
279
|
+
* Checks GitHub Releases for a newer version, prompts the user, then downloads
|
|
280
|
+
* and applies a three-way diff. Falls back to the main-branch tarball if no
|
|
281
|
+
* release information is available.
|
|
282
|
+
*
|
|
283
|
+
* @param {{ force: boolean, global: boolean }} options
|
|
284
|
+
* @param {object} [deps] - Injected dependencies (for testing)
|
|
285
|
+
*/
|
|
286
|
+
export async function updateAction(options = {}, deps = {}) {
|
|
287
|
+
const {
|
|
288
|
+
resolveTokenOrLogin: resolveAndLogin = resolveTokenOrLogin,
|
|
289
|
+
downloadAndExtractKit: download = downloadAndExtractKit,
|
|
290
|
+
cleanupTempDir: cleanup = cleanupTempDir,
|
|
291
|
+
writeToken: storeToken = writeToken,
|
|
292
|
+
readStoredToken: readToken = readStoredToken,
|
|
293
|
+
fetchLatestRelease: fetchRelease = fetchLatestRelease,
|
|
294
|
+
compareVersions: cmpVersions = compareVersions,
|
|
295
|
+
promptUser = defaultPromptUser,
|
|
296
|
+
// Injectable for tests — allows overriding resolved paths without touching CWD
|
|
297
|
+
manifestPath: injectedManifestPath,
|
|
298
|
+
targetDir: injectedTargetDir
|
|
299
|
+
} = deps;
|
|
300
|
+
|
|
301
|
+
// Read local package version (used as fallback when manifest has no version)
|
|
302
|
+
const pkg = JSON.parse(
|
|
303
|
+
readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const targetDir = injectedTargetDir ?? resolveTargetDir(options);
|
|
307
|
+
const manifestPath = injectedManifestPath ?? resolveManifestPath(options);
|
|
308
|
+
|
|
309
|
+
let tempDir = null;
|
|
310
|
+
try {
|
|
311
|
+
process.stdout.write('Authenticating with GitHub...\n');
|
|
312
|
+
const token = await resolveAndLogin({
|
|
313
|
+
readStored: readToken,
|
|
314
|
+
writeStored: storeToken,
|
|
315
|
+
display: ({ userCode, verificationUri, expiresIn }) => {
|
|
316
|
+
process.stdout.write('\n');
|
|
317
|
+
process.stdout.write(chalk.bold('To authenticate, visit: ') + chalk.cyan(verificationUri) + '\n');
|
|
318
|
+
process.stdout.write(chalk.bold('And enter the code: ') + chalk.yellow(userCode) + '\n');
|
|
319
|
+
process.stdout.write(`(Code expires in ${Math.round(expiresIn / 60)} minutes)\n\n`);
|
|
320
|
+
process.stdout.write('Waiting for authorization...\n');
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// --- Version check via GitHub Releases API ---
|
|
325
|
+
process.stdout.write('Checking for updates...\n');
|
|
326
|
+
const release = await fetchRelease(token);
|
|
327
|
+
|
|
328
|
+
// Read installed version from manifest (more accurate than pkg.version which reflects
|
|
329
|
+
// the CLI package, not the downloaded kit files). Fall back to pkg.version if no manifest.
|
|
330
|
+
let installedVersion = pkg.version;
|
|
331
|
+
if (existsSync(manifestPath)) {
|
|
332
|
+
try {
|
|
333
|
+
const existingManifest = readManifest(manifestPath);
|
|
334
|
+
if (existingManifest?.version) installedVersion = existingManifest.version;
|
|
335
|
+
} catch {
|
|
336
|
+
// Manifest unreadable — proceed with pkg.version fallback
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let downloadUrl; // undefined = use default (main branch)
|
|
341
|
+
let releaseVersion; // set when downloading from a specific release tarball
|
|
342
|
+
|
|
343
|
+
if (!release.available) {
|
|
344
|
+
// No release found or API error — warn and fall back to main branch
|
|
345
|
+
process.stdout.write(
|
|
346
|
+
chalk.yellow(`Warning: Could not check latest release (${release.reason}). Falling back to main branch.\n`)
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
const { needsUpdate, local, remote } = cmpVersions(installedVersion, release.version);
|
|
350
|
+
|
|
351
|
+
if (!needsUpdate) {
|
|
352
|
+
process.stdout.write(chalk.green(`Already up to date (v${local}).\n`));
|
|
353
|
+
// Still merge settings.json hooks from the bundled local source.
|
|
354
|
+
// The installed settings.json may be missing hooks if it was created
|
|
355
|
+
// before hooks were added to the kit or if it was manually edited.
|
|
356
|
+
// resolveSourceDir() points to the CLI package's own .claude/ — no download needed.
|
|
357
|
+
mergeSettingsJson(resolveSourceDir(), targetDir);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Show version diff
|
|
362
|
+
process.stdout.write(
|
|
363
|
+
chalk.cyan(`Update available: v${local} -> v${remote}\n`)
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Show release notes (if any), truncated to 500 chars.
|
|
367
|
+
// S4: Strip terminal escape sequences before printing to prevent injection via
|
|
368
|
+
// crafted GitHub release bodies (CSI/OSC sequences can clear screen, set window titles, etc.).
|
|
369
|
+
const body = release.body;
|
|
370
|
+
if (body && body.trim().length > 0) {
|
|
371
|
+
const rawNotes = body.length > 500 ? body.slice(0, 500) + '...' : body;
|
|
372
|
+
const notes = stripTerminalEscapes(rawNotes);
|
|
373
|
+
process.stdout.write('\nRelease notes:\n');
|
|
374
|
+
process.stdout.write(notes + '\n\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Prompt user
|
|
378
|
+
const confirmed = await promptUser(`Update to v${remote}? [y/N] `);
|
|
379
|
+
if (!confirmed) {
|
|
380
|
+
process.stdout.write('Update cancelled.\n');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
downloadUrl = release.tarballUrl;
|
|
385
|
+
releaseVersion = release.version;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --- Download and apply ---
|
|
389
|
+
process.stdout.write('Downloading latest kit from GitHub...\n');
|
|
390
|
+
tempDir = await download(token, downloadUrl !== undefined ? { url: downloadUrl } : {});
|
|
391
|
+
const sourceDir = join(tempDir, '.claude');
|
|
392
|
+
|
|
393
|
+
// Pass releaseVersion so runUpdate stores it in the manifest.
|
|
394
|
+
// When falling back to main branch (no release), releaseVersion is undefined and
|
|
395
|
+
// runUpdate falls back to pkg.version — which is the correct behaviour for that path.
|
|
396
|
+
const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force, version: releaseVersion });
|
|
397
|
+
|
|
398
|
+
if (result.upToDate) {
|
|
399
|
+
process.stdout.write(chalk.green('Already up to date.\n'));
|
|
400
|
+
} else {
|
|
401
|
+
if (result.updated.length > 0) {
|
|
402
|
+
process.stdout.write(chalk.green(`Updated: ${result.updated.length} files\n`));
|
|
403
|
+
}
|
|
404
|
+
if (result.added.length > 0) {
|
|
405
|
+
process.stdout.write(chalk.green(`Added: ${result.added.length} new files\n`));
|
|
406
|
+
}
|
|
407
|
+
if (result.removed.length > 0) {
|
|
408
|
+
process.stdout.write(chalk.yellow(`Removed: ${result.removed.length} files\n`));
|
|
409
|
+
for (const f of result.removed) {
|
|
410
|
+
process.stdout.write(` Removed: ${f}\n`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (result.orphans && result.orphans.length > 0) {
|
|
414
|
+
process.stdout.write(chalk.yellow(`Cleaned: ${result.orphans.length} orphan files\n`));
|
|
415
|
+
for (const f of result.orphans) {
|
|
416
|
+
process.stdout.write(` Cleaned: ${f}\n`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (result.conflicts.length > 0) {
|
|
420
|
+
process.stdout.write(
|
|
421
|
+
chalk.yellow(`Skipped: ${result.conflicts.length} user-modified files (use --force to overwrite)\n`)
|
|
422
|
+
);
|
|
423
|
+
for (const f of result.conflicts) {
|
|
424
|
+
process.stdout.write(` ${f}\n`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
process.stderr.write(chalk.red(`Error: ${err.message}\n`));
|
|
430
|
+
process.exit(1);
|
|
431
|
+
} finally {
|
|
432
|
+
if (tempDir) {
|
|
433
|
+
cleanup(tempDir);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|