claudekit-cli 1.4.1 → 1.5.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.
- package/bin/ck-darwin-arm64 +0 -0
- package/bin/ck-darwin-x64 +0 -0
- package/bin/ck-linux-x64 +0 -0
- package/bin/ck-win32-x64.exe +0 -0
- package/package.json +8 -2
- package/scripts/postinstall.js +74 -0
- package/.github/workflows/ci.yml +0 -45
- package/.github/workflows/claude-code-review.yml +0 -57
- package/.github/workflows/claude.yml +0 -50
- package/.github/workflows/release.yml +0 -102
- package/.releaserc.json +0 -17
- package/.repomixignore +0 -15
- package/AGENTS.md +0 -217
- package/CHANGELOG.md +0 -95
- package/CLAUDE.md +0 -34
- package/biome.json +0 -28
- package/bun.lock +0 -863
- package/dist/index.js +0 -22511
- package/src/commands/new.ts +0 -185
- package/src/commands/update.ts +0 -174
- package/src/commands/version.ts +0 -135
- package/src/index.ts +0 -102
- package/src/lib/auth.ts +0 -157
- package/src/lib/download.ts +0 -689
- package/src/lib/github.ts +0 -230
- package/src/lib/merge.ts +0 -119
- package/src/lib/prompts.ts +0 -114
- package/src/types.ts +0 -178
- package/src/utils/config.ts +0 -87
- package/src/utils/file-scanner.ts +0 -134
- package/src/utils/logger.ts +0 -124
- package/src/utils/safe-prompts.ts +0 -44
- package/src/utils/safe-spinner.ts +0 -38
- package/src/version.json +0 -3
- package/tests/commands/version.test.ts +0 -297
- package/tests/integration/cli.test.ts +0 -252
- package/tests/lib/auth.test.ts +0 -116
- package/tests/lib/download.test.ts +0 -292
- package/tests/lib/github-download-priority.test.ts +0 -432
- package/tests/lib/github.test.ts +0 -52
- package/tests/lib/merge.test.ts +0 -267
- package/tests/lib/prompts.test.ts +0 -66
- package/tests/types.test.ts +0 -337
- package/tests/utils/config.test.ts +0 -263
- package/tests/utils/file-scanner.test.ts +0 -202
- package/tests/utils/logger.test.ts +0 -239
- package/tsconfig.json +0 -30
package/src/lib/download.ts
DELETED
|
@@ -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
|
-
}
|