claudekit-cli 1.0.1 → 1.2.0

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 (69) hide show
  1. package/.github/workflows/ci.yml +2 -0
  2. package/.github/workflows/release.yml +44 -0
  3. package/CHANGELOG.md +28 -0
  4. package/CLAUDE.md +3 -2
  5. package/LICENSE +21 -0
  6. package/README.md +73 -3
  7. package/dist/index.js +11556 -10926
  8. package/package.json +1 -1
  9. package/src/commands/new.ts +41 -9
  10. package/src/commands/update.ts +59 -13
  11. package/src/commands/version.ts +135 -0
  12. package/src/index.ts +53 -1
  13. package/src/lib/download.ts +231 -1
  14. package/src/lib/github.ts +56 -0
  15. package/src/lib/prompts.ts +4 -3
  16. package/src/types.ts +11 -2
  17. package/src/utils/file-scanner.ts +134 -0
  18. package/src/utils/logger.ts +108 -21
  19. package/src/utils/safe-prompts.ts +54 -0
  20. package/tests/commands/version.test.ts +297 -0
  21. package/tests/lib/github-download-priority.test.ts +301 -0
  22. package/tests/lib/github.test.ts +2 -2
  23. package/tests/lib/merge.test.ts +77 -0
  24. package/tests/types.test.ts +4 -0
  25. package/tests/utils/file-scanner.test.ts +202 -0
  26. package/tests/utils/logger.test.ts +115 -0
  27. package/.opencode/agent/code-reviewer.md +0 -141
  28. package/.opencode/agent/debugger.md +0 -74
  29. package/.opencode/agent/docs-manager.md +0 -119
  30. package/.opencode/agent/git-manager.md +0 -60
  31. package/.opencode/agent/planner-researcher.md +0 -100
  32. package/.opencode/agent/planner.md +0 -87
  33. package/.opencode/agent/project-manager.md +0 -113
  34. package/.opencode/agent/researcher.md +0 -173
  35. package/.opencode/agent/solution-brainstormer.md +0 -89
  36. package/.opencode/agent/system-architecture.md +0 -192
  37. package/.opencode/agent/tester.md +0 -96
  38. package/.opencode/agent/ui-ux-designer.md +0 -203
  39. package/.opencode/agent/ui-ux-developer.md +0 -97
  40. package/.opencode/command/cook.md +0 -7
  41. package/.opencode/command/debug.md +0 -10
  42. package/.opencode/command/design/3d.md +0 -65
  43. package/.opencode/command/design/fast.md +0 -18
  44. package/.opencode/command/design/good.md +0 -21
  45. package/.opencode/command/design/screenshot.md +0 -22
  46. package/.opencode/command/design/video.md +0 -22
  47. package/.opencode/command/fix/ci.md +0 -8
  48. package/.opencode/command/fix/fast.md +0 -11
  49. package/.opencode/command/fix/hard.md +0 -15
  50. package/.opencode/command/fix/logs.md +0 -16
  51. package/.opencode/command/fix/test.md +0 -18
  52. package/.opencode/command/fix/types.md +0 -10
  53. package/.opencode/command/git/cm.md +0 -5
  54. package/.opencode/command/git/cp.md +0 -4
  55. package/.opencode/command/plan/ci.md +0 -12
  56. package/.opencode/command/plan/two.md +0 -13
  57. package/.opencode/command/plan.md +0 -10
  58. package/.opencode/command/test.md +0 -7
  59. package/.opencode/command/watzup.md +0 -8
  60. package/plans/251008-claudekit-cli-implementation-plan.md +0 -1469
  61. package/plans/reports/251008-from-code-reviewer-to-developer-review-report.md +0 -864
  62. package/plans/reports/251008-from-tester-to-developer-test-summary-report.md +0 -409
  63. package/plans/reports/251008-researcher-download-extraction-report.md +0 -1377
  64. package/plans/reports/251008-researcher-github-api-report.md +0 -1339
  65. package/plans/research/251008-cli-frameworks-bun-research.md +0 -1051
  66. package/plans/templates/bug-fix-template.md +0 -69
  67. package/plans/templates/feature-implementation-template.md +0 -84
  68. package/plans/templates/refactor-template.md +0 -82
  69. package/plans/templates/template-usage-guide.md +0 -58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudekit-cli",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "CLI tool for bootstrapping and updating ClaudeKit projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists, readdir } from "fs-extra";
3
3
  import ora from "ora";
4
+ import { AuthManager } from "../lib/auth.js";
4
5
  import { DownloadManager } from "../lib/download.js";
5
6
  import { GitHubClient } from "../lib/github.js";
6
7
  import { FileMerger } from "../lib/merge.js";
@@ -81,20 +82,51 @@ export async function newCommand(options: NewCommandOptions): Promise<void> {
81
82
 
82
83
  logger.success(`Found release: ${release.tag_name} - ${release.name}`);
83
84
 
84
- // Find downloadable asset
85
- const asset = release.assets.find(
86
- (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
87
- );
85
+ // Get downloadable asset (custom asset or GitHub tarball)
86
+ const downloadInfo = GitHubClient.getDownloadableAsset(release);
88
87
 
89
- if (!asset) {
90
- logger.error("No downloadable archive found in release");
91
- return;
92
- }
88
+ logger.info(`Download source: ${downloadInfo.type}`);
89
+ logger.debug(`Download URL: ${downloadInfo.url}`);
93
90
 
94
91
  // Download asset
95
92
  const downloadManager = new DownloadManager();
96
93
  const tempDir = await downloadManager.createTempDir();
97
- const archivePath = await downloadManager.downloadAsset(asset, tempDir);
94
+
95
+ // Get authentication token for API requests
96
+ const { token } = await AuthManager.getToken();
97
+
98
+ let archivePath: string;
99
+ try {
100
+ // Try downloading the asset/tarball with authentication
101
+ archivePath = await downloadManager.downloadFile({
102
+ url: downloadInfo.url,
103
+ name: downloadInfo.name,
104
+ size: downloadInfo.size,
105
+ destDir: tempDir,
106
+ token, // Always pass token for private repository access
107
+ });
108
+ } catch (error) {
109
+ // If asset download fails, fallback to GitHub tarball
110
+ if (downloadInfo.type === "asset") {
111
+ logger.warning("Asset download failed, falling back to GitHub tarball...");
112
+ const tarballInfo = {
113
+ type: "github-tarball" as const,
114
+ url: release.tarball_url,
115
+ name: `${kitConfig.repo}-${release.tag_name}.tar.gz`,
116
+ size: 0, // Size unknown for tarball
117
+ };
118
+
119
+ archivePath = await downloadManager.downloadFile({
120
+ url: tarballInfo.url,
121
+ name: tarballInfo.name,
122
+ size: tarballInfo.size,
123
+ destDir: tempDir,
124
+ token,
125
+ });
126
+ } else {
127
+ throw error;
128
+ }
129
+ }
98
130
 
99
131
  // Extract archive
100
132
  const extractDir = `${tempDir}/extracted`;
@@ -1,12 +1,14 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists } from "fs-extra";
3
3
  import ora from "ora";
4
+ import { AuthManager } from "../lib/auth.js";
4
5
  import { DownloadManager } from "../lib/download.js";
5
6
  import { GitHubClient } from "../lib/github.js";
6
7
  import { FileMerger } from "../lib/merge.js";
7
8
  import { PromptsManager } from "../lib/prompts.js";
8
9
  import { AVAILABLE_KITS, type UpdateCommandOptions, UpdateCommandOptionsSchema } from "../types.js";
9
10
  import { ConfigManager } from "../utils/config.js";
11
+ import { FileScanner } from "../utils/file-scanner.js";
10
12
  import { logger } from "../utils/logger.js";
11
13
 
12
14
  export async function updateCommand(options: UpdateCommandOptions): Promise<void> {
@@ -73,36 +75,80 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<void
73
75
 
74
76
  logger.success(`Found release: ${release.tag_name} - ${release.name}`);
75
77
 
76
- // Find downloadable asset
77
- const asset = release.assets.find(
78
- (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
79
- );
78
+ // Get downloadable asset (custom asset or GitHub tarball)
79
+ const downloadInfo = GitHubClient.getDownloadableAsset(release);
80
80
 
81
- if (!asset) {
82
- logger.error("No downloadable archive found in release");
83
- return;
84
- }
81
+ logger.info(`Download source: ${downloadInfo.type}`);
82
+ logger.debug(`Download URL: ${downloadInfo.url}`);
85
83
 
86
84
  // Download asset
87
85
  const downloadManager = new DownloadManager();
88
86
  const tempDir = await downloadManager.createTempDir();
89
- const archivePath = await downloadManager.downloadAsset(asset, tempDir);
87
+
88
+ // Get authentication token for API requests
89
+ const { token } = await AuthManager.getToken();
90
+
91
+ let archivePath: string;
92
+ try {
93
+ // Try downloading the asset/tarball with authentication
94
+ archivePath = await downloadManager.downloadFile({
95
+ url: downloadInfo.url,
96
+ name: downloadInfo.name,
97
+ size: downloadInfo.size,
98
+ destDir: tempDir,
99
+ token, // Always pass token for private repository access
100
+ });
101
+ } catch (error) {
102
+ // If asset download fails, fallback to GitHub tarball
103
+ if (downloadInfo.type === "asset") {
104
+ logger.warning("Asset download failed, falling back to GitHub tarball...");
105
+ const tarballInfo = {
106
+ type: "github-tarball" as const,
107
+ url: release.tarball_url,
108
+ name: `${kitConfig.repo}-${release.tag_name}.tar.gz`,
109
+ size: 0, // Size unknown for tarball
110
+ };
111
+
112
+ archivePath = await downloadManager.downloadFile({
113
+ url: tarballInfo.url,
114
+ name: tarballInfo.name,
115
+ size: tarballInfo.size,
116
+ destDir: tempDir,
117
+ token,
118
+ });
119
+ } else {
120
+ throw error;
121
+ }
122
+ }
90
123
 
91
124
  // Extract archive
92
125
  const extractDir = `${tempDir}/extracted`;
93
126
  await downloadManager.extractArchive(archivePath, extractDir);
94
127
 
128
+ // Identify custom .claude files to preserve
129
+ logger.info("Scanning for custom .claude files...");
130
+ const customClaudeFiles = await FileScanner.findCustomFiles(resolvedDir, extractDir, ".claude");
131
+
95
132
  // Merge files with confirmation
96
133
  const merger = new FileMerger();
134
+
135
+ // Add custom .claude files to ignore patterns
136
+ if (customClaudeFiles.length > 0) {
137
+ merger.addIgnorePatterns(customClaudeFiles);
138
+ logger.success(`Protected ${customClaudeFiles.length} custom .claude file(s)`);
139
+ }
140
+
97
141
  await merger.merge(extractDir, resolvedDir, false); // Show confirmation for updates
98
142
 
99
143
  prompts.outro(`✨ Project updated successfully at ${resolvedDir}`);
100
144
 
101
145
  // Show next steps
102
- prompts.note(
103
- "Your project has been updated with the latest version.\nProtected files (.env, etc.) were not modified.",
104
- "Update complete",
105
- );
146
+ const protectedNote =
147
+ customClaudeFiles.length > 0
148
+ ? "Your project has been updated with the latest version.\nProtected files (.env, .claude custom files, etc.) were not modified."
149
+ : "Your project has been updated with the latest version.\nProtected files (.env, etc.) were not modified.";
150
+
151
+ prompts.note(protectedNote, "Update complete");
106
152
  } catch (error) {
107
153
  if (error instanceof Error && error.message === "Merge cancelled by user") {
108
154
  logger.warning("Update cancelled");
@@ -0,0 +1,135 @@
1
+ import pc from "picocolors";
2
+ import { GitHubClient } from "../lib/github.js";
3
+ import { PromptsManager } from "../lib/prompts.js";
4
+ import {
5
+ AVAILABLE_KITS,
6
+ type GitHubRelease,
7
+ type VersionCommandOptions,
8
+ VersionCommandOptionsSchema,
9
+ } from "../types.js";
10
+ import { logger } from "../utils/logger.js";
11
+
12
+ /**
13
+ * Format a date as a relative time string
14
+ */
15
+ function formatRelativeTime(dateString?: string): string {
16
+ if (!dateString) return "Unknown";
17
+
18
+ const date = new Date(dateString);
19
+ const now = new Date();
20
+ const diffMs = now.getTime() - date.getTime();
21
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
22
+
23
+ if (diffDays === 0) return "Today";
24
+ if (diffDays === 1) return "Yesterday";
25
+ if (diffDays < 7) return `${diffDays} days ago`;
26
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
27
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
28
+ return `${Math.floor(diffDays / 365)} years ago`;
29
+ }
30
+
31
+ /**
32
+ * Display releases for a single kit
33
+ */
34
+ function displayKitReleases(kitName: string, releases: GitHubRelease[]): void {
35
+ console.log(`\n${pc.bold(pc.cyan(kitName))} - Available Versions:\n`);
36
+
37
+ if (releases.length === 0) {
38
+ console.log(pc.dim(" No releases found"));
39
+ return;
40
+ }
41
+
42
+ for (const release of releases) {
43
+ const version = pc.green(release.tag_name);
44
+ const name = release.name || "No title";
45
+ const publishedAt = formatRelativeTime(release.published_at);
46
+ const assetCount = release.assets.length;
47
+
48
+ // Add badges for prerelease and draft
49
+ const badges: string[] = [];
50
+ if (release.prerelease) badges.push(pc.yellow("[prerelease]"));
51
+ if (release.draft) badges.push(pc.gray("[draft]"));
52
+ const badgeStr = badges.length > 0 ? ` ${badges.join(" ")}` : "";
53
+
54
+ // Format: version | name | time | assets
55
+ const versionPart = version.padEnd(20);
56
+ const namePart = name.length > 40 ? `${name.slice(0, 37)}...` : name.padEnd(40);
57
+ const timePart = pc.dim(publishedAt.padEnd(20));
58
+ const assetPart = pc.dim(`(${assetCount} ${assetCount === 1 ? "asset" : "assets"})`);
59
+
60
+ console.log(` ${versionPart} ${namePart} ${timePart} ${assetPart}${badgeStr}`);
61
+ }
62
+
63
+ console.log(
64
+ pc.dim(`\nShowing ${releases.length} ${releases.length === 1 ? "release" : "releases"}`),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Version command - List available versions of ClaudeKit repositories
70
+ */
71
+ export async function versionCommand(options: VersionCommandOptions): Promise<void> {
72
+ const prompts = new PromptsManager();
73
+
74
+ prompts.intro("📦 ClaudeKit - Available Versions");
75
+
76
+ try {
77
+ // Validate and parse options
78
+ const validOptions = VersionCommandOptionsSchema.parse(options);
79
+
80
+ // Determine which kits to fetch
81
+ const kitsToFetch = validOptions.kit
82
+ ? [validOptions.kit]
83
+ : (Object.keys(AVAILABLE_KITS) as Array<keyof typeof AVAILABLE_KITS>);
84
+
85
+ // Initialize GitHub client
86
+ const github = new GitHubClient();
87
+
88
+ // Determine limit (default to 30, similar to GitHub CLI)
89
+ const limit = validOptions.limit || 30;
90
+
91
+ // Fetch releases for all requested kits in parallel
92
+ const releasePromises = kitsToFetch.map(async (kitType) => {
93
+ const kitConfig = AVAILABLE_KITS[kitType];
94
+ try {
95
+ const releases = await github.listReleases(kitConfig, limit);
96
+
97
+ // Filter out drafts and prereleases unless --all flag is set
98
+ const filteredReleases = validOptions.all
99
+ ? releases
100
+ : releases.filter((r) => !r.draft && !r.prerelease);
101
+
102
+ return {
103
+ kitType,
104
+ kitConfig,
105
+ releases: filteredReleases,
106
+ error: null,
107
+ };
108
+ } catch (error) {
109
+ return {
110
+ kitType,
111
+ kitConfig,
112
+ releases: [],
113
+ error: error instanceof Error ? error.message : "Unknown error",
114
+ };
115
+ }
116
+ });
117
+
118
+ const results = await Promise.all(releasePromises);
119
+
120
+ // Display results
121
+ for (const result of results) {
122
+ if (result.error) {
123
+ console.log(`\n${pc.bold(pc.cyan(result.kitConfig.name))} - ${pc.red("Error")}`);
124
+ console.log(pc.dim(` ${result.error}`));
125
+ } else {
126
+ displayKitReleases(result.kitConfig.name, result.releases);
127
+ }
128
+ }
129
+
130
+ prompts.outro("✨ Done");
131
+ } catch (error) {
132
+ logger.error(error instanceof Error ? error.message : "Unknown error occurred");
133
+ process.exit(1);
134
+ }
135
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,16 @@ import { fileURLToPath } from "node:url";
6
6
  import { cac } from "cac";
7
7
  import { newCommand } from "./commands/new.js";
8
8
  import { updateCommand } from "./commands/update.js";
9
+ import { versionCommand } from "./commands/version.js";
10
+ import { logger } from "./utils/logger.js";
11
+
12
+ // Set proper output encoding to prevent unicode rendering issues
13
+ if (process.stdout.setEncoding) {
14
+ process.stdout.setEncoding("utf8");
15
+ }
16
+ if (process.stderr.setEncoding) {
17
+ process.stderr.setEncoding("utf8");
18
+ }
9
19
 
10
20
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
11
21
 
@@ -14,6 +24,10 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"),
14
24
 
15
25
  const cli = cac("ck");
16
26
 
27
+ // Global options
28
+ cli.option("--verbose, -v", "Enable verbose logging for debugging");
29
+ cli.option("--log-file <path>", "Write logs to file");
30
+
17
31
  // New command
18
32
  cli
19
33
  .command("new", "Bootstrap a new ClaudeKit project")
@@ -34,11 +48,49 @@ cli
34
48
  await updateCommand(options);
35
49
  });
36
50
 
51
+ // Versions command
52
+ cli
53
+ .command("versions", "List available versions of ClaudeKit repositories")
54
+ .option("--kit <kit>", "Filter by specific kit (engineer, marketing)")
55
+ .option("--limit <limit>", "Number of releases to show (default: 30)")
56
+ .option("--all", "Show all releases including prereleases")
57
+ .action(async (options) => {
58
+ await versionCommand(options);
59
+ });
60
+
37
61
  // Version
38
62
  cli.version(packageJson.version);
39
63
 
40
64
  // Help
41
65
  cli.help();
42
66
 
43
- // Parse CLI arguments
67
+ // Parse to get global options first
68
+ const parsed = cli.parse(process.argv, { run: false });
69
+
70
+ // Check environment variable
71
+ const envVerbose =
72
+ process.env.CLAUDEKIT_VERBOSE === "1" || process.env.CLAUDEKIT_VERBOSE === "true";
73
+
74
+ // Enable verbose if flag or env var is set
75
+ const isVerbose = parsed.options.verbose || envVerbose;
76
+
77
+ if (isVerbose) {
78
+ logger.setVerbose(true);
79
+ }
80
+
81
+ // Set log file if specified
82
+ if (parsed.options.logFile) {
83
+ logger.setLogFile(parsed.options.logFile);
84
+ }
85
+
86
+ // Log startup info in verbose mode
87
+ logger.verbose("ClaudeKit CLI starting", {
88
+ version: packageJson.version,
89
+ command: parsed.args[0] || "none",
90
+ options: parsed.options,
91
+ cwd: process.cwd(),
92
+ node: process.version,
93
+ });
94
+
95
+ // Parse again to run the command
44
96
  cli.parse();
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { pipeline } from "node:stream";
6
6
  import { promisify } from "node:util";
7
7
  import cliProgress from "cli-progress";
8
+ import ignore from "ignore";
8
9
  import ora from "ora";
9
10
  import * as tar from "tar";
10
11
  import unzipper from "unzipper";
@@ -19,6 +20,29 @@ import { logger } from "../utils/logger.js";
19
20
  const streamPipeline = promisify(pipeline);
20
21
 
21
22
  export class DownloadManager {
23
+ /**
24
+ * Patterns to exclude from extraction
25
+ */
26
+ private static EXCLUDE_PATTERNS = [
27
+ ".git",
28
+ ".git/**",
29
+ ".github",
30
+ ".github/**",
31
+ "node_modules",
32
+ "node_modules/**",
33
+ ".DS_Store",
34
+ "Thumbs.db",
35
+ "*.log",
36
+ ];
37
+
38
+ /**
39
+ * Check if file path should be excluded
40
+ */
41
+ private shouldExclude(filePath: string): boolean {
42
+ const ig = ignore().add(DownloadManager.EXCLUDE_PATTERNS);
43
+ return ig.ignores(filePath);
44
+ }
45
+
22
46
  /**
23
47
  * Download asset from URL with progress tracking
24
48
  */
@@ -91,6 +115,90 @@ export class DownloadManager {
91
115
  }
92
116
  }
93
117
 
118
+ /**
119
+ * Download file from URL with progress tracking (supports both assets and API URLs)
120
+ */
121
+ async downloadFile(params: {
122
+ url: string;
123
+ name: string;
124
+ size?: number;
125
+ destDir: string;
126
+ token?: string;
127
+ }): Promise<string> {
128
+ const { url, name, size, destDir, token } = params;
129
+ const destPath = join(destDir, name);
130
+
131
+ await mkdir(destDir, { recursive: true });
132
+
133
+ logger.info(`Downloading ${name}${size ? ` (${this.formatBytes(size)})` : ""}...`);
134
+
135
+ const headers: Record<string, string> = {};
136
+
137
+ // Add authentication for GitHub API URLs
138
+ if (token && url.includes("api.github.com")) {
139
+ headers.Authorization = `Bearer ${token}`;
140
+ headers.Accept = "application/vnd.github+json";
141
+ } else {
142
+ headers.Accept = "application/octet-stream";
143
+ }
144
+
145
+ const response = await fetch(url, { headers });
146
+
147
+ if (!response.ok) {
148
+ throw new DownloadError(`Failed to download: ${response.statusText}`);
149
+ }
150
+
151
+ const totalSize = size || Number(response.headers.get("content-length")) || 0;
152
+ let downloadedSize = 0;
153
+
154
+ // Create progress bar only if we know the size
155
+ const progressBar =
156
+ totalSize > 0
157
+ ? new cliProgress.SingleBar({
158
+ format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
159
+ barCompleteChar: "\u2588",
160
+ barIncompleteChar: "\u2591",
161
+ hideCursor: true,
162
+ })
163
+ : null;
164
+
165
+ if (progressBar) {
166
+ progressBar.start(Math.round(totalSize / 1024 / 1024), 0);
167
+ }
168
+
169
+ const fileStream = createWriteStream(destPath);
170
+ const reader = response.body?.getReader();
171
+
172
+ if (!reader) {
173
+ throw new DownloadError("Failed to get response reader");
174
+ }
175
+
176
+ try {
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+
180
+ if (done) break;
181
+
182
+ fileStream.write(value);
183
+ downloadedSize += value.length;
184
+
185
+ if (progressBar) {
186
+ progressBar.update(Math.round(downloadedSize / 1024 / 1024));
187
+ }
188
+ }
189
+
190
+ fileStream.end();
191
+ if (progressBar) progressBar.stop();
192
+
193
+ logger.success(`Downloaded ${name}`);
194
+ return destPath;
195
+ } catch (error) {
196
+ fileStream.close();
197
+ if (progressBar) progressBar.stop();
198
+ throw error;
199
+ }
200
+ }
201
+
94
202
  /**
95
203
  * Extract archive to destination
96
204
  */
@@ -133,6 +241,14 @@ export class DownloadManager {
133
241
  file: archivePath,
134
242
  cwd: destDir,
135
243
  strip: 1, // Strip the root directory from the archive
244
+ filter: (path: string) => {
245
+ // Exclude unwanted files
246
+ const shouldInclude = !this.shouldExclude(path);
247
+ if (!shouldInclude) {
248
+ logger.debug(`Excluding: ${path}`);
249
+ }
250
+ return shouldInclude;
251
+ },
136
252
  });
137
253
  }
138
254
 
@@ -140,7 +256,121 @@ export class DownloadManager {
140
256
  * Extract zip archive
141
257
  */
142
258
  private async extractZip(archivePath: string, destDir: string): Promise<void> {
143
- await streamPipeline(createReadStream(archivePath), unzipper.Extract({ path: destDir }));
259
+ const { readdir, stat, mkdir: mkdirPromise, copyFile, rm } = await import("node:fs/promises");
260
+ const { join: pathJoin } = await import("node:path");
261
+
262
+ // Extract to a temporary directory first
263
+ const tempExtractDir = `${destDir}-temp`;
264
+ await mkdirPromise(tempExtractDir, { recursive: true });
265
+
266
+ try {
267
+ // Extract zip to temp directory
268
+ await streamPipeline(
269
+ createReadStream(archivePath),
270
+ unzipper.Extract({ path: tempExtractDir }),
271
+ );
272
+
273
+ // Find the root directory in the zip (if any)
274
+ const entries = await readdir(tempExtractDir);
275
+
276
+ // If there's a single root directory, strip it
277
+ if (entries.length === 1) {
278
+ const rootEntry = entries[0];
279
+ const rootPath = pathJoin(tempExtractDir, rootEntry);
280
+ const rootStat = await stat(rootPath);
281
+
282
+ if (rootStat.isDirectory()) {
283
+ // Move contents from the root directory to the destination
284
+ await this.moveDirectoryContents(rootPath, destDir);
285
+ } else {
286
+ // Single file, just move it
287
+ await mkdirPromise(destDir, { recursive: true });
288
+ await copyFile(rootPath, pathJoin(destDir, rootEntry));
289
+ }
290
+ } else {
291
+ // Multiple entries at root, move them all
292
+ await this.moveDirectoryContents(tempExtractDir, destDir);
293
+ }
294
+
295
+ // Clean up temp directory
296
+ await rm(tempExtractDir, { recursive: true, force: true });
297
+ } catch (error) {
298
+ // Clean up temp directory on error
299
+ try {
300
+ await rm(tempExtractDir, { recursive: true, force: true });
301
+ } catch {
302
+ // Ignore cleanup errors
303
+ }
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Move directory contents from source to destination, applying exclusion filters
310
+ */
311
+ private async moveDirectoryContents(sourceDir: string, destDir: string): Promise<void> {
312
+ const { readdir, stat, mkdir: mkdirPromise, copyFile } = await import("node:fs/promises");
313
+ const { join: pathJoin, relative } = await import("node:path");
314
+
315
+ await mkdirPromise(destDir, { recursive: true });
316
+
317
+ const entries = await readdir(sourceDir);
318
+
319
+ for (const entry of entries) {
320
+ const sourcePath = pathJoin(sourceDir, entry);
321
+ const destPath = pathJoin(destDir, entry);
322
+ const relativePath = relative(sourceDir, sourcePath);
323
+
324
+ // Skip excluded files
325
+ if (this.shouldExclude(relativePath)) {
326
+ logger.debug(`Excluding: ${relativePath}`);
327
+ continue;
328
+ }
329
+
330
+ const entryStat = await stat(sourcePath);
331
+
332
+ if (entryStat.isDirectory()) {
333
+ // Recursively copy directory
334
+ await this.copyDirectory(sourcePath, destPath);
335
+ } else {
336
+ // Copy file
337
+ await copyFile(sourcePath, destPath);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Recursively copy directory
344
+ */
345
+ private async copyDirectory(sourceDir: string, destDir: string): Promise<void> {
346
+ const { readdir, stat, mkdir: mkdirPromise, copyFile } = await import("node:fs/promises");
347
+ const { join: pathJoin, relative } = await import("node:path");
348
+
349
+ await mkdirPromise(destDir, { recursive: true });
350
+
351
+ const entries = await readdir(sourceDir);
352
+
353
+ for (const entry of entries) {
354
+ const sourcePath = pathJoin(sourceDir, entry);
355
+ const destPath = pathJoin(destDir, entry);
356
+ const relativePath = relative(sourceDir, sourcePath);
357
+
358
+ // Skip excluded files
359
+ if (this.shouldExclude(relativePath)) {
360
+ logger.debug(`Excluding: ${relativePath}`);
361
+ continue;
362
+ }
363
+
364
+ const entryStat = await stat(sourcePath);
365
+
366
+ if (entryStat.isDirectory()) {
367
+ // Recursively copy directory
368
+ await this.copyDirectory(sourcePath, destPath);
369
+ } else {
370
+ // Copy file
371
+ await copyFile(sourcePath, destPath);
372
+ }
373
+ }
144
374
  }
145
375
 
146
376
  /**