claudekit-cli 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudekit-cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "CLI tool for bootstrapping and updating ClaudeKit projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "@octokit/rest": "^22.0.0",
34
34
  "cac": "^6.7.14",
35
35
  "cli-progress": "^3.12.0",
36
+ "extract-zip": "^2.0.1",
36
37
  "fs-extra": "^11.2.0",
37
38
  "ignore": "^5.3.2",
38
39
  "keytar": "^7.9.0",
@@ -40,7 +41,6 @@
40
41
  "picocolors": "^1.1.1",
41
42
  "tar": "^7.4.3",
42
43
  "tmp": "^0.2.3",
43
- "unzipper": "^0.12.3",
44
44
  "zod": "^3.23.8"
45
45
  },
46
46
  "devDependencies": {
@@ -132,6 +132,9 @@ export async function newCommand(options: NewCommandOptions): Promise<void> {
132
132
  const extractDir = `${tempDir}/extracted`;
133
133
  await downloadManager.extractArchive(archivePath, extractDir);
134
134
 
135
+ // Validate extraction
136
+ await downloadManager.validateExtraction(extractDir);
137
+
135
138
  // Copy files to target directory
136
139
  const merger = new FileMerger();
137
140
  await merger.merge(extractDir, resolvedDir, true); // Skip confirmation for new projects
@@ -125,6 +125,9 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<void
125
125
  const extractDir = `${tempDir}/extracted`;
126
126
  await downloadManager.extractArchive(archivePath, extractDir);
127
127
 
128
+ // Validate extraction
129
+ await downloadManager.validateExtraction(extractDir);
130
+
128
131
  // Identify custom .claude files to preserve
129
132
  logger.info("Scanning for custom .claude files...");
130
133
  const customClaudeFiles = await FileScanner.findCustomFiles(resolvedDir, extractDir, ".claude");
@@ -1,13 +1,11 @@
1
- import { createReadStream, createWriteStream } from "node:fs";
1
+ import { createWriteStream } from "node:fs";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { pipeline } from "node:stream";
6
- import { promisify } from "node:util";
7
5
  import cliProgress from "cli-progress";
6
+ import extractZip from "extract-zip";
8
7
  import ignore from "ignore";
9
8
  import * as tar from "tar";
10
- import unzipper from "unzipper";
11
9
  import {
12
10
  type ArchiveType,
13
11
  DownloadError,
@@ -17,8 +15,6 @@ import {
17
15
  import { logger } from "../utils/logger.js";
18
16
  import { createSpinner } from "../utils/safe-spinner.js";
19
17
 
20
- const streamPipeline = promisify(pipeline);
21
-
22
18
  export class DownloadManager {
23
19
  /**
24
20
  * Patterns to exclude from extraction
@@ -239,19 +235,95 @@ export class DownloadManager {
239
235
  * Extract tar.gz archive
240
236
  */
241
237
  private async extractTarGz(archivePath: string, destDir: string): Promise<void> {
242
- await tar.extract({
243
- file: archivePath,
244
- cwd: destDir,
245
- strip: 1, // Strip the root directory from the archive
246
- filter: (path: string) => {
247
- // Exclude unwanted files
248
- const shouldInclude = !this.shouldExclude(path);
249
- if (!shouldInclude) {
250
- logger.debug(`Excluding: ${path}`);
238
+ const { readdir, stat, mkdir: mkdirPromise, copyFile, rm } = await import("node:fs/promises");
239
+ const { join: pathJoin } = await import("node:path");
240
+
241
+ // Extract to a temporary directory first
242
+ const tempExtractDir = `${destDir}-temp`;
243
+ await mkdirPromise(tempExtractDir, { recursive: true });
244
+
245
+ try {
246
+ // Extract without stripping first
247
+ await tar.extract({
248
+ file: archivePath,
249
+ cwd: tempExtractDir,
250
+ strip: 0, // Don't strip yet - we'll decide based on wrapper detection
251
+ filter: (path: string) => {
252
+ // Exclude unwanted files
253
+ const shouldInclude = !this.shouldExclude(path);
254
+ if (!shouldInclude) {
255
+ logger.debug(`Excluding: ${path}`);
256
+ }
257
+ return shouldInclude;
258
+ },
259
+ });
260
+
261
+ logger.debug(`Extracted TAR.GZ to temp: ${tempExtractDir}`);
262
+
263
+ // Apply same wrapper detection logic as zip
264
+ const entries = await readdir(tempExtractDir);
265
+ logger.debug(`Root entries: ${entries.join(", ")}`);
266
+
267
+ if (entries.length === 1) {
268
+ const rootEntry = entries[0];
269
+ const rootPath = pathJoin(tempExtractDir, rootEntry);
270
+ const rootStat = await stat(rootPath);
271
+
272
+ if (rootStat.isDirectory()) {
273
+ // Check contents of root directory
274
+ const rootContents = await readdir(rootPath);
275
+ logger.debug(`Root directory '${rootEntry}' contains: ${rootContents.join(", ")}`);
276
+
277
+ // Only strip if root is a version/release wrapper
278
+ const isWrapper = this.isWrapperDirectory(rootEntry);
279
+ logger.debug(`Is wrapper directory: ${isWrapper}`);
280
+
281
+ if (isWrapper) {
282
+ // Strip wrapper and move contents
283
+ logger.debug(`Stripping wrapper directory: ${rootEntry}`);
284
+ await this.moveDirectoryContents(rootPath, destDir);
285
+ } else {
286
+ // Keep root directory - move everything including root
287
+ logger.debug("Preserving complete directory structure");
288
+ await this.moveDirectoryContents(tempExtractDir, destDir);
289
+ }
290
+ } else {
291
+ // Single file, just move it
292
+ await mkdirPromise(destDir, { recursive: true });
293
+ await copyFile(rootPath, pathJoin(destDir, rootEntry));
251
294
  }
252
- return shouldInclude;
253
- },
254
- });
295
+ } else {
296
+ // Multiple entries at root, move them all
297
+ logger.debug("Multiple root entries - moving all");
298
+ await this.moveDirectoryContents(tempExtractDir, destDir);
299
+ }
300
+
301
+ logger.debug(`Moved contents to: ${destDir}`);
302
+
303
+ // Clean up temp directory
304
+ await rm(tempExtractDir, { recursive: true, force: true });
305
+ } catch (error) {
306
+ // Clean up temp directory on error
307
+ try {
308
+ await rm(tempExtractDir, { recursive: true, force: true });
309
+ } catch {
310
+ // Ignore cleanup errors
311
+ }
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Check if directory name is a version/release wrapper
318
+ * Examples: claudekit-engineer-v1.0.0, claudekit-engineer-1.0.0, repo-abc1234
319
+ */
320
+ private isWrapperDirectory(dirName: string): boolean {
321
+ // Match version patterns: project-v1.0.0, project-1.0.0
322
+ const versionPattern = /^[\w-]+-v?\d+\.\d+\.\d+/;
323
+ // Match commit hash patterns: project-abc1234
324
+ const hashPattern = /^[\w-]+-[a-f0-9]{7,}$/;
325
+
326
+ return versionPattern.test(dirName) || hashPattern.test(dirName);
255
327
  }
256
328
 
257
329
  /**
@@ -266,24 +338,39 @@ export class DownloadManager {
266
338
  await mkdirPromise(tempExtractDir, { recursive: true });
267
339
 
268
340
  try {
269
- // Extract zip to temp directory
270
- await streamPipeline(
271
- createReadStream(archivePath),
272
- unzipper.Extract({ path: tempExtractDir }),
273
- );
341
+ // Extract zip to temp directory using extract-zip
342
+ await extractZip(archivePath, { dir: tempExtractDir });
343
+
344
+ logger.debug(`Extracted ZIP to temp: ${tempExtractDir}`);
274
345
 
275
346
  // Find the root directory in the zip (if any)
276
347
  const entries = await readdir(tempExtractDir);
348
+ logger.debug(`Root entries: ${entries.join(", ")}`);
277
349
 
278
- // If there's a single root directory, strip it
350
+ // If there's a single root directory, check if it's a wrapper
279
351
  if (entries.length === 1) {
280
352
  const rootEntry = entries[0];
281
353
  const rootPath = pathJoin(tempExtractDir, rootEntry);
282
354
  const rootStat = await stat(rootPath);
283
355
 
284
356
  if (rootStat.isDirectory()) {
285
- // Move contents from the root directory to the destination
286
- await this.moveDirectoryContents(rootPath, destDir);
357
+ // Check contents of root directory
358
+ const rootContents = await readdir(rootPath);
359
+ logger.debug(`Root directory '${rootEntry}' contains: ${rootContents.join(", ")}`);
360
+
361
+ // Only strip if root is a version/release wrapper
362
+ const isWrapper = this.isWrapperDirectory(rootEntry);
363
+ logger.debug(`Is wrapper directory: ${isWrapper}`);
364
+
365
+ if (isWrapper) {
366
+ // Strip wrapper and move contents
367
+ logger.debug(`Stripping wrapper directory: ${rootEntry}`);
368
+ await this.moveDirectoryContents(rootPath, destDir);
369
+ } else {
370
+ // Keep root directory - move everything including root
371
+ logger.debug("Preserving complete directory structure");
372
+ await this.moveDirectoryContents(tempExtractDir, destDir);
373
+ }
287
374
  } else {
288
375
  // Single file, just move it
289
376
  await mkdirPromise(destDir, { recursive: true });
@@ -291,9 +378,12 @@ export class DownloadManager {
291
378
  }
292
379
  } else {
293
380
  // Multiple entries at root, move them all
381
+ logger.debug("Multiple root entries - moving all");
294
382
  await this.moveDirectoryContents(tempExtractDir, destDir);
295
383
  }
296
384
 
385
+ logger.debug(`Moved contents to: ${destDir}`);
386
+
297
387
  // Clean up temp directory
298
388
  await rm(tempExtractDir, { recursive: true, force: true });
299
389
  } catch (error) {
@@ -388,6 +478,44 @@ export class DownloadManager {
388
478
  throw new ExtractionError(`Cannot detect archive type from filename: ${filename}`);
389
479
  }
390
480
 
481
+ /**
482
+ * Validate extraction results
483
+ */
484
+ async validateExtraction(extractDir: string): Promise<boolean> {
485
+ const { readdir, access } = await import("node:fs/promises");
486
+ const { join: pathJoin } = await import("node:path");
487
+ const { constants } = await import("node:fs");
488
+
489
+ try {
490
+ // Check if extract directory exists and is not empty
491
+ const entries = await readdir(extractDir);
492
+ logger.debug(`Extracted files: ${entries.join(", ")}`);
493
+
494
+ if (entries.length === 0) {
495
+ logger.warning("Extraction resulted in no files");
496
+ return false;
497
+ }
498
+
499
+ // Verify critical paths exist
500
+ const criticalPaths = [".claude", "CLAUDE.md"];
501
+ for (const path of criticalPaths) {
502
+ try {
503
+ await access(pathJoin(extractDir, path), constants.F_OK);
504
+ logger.debug(`✓ Found: ${path}`);
505
+ } catch {
506
+ logger.warning(`Expected path not found: ${path}`);
507
+ }
508
+ }
509
+
510
+ return true;
511
+ } catch (error) {
512
+ logger.error(
513
+ `Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
514
+ );
515
+ return false;
516
+ }
517
+ }
518
+
391
519
  /**
392
520
  * Create temporary download directory
393
521
  */