@khanhcan148/mk 0.1.15 → 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.
@@ -1,426 +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
- // 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
- // Files are unchanged but we may still need to record the release version.
137
- // Without this, manifest.version stays at the old value and the next `mk update`
138
- // will always report "Update available" even though nothing changed on disk.
139
- if (explicitVersion && explicitVersion !== manifest.version) {
140
- updateManifest(manifestPath, manifest.files, explicitVersion);
141
- }
142
- return { ...diff, upToDate: true };
143
- }
144
-
145
- const newFiles = { ...manifest.files };
146
-
147
- /**
148
- * Copy a source file entry to its destination.
149
- * Fix 11-12: Uses O(1) Map lookup instead of O(n) Array.find to eliminate O(n²) worst case.
150
- * @param {string} relPath
151
- */
152
- function applyCopy(relPath) {
153
- const entry = sourceFileMap.get(relPath);
154
- if (!entry) return;
155
- const destAbs = join(projectRoot, relPath);
156
- // Bounds check: destination must stay inside .claude/ subtree
157
- assertSafePath(destAbs, claudeRoot, `copy destination for "${relPath}"`);
158
- mkdirSync(dirname(destAbs), { recursive: true });
159
- copyFileSync(entry.sourceAbsPath, destAbs);
160
- // Reuse source checksum copy is bit-for-bit identical, no need to re-hash
161
- newFiles[relPath] = { checksum: sourceFiles[relPath].checksum, size: entry.size };
162
- }
163
-
164
- // Apply safe updates
165
- for (const relPath of diff.updated) {
166
- applyCopy(relPath);
167
- }
168
-
169
- // Handle conflicts
170
- const skippedConflicts = [];
171
- for (const relPath of diff.conflicts) {
172
- if (force) {
173
- applyCopy(relPath);
174
- } else {
175
- skippedConflicts.push(relPath);
176
- }
177
- }
178
-
179
- // Add new files
180
- for (const relPath of diff.added) {
181
- applyCopy(relPath);
182
- }
183
-
184
- // Remove deleted kit files
185
- for (const relPath of diff.removed) {
186
- const absPath = join(projectRoot, relPath);
187
- // Bounds check: removal must stay inside .claude/ subtree
188
- try {
189
- assertSafePath(absPath, claudeRoot, `removal target "${relPath}"`);
190
- } catch (err) {
191
- process.stderr.write(`Warning: Skipping unsafe removal path: ${err.message}\n`);
192
- continue;
193
- }
194
- try {
195
- unlinkSync(absPath);
196
- } catch {
197
- // Already missing — skip
198
- }
199
- delete newFiles[relPath];
200
- }
201
-
202
- // Orphan cleanup: files on disk (in kit subdirs) that are not in the new source
203
- const diskFiles = collectDiskFiles(targetDir);
204
- const orphans = [];
205
- const orphanParentDirs = new Set();
206
- for (const relPath of diskFiles) {
207
- if (relPath in sourceFiles) continue; // present in new source — keep
208
- const absPath = join(projectRoot, relPath);
209
- try {
210
- assertSafePath(absPath, claudeRoot, `orphan "${relPath}"`);
211
- } catch (err) {
212
- process.stderr.write(`Warning: Skipping unsafe orphan path: ${err.message}\n`);
213
- continue;
214
- }
215
- try {
216
- unlinkSync(absPath);
217
- orphans.push(relPath);
218
- orphanParentDirs.add(dirname(absPath));
219
- } catch {
220
- // Already missing — skip
221
- }
222
- }
223
-
224
- // Clean up empty directories bottom-up after orphan deletion
225
- const resolvedTarget = resolve(targetDir);
226
- const sortedOrphanDirs = [...orphanParentDirs].sort((a, b) => {
227
- return b.split(/[/\\]/).length - a.split(/[/\\]/).length;
228
- });
229
- for (const dir of sortedOrphanDirs) {
230
- if (isEmptyDir(dir)) {
231
- try {
232
- rmdirSync(dir);
233
- let current = dirname(dir);
234
- let prev = dir;
235
- while (current !== prev && isEmptyDir(current)) {
236
- const resolvedCurrent = resolve(current);
237
- if (resolvedCurrent === resolvedTarget || !resolvedCurrent.startsWith(resolvedTarget + sep)) {
238
- break;
239
- }
240
- try {
241
- rmdirSync(current);
242
- } catch {
243
- break;
244
- }
245
- prev = current;
246
- current = dirname(current);
247
- }
248
- } catch {
249
- // Non-empty or permission error — skip
250
- }
251
- }
252
- }
253
-
254
- // Merge settings.json hooks — additive merge, never overwrites user keys
255
- mergeSettingsJson(sourceDir, targetDir);
256
-
257
- // Update manifest with new file map.
258
- // Use explicitVersion when provided (e.g. release.version from updateAction);
259
- // fall back to pkg.version for direct runUpdate calls or main-branch fallback downloads.
260
- updateManifest(manifestPath, newFiles, explicitVersion ?? pkg.version);
261
-
262
- return {
263
- updated: diff.updated,
264
- added: diff.added,
265
- removed: diff.removed,
266
- conflicts: skippedConflicts,
267
- unchanged: diff.unchanged,
268
- orphans,
269
- upToDate: false
270
- };
271
- }
272
-
273
- /**
274
- * CLI action handler for 'mk update'.
275
- * Checks GitHub Releases for a newer version, prompts the user, then downloads
276
- * and applies a three-way diff. Falls back to the main-branch tarball if no
277
- * release information is available.
278
- *
279
- * @param {{ force: boolean, global: boolean }} options
280
- * @param {object} [deps] - Injected dependencies (for testing)
281
- */
282
- export async function updateAction(options = {}, deps = {}) {
283
- const {
284
- resolveTokenOrLogin: resolveAndLogin = resolveTokenOrLogin,
285
- downloadAndExtractKit: download = downloadAndExtractKit,
286
- cleanupTempDir: cleanup = cleanupTempDir,
287
- writeToken: storeToken = writeToken,
288
- readStoredToken: readToken = readStoredToken,
289
- fetchLatestRelease: fetchRelease = fetchLatestRelease,
290
- compareVersions: cmpVersions = compareVersions,
291
- promptUser = defaultPromptUser,
292
- // Injectable for tests — allows overriding resolved paths without touching CWD
293
- manifestPath: injectedManifestPath
294
- } = deps;
295
-
296
- // Read local package version (used as fallback when manifest has no version)
297
- const pkg = JSON.parse(
298
- readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')
299
- );
300
-
301
- const targetDir = resolveTargetDir(options);
302
- const manifestPath = injectedManifestPath ?? resolveManifestPath(options);
303
-
304
- let tempDir = null;
305
- try {
306
- process.stdout.write('Authenticating with GitHub...\n');
307
- const token = await resolveAndLogin({
308
- readStored: readToken,
309
- writeStored: storeToken,
310
- display: ({ userCode, verificationUri, expiresIn }) => {
311
- process.stdout.write('\n');
312
- process.stdout.write(chalk.bold('To authenticate, visit: ') + chalk.cyan(verificationUri) + '\n');
313
- process.stdout.write(chalk.bold('And enter the code: ') + chalk.yellow(userCode) + '\n');
314
- process.stdout.write(`(Code expires in ${Math.round(expiresIn / 60)} minutes)\n\n`);
315
- process.stdout.write('Waiting for authorization...\n');
316
- }
317
- });
318
-
319
- // --- Version check via GitHub Releases API ---
320
- process.stdout.write('Checking for updates...\n');
321
- const release = await fetchRelease(token);
322
-
323
- // Read installed version from manifest (more accurate than pkg.version which reflects
324
- // the CLI package, not the downloaded kit files). Fall back to pkg.version if no manifest.
325
- let installedVersion = pkg.version;
326
- if (existsSync(manifestPath)) {
327
- try {
328
- const existingManifest = readManifest(manifestPath);
329
- if (existingManifest?.version) installedVersion = existingManifest.version;
330
- } catch {
331
- // Manifest unreadable — proceed with pkg.version fallback
332
- }
333
- }
334
-
335
- let downloadUrl; // undefined = use default (main branch)
336
- let releaseVersion; // set when downloading from a specific release tarball
337
-
338
- if (!release.available) {
339
- // No release found or API error — warn and fall back to main branch
340
- process.stdout.write(
341
- chalk.yellow(`Warning: Could not check latest release (${release.reason}). Falling back to main branch.\n`)
342
- );
343
- } else {
344
- const { needsUpdate, local, remote } = cmpVersions(installedVersion, release.version);
345
-
346
- if (!needsUpdate) {
347
- process.stdout.write(chalk.green(`Already up to date (v${local}).\n`));
348
- return;
349
- }
350
-
351
- // Show version diff
352
- process.stdout.write(
353
- chalk.cyan(`Update available: v${local} -> v${remote}\n`)
354
- );
355
-
356
- // Show release notes (if any), truncated to 500 chars.
357
- // S4: Strip terminal escape sequences before printing to prevent injection via
358
- // crafted GitHub release bodies (CSI/OSC sequences can clear screen, set window titles, etc.).
359
- const body = release.body;
360
- if (body && body.trim().length > 0) {
361
- const rawNotes = body.length > 500 ? body.slice(0, 500) + '...' : body;
362
- const notes = stripTerminalEscapes(rawNotes);
363
- process.stdout.write('\nRelease notes:\n');
364
- process.stdout.write(notes + '\n\n');
365
- }
366
-
367
- // Prompt user
368
- const confirmed = await promptUser(`Update to v${remote}? [y/N] `);
369
- if (!confirmed) {
370
- process.stdout.write('Update cancelled.\n');
371
- return;
372
- }
373
-
374
- downloadUrl = release.tarballUrl;
375
- releaseVersion = release.version;
376
- }
377
-
378
- // --- Download and apply ---
379
- process.stdout.write('Downloading latest kit from GitHub...\n');
380
- tempDir = await download(token, downloadUrl !== undefined ? { url: downloadUrl } : {});
381
- const sourceDir = join(tempDir, '.claude');
382
-
383
- // Pass releaseVersion so runUpdate stores it in the manifest.
384
- // When falling back to main branch (no release), releaseVersion is undefined and
385
- // runUpdate falls back to pkg.version — which is the correct behaviour for that path.
386
- const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force, version: releaseVersion });
387
-
388
- if (result.upToDate) {
389
- process.stdout.write(chalk.green('Already up to date.\n'));
390
- } else {
391
- if (result.updated.length > 0) {
392
- process.stdout.write(chalk.green(`Updated: ${result.updated.length} files\n`));
393
- }
394
- if (result.added.length > 0) {
395
- process.stdout.write(chalk.green(`Added: ${result.added.length} new files\n`));
396
- }
397
- if (result.removed.length > 0) {
398
- process.stdout.write(chalk.yellow(`Removed: ${result.removed.length} files\n`));
399
- for (const f of result.removed) {
400
- process.stdout.write(` Removed: ${f}\n`);
401
- }
402
- }
403
- if (result.orphans && result.orphans.length > 0) {
404
- process.stdout.write(chalk.yellow(`Cleaned: ${result.orphans.length} orphan files\n`));
405
- for (const f of result.orphans) {
406
- process.stdout.write(` Cleaned: ${f}\n`);
407
- }
408
- }
409
- if (result.conflicts.length > 0) {
410
- process.stdout.write(
411
- chalk.yellow(`Skipped: ${result.conflicts.length} user-modified files (use --force to overwrite)\n`)
412
- );
413
- for (const f of result.conflicts) {
414
- process.stdout.write(` ${f}\n`);
415
- }
416
- }
417
- }
418
- } catch (err) {
419
- process.stderr.write(chalk.red(`Error: ${err.message}\n`));
420
- process.exit(1);
421
- } finally {
422
- if (tempDir) {
423
- cleanup(tempDir);
424
- }
425
- }
426
- }
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() 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
+ }