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