claudekit-cli 1.4.1 → 1.5.1

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.
Files changed (47) hide show
  1. package/bin/ck-darwin-arm64 +0 -0
  2. package/bin/ck-darwin-x64 +0 -0
  3. package/bin/ck-linux-x64 +0 -0
  4. package/bin/ck-win32-x64.exe +0 -0
  5. package/bin/ck.js +62 -0
  6. package/package.json +6 -2
  7. package/.github/workflows/ci.yml +0 -45
  8. package/.github/workflows/claude-code-review.yml +0 -57
  9. package/.github/workflows/claude.yml +0 -50
  10. package/.github/workflows/release.yml +0 -102
  11. package/.releaserc.json +0 -17
  12. package/.repomixignore +0 -15
  13. package/AGENTS.md +0 -217
  14. package/CHANGELOG.md +0 -95
  15. package/CLAUDE.md +0 -34
  16. package/biome.json +0 -28
  17. package/bun.lock +0 -863
  18. package/dist/index.js +0 -22511
  19. package/src/commands/new.ts +0 -185
  20. package/src/commands/update.ts +0 -174
  21. package/src/commands/version.ts +0 -135
  22. package/src/index.ts +0 -102
  23. package/src/lib/auth.ts +0 -157
  24. package/src/lib/download.ts +0 -689
  25. package/src/lib/github.ts +0 -230
  26. package/src/lib/merge.ts +0 -119
  27. package/src/lib/prompts.ts +0 -114
  28. package/src/types.ts +0 -178
  29. package/src/utils/config.ts +0 -87
  30. package/src/utils/file-scanner.ts +0 -134
  31. package/src/utils/logger.ts +0 -124
  32. package/src/utils/safe-prompts.ts +0 -44
  33. package/src/utils/safe-spinner.ts +0 -38
  34. package/src/version.json +0 -3
  35. package/tests/commands/version.test.ts +0 -297
  36. package/tests/integration/cli.test.ts +0 -252
  37. package/tests/lib/auth.test.ts +0 -116
  38. package/tests/lib/download.test.ts +0 -292
  39. package/tests/lib/github-download-priority.test.ts +0 -432
  40. package/tests/lib/github.test.ts +0 -52
  41. package/tests/lib/merge.test.ts +0 -267
  42. package/tests/lib/prompts.test.ts +0 -66
  43. package/tests/types.test.ts +0 -337
  44. package/tests/utils/config.test.ts +0 -263
  45. package/tests/utils/file-scanner.test.ts +0 -202
  46. package/tests/utils/logger.test.ts +0 -239
  47. package/tsconfig.json +0 -30
@@ -1,689 +0,0 @@
1
- import { createWriteStream } from "node:fs";
2
- import { mkdir } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join, relative, resolve } from "node:path";
5
- import cliProgress from "cli-progress";
6
- import extractZip from "extract-zip";
7
- import ignore from "ignore";
8
- import * as tar from "tar";
9
- import {
10
- type ArchiveType,
11
- DownloadError,
12
- ExtractionError,
13
- type GitHubReleaseAsset,
14
- } from "../types.js";
15
- import { logger } from "../utils/logger.js";
16
- import { createSpinner } from "../utils/safe-spinner.js";
17
-
18
- export class DownloadManager {
19
- /**
20
- * Maximum extraction size (500MB) to prevent archive bombs
21
- */
22
- private static MAX_EXTRACTION_SIZE = 500 * 1024 * 1024; // 500MB
23
-
24
- /**
25
- * Patterns to exclude from extraction
26
- */
27
- private static EXCLUDE_PATTERNS = [
28
- ".git",
29
- ".git/**",
30
- ".github",
31
- ".github/**",
32
- "node_modules",
33
- "node_modules/**",
34
- ".DS_Store",
35
- "Thumbs.db",
36
- "*.log",
37
- ];
38
-
39
- /**
40
- * Track total extracted size to prevent archive bombs
41
- */
42
- private totalExtractedSize = 0;
43
-
44
- /**
45
- * Instance-level ignore object with combined default and user patterns
46
- */
47
- private ig: ReturnType<typeof ignore>;
48
-
49
- /**
50
- * Store user-defined exclude patterns
51
- */
52
- private userExcludePatterns: string[] = [];
53
-
54
- /**
55
- * Initialize DownloadManager with default exclude patterns
56
- */
57
- constructor() {
58
- // Initialize ignore with default patterns
59
- this.ig = ignore().add(DownloadManager.EXCLUDE_PATTERNS);
60
- }
61
-
62
- /**
63
- * Set additional user-defined exclude patterns
64
- * These are added to (not replace) the default EXCLUDE_PATTERNS
65
- */
66
- setExcludePatterns(patterns: string[]): void {
67
- this.userExcludePatterns = patterns;
68
- // Reinitialize ignore with both default and user patterns
69
- this.ig = ignore().add([...DownloadManager.EXCLUDE_PATTERNS, ...this.userExcludePatterns]);
70
-
71
- if (patterns.length > 0) {
72
- logger.info(`Added ${patterns.length} custom exclude pattern(s)`);
73
- patterns.forEach((p) => logger.debug(` - ${p}`));
74
- }
75
- }
76
-
77
- /**
78
- * Check if file path should be excluded
79
- * Uses instance-level ignore with both default and user patterns
80
- */
81
- private shouldExclude(filePath: string): boolean {
82
- return this.ig.ignores(filePath);
83
- }
84
-
85
- /**
86
- * Decode percent-encoded file paths to handle Mojibake issues
87
- *
88
- * GitHub tarballs may contain percent-encoded paths (e.g., %20 for space, %C3%A9 for é)
89
- * that need to be decoded to prevent character encoding corruption.
90
- *
91
- * @param path - File path that may contain URL-encoded characters
92
- * @returns Decoded path, or original path if decoding fails
93
- * @private
94
- */
95
- private decodeFilePath(path: string): string {
96
- // Early exit for non-encoded paths (performance optimization)
97
- if (!path.includes("%")) {
98
- return path;
99
- }
100
-
101
- try {
102
- // Only decode if path contains valid percent-encoding pattern (%XX)
103
- if (/%[0-9A-F]{2}/i.test(path)) {
104
- const decoded = decodeURIComponent(path);
105
- logger.debug(`Decoded path: ${path} -> ${decoded}`);
106
- return decoded;
107
- }
108
- return path;
109
- } catch (error) {
110
- // If decoding fails (malformed encoding), return original path
111
- logger.warning(
112
- `Failed to decode path "${path}": ${error instanceof Error ? error.message : "Unknown error"}`,
113
- );
114
- return path;
115
- }
116
- }
117
-
118
- /**
119
- * Validate path to prevent path traversal attacks (zip slip)
120
- */
121
- private isPathSafe(basePath: string, targetPath: string): boolean {
122
- // Resolve both paths to their absolute canonical forms
123
- const resolvedBase = resolve(basePath);
124
- const resolvedTarget = resolve(targetPath);
125
-
126
- // Calculate relative path from base to target
127
- const relativePath = relative(resolvedBase, resolvedTarget);
128
-
129
- // If path starts with .. or is absolute, it's trying to escape
130
- // Also block if relative path is empty but resolved paths differ (edge case)
131
- return (
132
- !relativePath.startsWith("..") &&
133
- !relativePath.startsWith("/") &&
134
- resolvedTarget.startsWith(resolvedBase)
135
- );
136
- }
137
-
138
- /**
139
- * Track extracted file size and check against limit
140
- */
141
- private checkExtractionSize(fileSize: number): void {
142
- this.totalExtractedSize += fileSize;
143
- if (this.totalExtractedSize > DownloadManager.MAX_EXTRACTION_SIZE) {
144
- throw new ExtractionError(
145
- `Archive exceeds maximum extraction size of ${this.formatBytes(DownloadManager.MAX_EXTRACTION_SIZE)}. Possible archive bomb detected.`,
146
- );
147
- }
148
- }
149
-
150
- /**
151
- * Reset extraction size tracker
152
- */
153
- private resetExtractionSize(): void {
154
- this.totalExtractedSize = 0;
155
- }
156
-
157
- /**
158
- * Download asset from URL with progress tracking
159
- */
160
- async downloadAsset(asset: GitHubReleaseAsset, destDir: string): Promise<string> {
161
- try {
162
- const destPath = join(destDir, asset.name);
163
-
164
- // Ensure destination directory exists
165
- await mkdir(destDir, { recursive: true });
166
-
167
- logger.info(`Downloading ${asset.name} (${this.formatBytes(asset.size)})...`);
168
-
169
- // Create progress bar with simple ASCII characters
170
- const progressBar = new cliProgress.SingleBar({
171
- format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
172
- barCompleteChar: "=",
173
- barIncompleteChar: "-",
174
- hideCursor: true,
175
- });
176
-
177
- const response = await fetch(asset.browser_download_url, {
178
- headers: {
179
- Accept: "application/octet-stream",
180
- },
181
- });
182
-
183
- if (!response.ok) {
184
- throw new DownloadError(`Failed to download: ${response.statusText}`);
185
- }
186
-
187
- const totalSize = asset.size;
188
- let downloadedSize = 0;
189
-
190
- progressBar.start(Math.round(totalSize / 1024 / 1024), 0);
191
-
192
- const fileStream = createWriteStream(destPath);
193
- const reader = response.body?.getReader();
194
-
195
- if (!reader) {
196
- throw new DownloadError("Failed to get response reader");
197
- }
198
-
199
- try {
200
- while (true) {
201
- const { done, value } = await reader.read();
202
-
203
- if (done) {
204
- break;
205
- }
206
-
207
- fileStream.write(value);
208
- downloadedSize += value.length;
209
- progressBar.update(Math.round(downloadedSize / 1024 / 1024));
210
- }
211
-
212
- fileStream.end();
213
- progressBar.stop();
214
-
215
- logger.success(`Downloaded ${asset.name}`);
216
- return destPath;
217
- } catch (error) {
218
- fileStream.close();
219
- progressBar.stop();
220
- throw error;
221
- }
222
- } catch (error) {
223
- throw new DownloadError(
224
- `Failed to download ${asset.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
225
- );
226
- }
227
- }
228
-
229
- /**
230
- * Download file from URL with progress tracking (supports both assets and API URLs)
231
- */
232
- async downloadFile(params: {
233
- url: string;
234
- name: string;
235
- size?: number;
236
- destDir: string;
237
- token?: string;
238
- }): Promise<string> {
239
- const { url, name, size, destDir, token } = params;
240
- const destPath = join(destDir, name);
241
-
242
- await mkdir(destDir, { recursive: true });
243
-
244
- logger.info(`Downloading ${name}${size ? ` (${this.formatBytes(size)})` : ""}...`);
245
-
246
- const headers: Record<string, string> = {};
247
-
248
- // Add authentication for GitHub API URLs
249
- if (token && url.includes("api.github.com")) {
250
- headers.Authorization = `Bearer ${token}`;
251
- // Use application/octet-stream for asset downloads (not vnd.github+json)
252
- headers.Accept = "application/octet-stream";
253
- headers["X-GitHub-Api-Version"] = "2022-11-28";
254
- } else {
255
- headers.Accept = "application/octet-stream";
256
- }
257
-
258
- const response = await fetch(url, { headers });
259
-
260
- if (!response.ok) {
261
- throw new DownloadError(`Failed to download: ${response.statusText}`);
262
- }
263
-
264
- const totalSize = size || Number(response.headers.get("content-length")) || 0;
265
- let downloadedSize = 0;
266
-
267
- // Create progress bar only if we know the size (using simple ASCII characters)
268
- const progressBar =
269
- totalSize > 0
270
- ? new cliProgress.SingleBar({
271
- format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
272
- barCompleteChar: "=",
273
- barIncompleteChar: "-",
274
- hideCursor: true,
275
- })
276
- : null;
277
-
278
- if (progressBar) {
279
- progressBar.start(Math.round(totalSize / 1024 / 1024), 0);
280
- }
281
-
282
- const fileStream = createWriteStream(destPath);
283
- const reader = response.body?.getReader();
284
-
285
- if (!reader) {
286
- throw new DownloadError("Failed to get response reader");
287
- }
288
-
289
- try {
290
- while (true) {
291
- const { done, value } = await reader.read();
292
-
293
- if (done) break;
294
-
295
- fileStream.write(value);
296
- downloadedSize += value.length;
297
-
298
- if (progressBar) {
299
- progressBar.update(Math.round(downloadedSize / 1024 / 1024));
300
- }
301
- }
302
-
303
- fileStream.end();
304
- if (progressBar) progressBar.stop();
305
-
306
- logger.success(`Downloaded ${name}`);
307
- return destPath;
308
- } catch (error) {
309
- fileStream.close();
310
- if (progressBar) progressBar.stop();
311
- throw error;
312
- }
313
- }
314
-
315
- /**
316
- * Extract archive to destination
317
- */
318
- async extractArchive(
319
- archivePath: string,
320
- destDir: string,
321
- archiveType?: ArchiveType,
322
- ): Promise<void> {
323
- const spinner = createSpinner("Extracting files...").start();
324
-
325
- try {
326
- // Reset extraction size tracker
327
- this.resetExtractionSize();
328
-
329
- // Detect archive type from filename if not provided
330
- const detectedType = archiveType || this.detectArchiveType(archivePath);
331
-
332
- // Ensure destination directory exists
333
- await mkdir(destDir, { recursive: true });
334
-
335
- if (detectedType === "tar.gz") {
336
- await this.extractTarGz(archivePath, destDir);
337
- } else if (detectedType === "zip") {
338
- await this.extractZip(archivePath, destDir);
339
- } else {
340
- throw new ExtractionError(`Unsupported archive type: ${detectedType}`);
341
- }
342
-
343
- spinner.succeed("Files extracted successfully");
344
- } catch (error) {
345
- spinner.fail("Extraction failed");
346
- throw new ExtractionError(
347
- `Failed to extract archive: ${error instanceof Error ? error.message : "Unknown error"}`,
348
- );
349
- }
350
- }
351
-
352
- /**
353
- * Extract tar.gz archive
354
- */
355
- private async extractTarGz(archivePath: string, destDir: string): Promise<void> {
356
- const { readdir, stat, mkdir: mkdirPromise, copyFile, rm } = await import("node:fs/promises");
357
- const { join: pathJoin } = await import("node:path");
358
-
359
- // Extract to a temporary directory first
360
- const tempExtractDir = `${destDir}-temp`;
361
- await mkdirPromise(tempExtractDir, { recursive: true });
362
-
363
- try {
364
- // Extract without stripping first
365
- await tar.extract({
366
- file: archivePath,
367
- cwd: tempExtractDir,
368
- strip: 0, // Don't strip yet - we'll decide based on wrapper detection
369
- filter: (path: string) => {
370
- // Decode percent-encoded paths from GitHub tarballs
371
- const decodedPath = this.decodeFilePath(path);
372
- // Exclude unwanted files
373
- const shouldInclude = !this.shouldExclude(decodedPath);
374
- if (!shouldInclude) {
375
- logger.debug(`Excluding: ${decodedPath}`);
376
- }
377
- return shouldInclude;
378
- },
379
- });
380
-
381
- logger.debug(`Extracted TAR.GZ to temp: ${tempExtractDir}`);
382
-
383
- // Apply same wrapper detection logic as zip
384
- const entries = await readdir(tempExtractDir, { encoding: "utf8" });
385
- logger.debug(`Root entries: ${entries.join(", ")}`);
386
-
387
- if (entries.length === 1) {
388
- const rootEntry = entries[0];
389
- const rootPath = pathJoin(tempExtractDir, rootEntry);
390
- const rootStat = await stat(rootPath);
391
-
392
- if (rootStat.isDirectory()) {
393
- // Check contents of root directory
394
- const rootContents = await readdir(rootPath, { encoding: "utf8" });
395
- logger.debug(`Root directory '${rootEntry}' contains: ${rootContents.join(", ")}`);
396
-
397
- // Only strip if root is a version/release wrapper
398
- const isWrapper = this.isWrapperDirectory(rootEntry);
399
- logger.debug(`Is wrapper directory: ${isWrapper}`);
400
-
401
- if (isWrapper) {
402
- // Strip wrapper and move contents
403
- logger.debug(`Stripping wrapper directory: ${rootEntry}`);
404
- await this.moveDirectoryContents(rootPath, destDir);
405
- } else {
406
- // Keep root directory - move everything including root
407
- logger.debug("Preserving complete directory structure");
408
- await this.moveDirectoryContents(tempExtractDir, destDir);
409
- }
410
- } else {
411
- // Single file, just move it
412
- await mkdirPromise(destDir, { recursive: true });
413
- await copyFile(rootPath, pathJoin(destDir, rootEntry));
414
- }
415
- } else {
416
- // Multiple entries at root, move them all
417
- logger.debug("Multiple root entries - moving all");
418
- await this.moveDirectoryContents(tempExtractDir, destDir);
419
- }
420
-
421
- logger.debug(`Moved contents to: ${destDir}`);
422
-
423
- // Clean up temp directory
424
- await rm(tempExtractDir, { recursive: true, force: true });
425
- } catch (error) {
426
- // Clean up temp directory on error
427
- try {
428
- await rm(tempExtractDir, { recursive: true, force: true });
429
- } catch {
430
- // Ignore cleanup errors
431
- }
432
- throw error;
433
- }
434
- }
435
-
436
- /**
437
- * Check if directory name is a version/release wrapper
438
- * Examples: claudekit-engineer-v1.0.0, claudekit-engineer-1.0.0, repo-abc1234,
439
- * project-v1.0.0-alpha, project-1.2.3-beta.1, repo-v2.0.0-rc.5
440
- */
441
- private isWrapperDirectory(dirName: string): boolean {
442
- // Match version patterns with optional prerelease: project-v1.0.0, project-1.0.0-alpha, project-v2.0.0-rc.1
443
- const versionPattern = /^[\w-]+-v?\d+\.\d+\.\d+(-[\w.]+)?$/;
444
- // Match commit hash patterns: project-abc1234 (7-40 chars for short/full SHA)
445
- const hashPattern = /^[\w-]+-[a-f0-9]{7,40}$/;
446
-
447
- return versionPattern.test(dirName) || hashPattern.test(dirName);
448
- }
449
-
450
- /**
451
- * Extract zip archive
452
- */
453
- private async extractZip(archivePath: string, destDir: string): Promise<void> {
454
- const { readdir, stat, mkdir: mkdirPromise, copyFile, rm } = await import("node:fs/promises");
455
- const { join: pathJoin } = await import("node:path");
456
-
457
- // Extract to a temporary directory first
458
- const tempExtractDir = `${destDir}-temp`;
459
- await mkdirPromise(tempExtractDir, { recursive: true });
460
-
461
- try {
462
- // Extract zip to temp directory using extract-zip
463
- await extractZip(archivePath, { dir: tempExtractDir });
464
-
465
- logger.debug(`Extracted ZIP to temp: ${tempExtractDir}`);
466
-
467
- // Find the root directory in the zip (if any)
468
- const entries = await readdir(tempExtractDir, { encoding: "utf8" });
469
- logger.debug(`Root entries: ${entries.join(", ")}`);
470
-
471
- // If there's a single root directory, check if it's a wrapper
472
- if (entries.length === 1) {
473
- const rootEntry = entries[0];
474
- const rootPath = pathJoin(tempExtractDir, rootEntry);
475
- const rootStat = await stat(rootPath);
476
-
477
- if (rootStat.isDirectory()) {
478
- // Check contents of root directory
479
- const rootContents = await readdir(rootPath, { encoding: "utf8" });
480
- logger.debug(`Root directory '${rootEntry}' contains: ${rootContents.join(", ")}`);
481
-
482
- // Only strip if root is a version/release wrapper
483
- const isWrapper = this.isWrapperDirectory(rootEntry);
484
- logger.debug(`Is wrapper directory: ${isWrapper}`);
485
-
486
- if (isWrapper) {
487
- // Strip wrapper and move contents
488
- logger.debug(`Stripping wrapper directory: ${rootEntry}`);
489
- await this.moveDirectoryContents(rootPath, destDir);
490
- } else {
491
- // Keep root directory - move everything including root
492
- logger.debug("Preserving complete directory structure");
493
- await this.moveDirectoryContents(tempExtractDir, destDir);
494
- }
495
- } else {
496
- // Single file, just move it
497
- await mkdirPromise(destDir, { recursive: true });
498
- await copyFile(rootPath, pathJoin(destDir, rootEntry));
499
- }
500
- } else {
501
- // Multiple entries at root, move them all
502
- logger.debug("Multiple root entries - moving all");
503
- await this.moveDirectoryContents(tempExtractDir, destDir);
504
- }
505
-
506
- logger.debug(`Moved contents to: ${destDir}`);
507
-
508
- // Clean up temp directory
509
- await rm(tempExtractDir, { recursive: true, force: true });
510
- } catch (error) {
511
- // Clean up temp directory on error
512
- try {
513
- await rm(tempExtractDir, { recursive: true, force: true });
514
- } catch {
515
- // Ignore cleanup errors
516
- }
517
- throw error;
518
- }
519
- }
520
-
521
- /**
522
- * Move directory contents from source to destination, applying exclusion filters
523
- */
524
- private async moveDirectoryContents(sourceDir: string, destDir: string): Promise<void> {
525
- const { readdir, stat, mkdir: mkdirPromise, copyFile } = await import("node:fs/promises");
526
- const { join: pathJoin, relative } = await import("node:path");
527
-
528
- await mkdirPromise(destDir, { recursive: true });
529
-
530
- const entries = await readdir(sourceDir, { encoding: "utf8" });
531
-
532
- for (const entry of entries) {
533
- const sourcePath = pathJoin(sourceDir, entry);
534
- const destPath = pathJoin(destDir, entry);
535
- const relativePath = relative(sourceDir, sourcePath);
536
-
537
- // Validate path safety (prevent path traversal)
538
- if (!this.isPathSafe(destDir, destPath)) {
539
- logger.warning(`Skipping unsafe path: ${relativePath}`);
540
- throw new ExtractionError(`Path traversal attempt detected: ${relativePath}`);
541
- }
542
-
543
- // Skip excluded files
544
- if (this.shouldExclude(relativePath)) {
545
- logger.debug(`Excluding: ${relativePath}`);
546
- continue;
547
- }
548
-
549
- const entryStat = await stat(sourcePath);
550
-
551
- if (entryStat.isDirectory()) {
552
- // Recursively copy directory
553
- await this.copyDirectory(sourcePath, destPath);
554
- } else {
555
- // Track file size and check limit
556
- this.checkExtractionSize(entryStat.size);
557
- // Copy file
558
- await copyFile(sourcePath, destPath);
559
- }
560
- }
561
- }
562
-
563
- /**
564
- * Recursively copy directory
565
- */
566
- private async copyDirectory(sourceDir: string, destDir: string): Promise<void> {
567
- const { readdir, stat, mkdir: mkdirPromise, copyFile } = await import("node:fs/promises");
568
- const { join: pathJoin, relative } = await import("node:path");
569
-
570
- await mkdirPromise(destDir, { recursive: true });
571
-
572
- const entries = await readdir(sourceDir, { encoding: "utf8" });
573
-
574
- for (const entry of entries) {
575
- const sourcePath = pathJoin(sourceDir, entry);
576
- const destPath = pathJoin(destDir, entry);
577
- const relativePath = relative(sourceDir, sourcePath);
578
-
579
- // Validate path safety (prevent path traversal)
580
- if (!this.isPathSafe(destDir, destPath)) {
581
- logger.warning(`Skipping unsafe path: ${relativePath}`);
582
- throw new ExtractionError(`Path traversal attempt detected: ${relativePath}`);
583
- }
584
-
585
- // Skip excluded files
586
- if (this.shouldExclude(relativePath)) {
587
- logger.debug(`Excluding: ${relativePath}`);
588
- continue;
589
- }
590
-
591
- const entryStat = await stat(sourcePath);
592
-
593
- if (entryStat.isDirectory()) {
594
- // Recursively copy directory
595
- await this.copyDirectory(sourcePath, destPath);
596
- } else {
597
- // Track file size and check limit
598
- this.checkExtractionSize(entryStat.size);
599
- // Copy file
600
- await copyFile(sourcePath, destPath);
601
- }
602
- }
603
- }
604
-
605
- /**
606
- * Detect archive type from filename
607
- */
608
- private detectArchiveType(filename: string): ArchiveType {
609
- if (filename.endsWith(".tar.gz") || filename.endsWith(".tgz")) {
610
- return "tar.gz";
611
- }
612
- if (filename.endsWith(".zip")) {
613
- return "zip";
614
- }
615
- throw new ExtractionError(`Cannot detect archive type from filename: ${filename}`);
616
- }
617
-
618
- /**
619
- * Validate extraction results
620
- * @throws {ExtractionError} If validation fails
621
- */
622
- async validateExtraction(extractDir: string): Promise<void> {
623
- const { readdir, access } = await import("node:fs/promises");
624
- const { join: pathJoin } = await import("node:path");
625
- const { constants } = await import("node:fs");
626
-
627
- try {
628
- // Check if extract directory exists and is not empty
629
- const entries = await readdir(extractDir, { encoding: "utf8" });
630
- logger.debug(`Extracted files: ${entries.join(", ")}`);
631
-
632
- if (entries.length === 0) {
633
- throw new ExtractionError("Extraction resulted in no files");
634
- }
635
-
636
- // Verify critical paths exist
637
- const criticalPaths = [".claude", "CLAUDE.md"];
638
- const missingPaths: string[] = [];
639
-
640
- for (const path of criticalPaths) {
641
- try {
642
- await access(pathJoin(extractDir, path), constants.F_OK);
643
- logger.debug(`✓ Found: ${path}`);
644
- } catch {
645
- logger.warning(`Expected path not found: ${path}`);
646
- missingPaths.push(path);
647
- }
648
- }
649
-
650
- // Warn if critical paths are missing but don't fail validation
651
- if (missingPaths.length > 0) {
652
- logger.warning(
653
- `Some expected paths are missing: ${missingPaths.join(", ")}. This may not be a ClaudeKit project.`,
654
- );
655
- }
656
-
657
- logger.debug("Extraction validation passed");
658
- } catch (error) {
659
- if (error instanceof ExtractionError) {
660
- throw error;
661
- }
662
- throw new ExtractionError(
663
- `Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
664
- );
665
- }
666
- }
667
-
668
- /**
669
- * Create temporary download directory
670
- */
671
- async createTempDir(): Promise<string> {
672
- const tempDir = join(tmpdir(), `claudekit-${Date.now()}`);
673
- await mkdir(tempDir, { recursive: true });
674
- return tempDir;
675
- }
676
-
677
- /**
678
- * Format bytes to human readable string
679
- */
680
- private formatBytes(bytes: number): string {
681
- if (bytes === 0) return "0 Bytes";
682
-
683
- const k = 1024;
684
- const sizes = ["Bytes", "KB", "MB", "GB"];
685
- const i = Math.floor(Math.log(bytes) / Math.log(k));
686
-
687
- return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
688
- }
689
- }