git-ripper 1.5.0 → 1.5.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/src/downloader.js CHANGED
@@ -1,878 +1,904 @@
1
- import axios from "axios";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import process from "node:process";
5
- import { fileURLToPath } from "node:url";
6
- import { dirname } from "node:path";
7
- import cliProgress from "cli-progress";
8
- import pLimit from "p-limit";
9
- import chalk from "chalk";
10
- import prettyBytes from "pretty-bytes";
11
- import { ResumeManager } from "./resumeManager.js";
12
-
13
- // Set concurrency limit (adjustable based on network performance)
14
- // Reduced from 500 to 5 to prevent GitHub API rate limiting
15
- const limit = pLimit(5);
16
-
17
- // Ensure __dirname and __filename are available in ESM
18
- const __filename = fileURLToPath(import.meta.url);
19
- const __dirname = dirname(__filename);
20
-
21
- // Define spinner animation frames
22
- const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
- // Alternative progress bar characters for more visual appeal
24
- const progressChars = {
25
- complete: "▰", // Alternative: '■', '●', '◆', '▣'
26
- incomplete: "▱", // Alternative: '□', '○', '◇', '▢'
27
- };
28
-
29
- // Track frame index for spinner animation
30
- let spinnerFrameIndex = 0;
31
-
32
- /**
33
- * Returns the next spinner frame for animation
34
- * @returns {string} - The spinner character
35
- */
36
- const getSpinnerFrame = () => {
37
- const frame = spinnerFrames[spinnerFrameIndex];
38
- spinnerFrameIndex = (spinnerFrameIndex + 1) % spinnerFrames.length;
39
- return frame;
40
- };
41
-
42
- /**
43
- * Fetches the contents of a folder from a GitHub repository
44
- * @param {string} owner - Repository owner
45
- * @param {string} repo - Repository name
46
- * @param {string} branch - Branch name
47
- * @param {string} folderPath - Path to the folder
48
- * @param {string} [token] - GitHub Personal Access Token
49
- * @returns {Promise<Array>} - Promise resolving to an array of file objects
50
- * @throws {Error} - Throws error on API failures instead of returning empty array
51
- */
52
- const fetchFolderContents = async (owner, repo, branch, folderPath, token) => {
53
- const headers = {
54
- Accept: "application/vnd.github+json",
55
- "X-GitHub-Api-Version": "2022-11-28",
56
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
57
- };
58
- let effectiveBranch = branch;
59
- if (!effectiveBranch) {
60
- // If no branch is specified, fetch the default branch for the repository
61
- try {
62
- const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
63
- owner
64
- )}/${encodeURIComponent(repo)}`;
65
- const repoInfoResponse = await axios.get(repoInfoUrl, { headers });
66
- effectiveBranch = repoInfoResponse.data.default_branch;
67
- if (!effectiveBranch) {
68
- throw new Error(
69
- `Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
70
- );
71
- }
72
- console.log(
73
- chalk.blue(
74
- `No branch specified, using default branch: ${effectiveBranch}`
75
- )
76
- );
77
- } catch (error) {
78
- if (error.message.includes("Could not determine default branch")) {
79
- throw error;
80
- }
81
- throw new Error(
82
- `Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
83
- );
84
- }
85
- }
86
-
87
- const apiUrl = `https://api.github.com/repos/${encodeURIComponent(
88
- owner
89
- )}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(
90
- effectiveBranch
91
- )}?recursive=1`;
92
-
93
- try {
94
- const response = await axios.get(apiUrl, { headers });
95
-
96
- // Check if GitHub API returned truncated results
97
- if (response.data.truncated) {
98
- console.warn(
99
- chalk.yellow(
100
- `Warning: The repository is too large and some files may be missing. ` +
101
- `Consider using git clone for complete repositories.`
102
- )
103
- );
104
- }
105
-
106
- // Original filter:
107
- // return response.data.tree.filter((item) =>
108
- // item.path.startsWith(folderPath)
109
- // );
110
-
111
- // New filter logic:
112
- if (folderPath === "") {
113
- // For the root directory, all items from the recursive tree are relevant.
114
- // item.path.startsWith("") would also achieve this.
115
- return response.data.tree;
116
- } else {
117
- // For a specific folder, items must be *inside* that folder.
118
- // Ensure folderPath is treated as a directory prefix by adding a trailing slash if not present.
119
- const prefix = folderPath.endsWith("/") ? folderPath : folderPath + "/";
120
- return response.data.tree.filter((item) => item.path.startsWith(prefix));
121
- }
122
- } catch (error) {
123
- let errorMessage = "";
124
- let isRateLimit = false;
125
-
126
- if (error.response) {
127
- // Handle specific HTTP error codes
128
- switch (error.response.status) {
129
- case 403:
130
- if (error.response.headers["x-ratelimit-remaining"] === "0") {
131
- isRateLimit = true;
132
- errorMessage = `GitHub API rate limit exceeded. Please wait until ${new Date(
133
- parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
134
- ).toLocaleTimeString()} or add a GitHub token (feature coming soon).`;
135
- } else {
136
- errorMessage = `Access forbidden: ${
137
- error.response.data.message ||
138
- "Repository may be private or you may not have access"
139
- }`;
140
- }
141
- break;
142
- case 404:
143
- errorMessage = `Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`;
144
- break;
145
- default:
146
- errorMessage = `API error (${error.response.status}): ${
147
- error.response.data.message || error.message
148
- }`;
149
- }
150
- } else if (error.request) {
151
- errorMessage = `Network error: No response received from GitHub. Please check your internet connection.`;
152
- } else {
153
- errorMessage = `Error preparing request: ${error.message}`;
154
- }
155
-
156
- // Always throw the error instead of returning empty array
157
- const enrichedError = new Error(errorMessage);
158
- enrichedError.isRateLimit = isRateLimit;
159
- enrichedError.statusCode = error.response?.status;
160
- throw enrichedError;
161
- }
162
- };
163
-
164
- /**
165
- * Downloads a single file from a GitHub repository
166
- * @param {string} owner - Repository owner
167
- * @param {string} repo - Repository name
168
- * @param {string} branch - Branch name
169
- * @param {string} filePath - Path to the file
170
- * @param {string} outputPath - Path where the file should be saved
171
- * @param {string} [token] - GitHub Personal Access Token
172
- * @returns {Promise<Object>} - Object containing download status
173
- */
174
- const downloadFile = async (owner, repo, branch, filePath, outputPath, token) => {
175
- const headers = {
176
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
177
- };
178
- let effectiveBranch = branch;
179
- if (!effectiveBranch) {
180
- // If no branch is specified, fetch the default branch for the repository
181
- // This check might be redundant if fetchFolderContents already resolved it,
182
- // but it's a good fallback for direct downloadFile calls if any.
183
- try {
184
- const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
185
- owner
186
- )}/${encodeURIComponent(repo)}`;
187
- const repoInfoResponse = await axios.get(repoInfoUrl, { headers });
188
- effectiveBranch = repoInfoResponse.data.default_branch;
189
- if (!effectiveBranch) {
190
- // console.error(chalk.red(`Could not determine default branch for ${owner}/${repo} for file ${filePath}.`));
191
- // Do not log error here as it might be a root file download where branch is not in URL
192
- }
193
- } catch (error) {
194
- // console.error(chalk.red(`Failed to fetch default branch for ${owner}/${repo} for file ${filePath}: ${error.message}`));
195
- // Do not log error here
196
- }
197
- // If still no branch, the raw URL might work for default branch, or fail.
198
- // The original code didn't explicitly handle this for downloadFile, relying on raw.githubusercontent default behavior.
199
- // For robustness, we should ensure effectiveBranch is set. If not, the URL will be malformed or use GitHub's default.
200
- if (!effectiveBranch) {
201
- // Fallback to a common default, or let the API call fail if truly ambiguous
202
- // For raw content, GitHub often defaults to the main branch if not specified,
203
- // but it's better to be explicit if we can.
204
- // However, altering the URL structure for raw.githubusercontent.com without a branch
205
- // might be tricky if the original URL didn't have it.
206
- // The existing raw URL construction assumes branch is present or GitHub handles its absence.
207
- // Let's stick to the original logic for raw URL construction if branch is not found,
208
- // as `https://raw.githubusercontent.com/${owner}/${repo}/${filePath}` might work for root files on default branch.
209
- // The critical part is `fetchFolderContents` determining the branch for listing.
210
- }
211
- }
212
-
213
- const baseUrl = `https://raw.githubusercontent.com/${encodeURIComponent(
214
- owner
215
- )}/${encodeURIComponent(repo)}`;
216
- const encodedFilePath = filePath
217
- .split("/")
218
- .map((part) => encodeURIComponent(part))
219
- .join("/");
220
- const fileUrlPath = effectiveBranch
221
- ? `/${encodeURIComponent(effectiveBranch)}/${encodedFilePath}`
222
- : `/${encodedFilePath}`; // filePath might be at root
223
- const url = `${baseUrl}${fileUrlPath}`;
224
-
225
- try {
226
- const response = await axios.get(url, { responseType: "arraybuffer", headers });
227
-
228
- // Ensure the directory exists
229
- try {
230
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
231
- } catch (dirError) {
232
- return {
233
- filePath,
234
- success: false,
235
- error: `Failed to create directory: ${dirError.message}`,
236
- size: 0,
237
- };
238
- }
239
-
240
- // Write the file
241
- try {
242
- fs.writeFileSync(outputPath, Buffer.from(response.data));
243
- } catch (fileError) {
244
- return {
245
- filePath,
246
- success: false,
247
- error: `Failed to write file: ${fileError.message}`,
248
- size: 0,
249
- };
250
- }
251
-
252
- return {
253
- filePath,
254
- success: true,
255
- size: response.data.length,
256
- };
257
- } catch (error) {
258
- // More detailed error handling for network requests
259
- let errorMessage = error.message;
260
-
261
- if (error.response) {
262
- // The request was made and the server responded with an error status
263
- switch (error.response.status) {
264
- case 403:
265
- errorMessage = "Access forbidden (possibly rate limited)";
266
- break;
267
- case 404:
268
- errorMessage = "File not found";
269
- break;
270
- default:
271
- errorMessage = `HTTP error ${error.response.status}`;
272
- }
273
- } else if (error.request) {
274
- // The request was made but no response was received
275
- errorMessage = "No response from server";
276
- }
277
-
278
- return {
279
- filePath,
280
- success: false,
281
- error: errorMessage,
282
- size: 0,
283
- };
284
- }
285
- };
286
-
287
- /**
288
- * Creates a simplified progress bar renderer with animation
289
- * @param {string} owner - Repository owner
290
- * @param {string} repo - Repository name
291
- * @param {string} folderPath - Path to the folder
292
- * @returns {Function} - Function to render progress bar
293
- */
294
- const createProgressRenderer = (owner, repo, folderPath) => {
295
- // Default terminal width
296
- const terminalWidth = process.stdout.columns || 80;
297
-
298
- return (options, params, payload) => {
299
- try {
300
- const { value, total, startTime } = params;
301
- const { downloadedSize = 0 } = payload || { downloadedSize: 0 };
302
-
303
- // Calculate progress percentage
304
- const progress = Math.min(1, Math.max(0, value / Math.max(1, total)));
305
- const percentage = Math.floor(progress * 100);
306
-
307
- // Calculate elapsed time
308
- const elapsedSecs = Math.max(0.1, (Date.now() - startTime) / 1000);
309
-
310
- // Create the progress bar
311
- const barLength = Math.max(
312
- 20,
313
- Math.min(40, Math.floor(terminalWidth / 2))
314
- );
315
- const completedLength = Math.round(barLength * progress);
316
- const remainingLength = barLength - completedLength;
317
-
318
- // Build the bar with custom progress characters
319
- const completedBar = chalk.greenBright(
320
- progressChars.complete.repeat(completedLength)
321
- );
322
- const remainingBar = chalk.gray(
323
- progressChars.incomplete.repeat(remainingLength)
324
- );
325
-
326
- // Add spinner for animation
327
- const spinner = chalk.cyanBright(getSpinnerFrame());
328
-
329
- // Format the output
330
- const progressInfo = `${chalk.cyan(`${value}/${total}`)} files`;
331
- const sizeInfo = prettyBytes(downloadedSize || 0);
332
-
333
- return `${spinner} ${completedBar}${remainingBar} ${chalk.yellow(
334
- percentage + "%"
335
- )} | ${progressInfo} | ${chalk.magenta(sizeInfo)}`;
336
- } catch (error) {
337
- // Fallback to a very simple progress indicator
338
- return `${Math.floor((params.value / params.total) * 100)}% complete`;
339
- }
340
- };
341
- };
342
-
343
- /**
344
- * Downloads all files from a folder in a GitHub repository
345
- * @param {Object} repoInfo - Object containing repository information
346
- * @param {string} repoInfo.owner - Repository owner
347
- * @param {string} repoInfo.repo - Repository name
348
- * @param {string} repoInfo.branch - Branch name
349
- * @param {string} repoInfo.folderPath - Path to the folder
350
- * @param {string} outputDir - Directory where files should be saved
351
- * @param {Object} [options] - Download options
352
- * @param {string} [options.token] - GitHub Personal Access Token
353
- * @returns {Promise<void>} - Promise that resolves when all files are downloaded
354
- */
355
- const downloadFolder = async (
356
- { owner, repo, branch, folderPath },
357
- outputDir,
358
- options = {}
359
- ) => {
360
- const { token } = options;
361
- console.log(
362
- chalk.cyan(`Analyzing repository structure for ${owner}/${repo}...`)
363
- );
364
-
365
- try {
366
- const contents = await fetchFolderContents(owner, repo, branch, folderPath, token);
367
-
368
- if (!contents || contents.length === 0) {
369
- const message = `No files found in ${folderPath || "repository root"}`;
370
- console.log(chalk.yellow(message));
371
- // Don't print success message when no files are found - this might indicate an error
372
- return {
373
- success: true,
374
- filesDownloaded: 0,
375
- failedFiles: 0,
376
- isEmpty: true,
377
- };
378
- }
379
-
380
- // Filter for blob type (files)
381
- const files = contents.filter((item) => item.type === "blob");
382
- const totalFiles = files.length;
383
-
384
- if (totalFiles === 0) {
385
- const message = `No files found in ${
386
- folderPath || "repository root"
387
- } (only directories)`;
388
- console.log(chalk.yellow(message));
389
- // This is a legitimate case - directory exists but contains only subdirectories
390
- console.log(chalk.green(`Directory structure downloaded successfully!`));
391
- return {
392
- success: true,
393
- filesDownloaded: 0,
394
- failedFiles: 0,
395
- isEmpty: true,
396
- };
397
- }
398
-
399
- console.log(
400
- chalk.cyan(
401
- `Downloading ${totalFiles} files from ${chalk.white(
402
- owner + "/" + repo
403
- )}...`
404
- )
405
- );
406
-
407
- // Simplified progress bar setup
408
- const progressBar = new cliProgress.SingleBar({
409
- format: createProgressRenderer(owner, repo, folderPath),
410
- hideCursor: true,
411
- clearOnComplete: false,
412
- stopOnComplete: true,
413
- forceRedraw: true,
414
- });
415
-
416
- // Track download metrics
417
- let downloadedSize = 0;
418
- const startTime = Date.now();
419
- let failedFiles = [];
420
-
421
- // Start progress bar
422
- progressBar.start(totalFiles, 0, {
423
- downloadedSize: 0,
424
- startTime,
425
- });
426
-
427
- // Create download promises with concurrency control
428
- const fileDownloadPromises = files.map((item) => {
429
- // Keep the original structure by preserving the folder name
430
- let relativePath = item.path;
431
- if (folderPath && folderPath.trim() !== "") {
432
- relativePath = item.path
433
- .substring(folderPath.length)
434
- .replace(/^\//, "");
435
- }
436
- const outputFilePath = path.join(outputDir, relativePath);
437
-
438
- return limit(async () => {
439
- try {
440
- const result = await downloadFile(
441
- owner,
442
- repo,
443
- branch,
444
- item.path,
445
- outputFilePath,
446
- token
447
- );
448
-
449
- // Update progress metrics
450
- if (result.success) {
451
- downloadedSize += result.size || 0;
452
- } else {
453
- // Track failed files for reporting
454
- failedFiles.push({
455
- path: item.path,
456
- error: result.error,
457
- });
458
- }
459
-
460
- // Update progress bar with current metrics
461
- progressBar.increment(1, {
462
- downloadedSize,
463
- });
464
-
465
- return result;
466
- } catch (error) {
467
- failedFiles.push({
468
- path: item.path,
469
- error: error.message,
470
- });
471
-
472
- progressBar.increment(1, { downloadedSize });
473
- return {
474
- filePath: item.path,
475
- success: false,
476
- error: error.message,
477
- size: 0,
478
- };
479
- }
480
- });
481
- });
482
-
483
- // Execute downloads in parallel with controlled concurrency
484
- const results = await Promise.all(fileDownloadPromises);
485
- progressBar.stop();
486
-
487
- console.log(); // Add an empty line after progress bar
488
-
489
- // Count successful and failed downloads
490
- const succeeded = results.filter((r) => r.success).length;
491
- const failed = failedFiles.length;
492
- if (failed > 0) {
493
- console.log(
494
- chalk.yellow(
495
- `Downloaded ${succeeded} files successfully, ${failed} files failed`
496
- )
497
- );
498
-
499
- // Show detailed errors if there aren't too many
500
- if (failed <= 5) {
501
- console.log(chalk.yellow("Failed files:"));
502
- failedFiles.forEach((file) => {
503
- console.log(chalk.yellow(` - ${file.path}: ${file.error}`));
504
- });
505
- } else {
506
- console.log(
507
- chalk.yellow(
508
- `${failed} files failed to download. Check your connection or repository access.`
509
- )
510
- );
511
- }
512
-
513
- // Don't claim success if files failed to download
514
- if (succeeded === 0) {
515
- console.log(
516
- chalk.red(`Download failed: No files were downloaded successfully`)
517
- );
518
- return {
519
- success: false,
520
- filesDownloaded: succeeded,
521
- failedFiles: failed,
522
- isEmpty: false,
523
- };
524
- } else {
525
- console.log(chalk.yellow(`Download completed with errors`));
526
- return {
527
- success: false,
528
- filesDownloaded: succeeded,
529
- failedFiles: failed,
530
- isEmpty: false,
531
- };
532
- }
533
- } else {
534
- console.log(
535
- chalk.green(`All ${succeeded} files downloaded successfully!`)
536
- );
537
- console.log(chalk.green(`Folder cloned successfully!`));
538
- return {
539
- success: true,
540
- filesDownloaded: succeeded,
541
- failedFiles: failed,
542
- isEmpty: false,
543
- };
544
- }
545
- } catch (error) {
546
- // Log the specific error details
547
- console.error(chalk.red(`Error downloading folder: ${error.message}`));
548
-
549
- // Re-throw the error so the main CLI can exit with proper error code
550
- throw error;
551
- }
552
- };
553
-
554
- // Export functions in ESM format
555
- export { downloadFolder, downloadFolderWithResume, downloadFile };
556
-
557
- /**
558
- * Downloads all files from a folder in a GitHub repository with resume capability
559
- */
560
- const downloadFolderWithResume = async (
561
- { owner, repo, branch, folderPath },
562
- outputDir,
563
- options = { resume: true, forceRestart: false }
564
- ) => {
565
- const { resume = true, forceRestart = false, token } = options;
566
-
567
- if (!resume) {
568
- return downloadFolder({ owner, repo, branch, folderPath }, outputDir, options);
569
- }
570
-
571
- const resumeManager = new ResumeManager();
572
- const encodedFolderPath = folderPath
573
- ? folderPath
574
- .split("/")
575
- .map((part) => encodeURIComponent(part))
576
- .join("/")
577
- : "";
578
- const url = `https://github.com/${encodeURIComponent(
579
- owner
580
- )}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(
581
- branch || "main"
582
- )}/${encodedFolderPath}`;
583
-
584
- // Clear checkpoint if force restart is requested
585
- if (forceRestart) {
586
- resumeManager.cleanupCheckpoint(url, outputDir);
587
- }
588
-
589
- // Check for existing checkpoint
590
- let checkpoint = resumeManager.loadCheckpoint(url, outputDir);
591
-
592
- if (checkpoint) {
593
- console.log(
594
- chalk.blue(
595
- `Found previous download from ${new Date(
596
- checkpoint.timestamp
597
- ).toLocaleString()}`
598
- )
599
- );
600
- console.log(
601
- chalk.blue(
602
- `Progress: ${checkpoint.downloadedFiles.length}/${checkpoint.totalFiles} files completed`
603
- )
604
- );
605
-
606
- // Verify integrity of existing files
607
- const validFiles = [];
608
- let corruptedCount = 0;
609
-
610
- for (const filename of checkpoint.downloadedFiles) {
611
- const filepath = path.join(outputDir, filename);
612
- const expectedHash = checkpoint.fileHashes[filename];
613
-
614
- if (
615
- expectedHash &&
616
- resumeManager.verifyFileIntegrity(filepath, expectedHash)
617
- ) {
618
- validFiles.push(filename);
619
- } else {
620
- corruptedCount++;
621
- }
622
- }
623
-
624
- checkpoint.downloadedFiles = validFiles;
625
- if (corruptedCount > 0) {
626
- console.log(
627
- chalk.yellow(
628
- `Detected ${corruptedCount} corrupted files, will re-download`
629
- )
630
- );
631
- }
632
- console.log(chalk.green(`Verified ${validFiles.length} existing files`));
633
- }
634
-
635
- console.log(
636
- chalk.cyan(`Analyzing repository structure for ${owner}/${repo}...`)
637
- );
638
-
639
- try {
640
- const contents = await fetchFolderContents(owner, repo, branch, folderPath, token);
641
- if (!contents || contents.length === 0) {
642
- const message = `No files found in ${folderPath || "repository root"}`;
643
- console.log(chalk.yellow(message));
644
- // Don't print success message when no files are found - this might indicate an error
645
- return {
646
- success: true,
647
- filesDownloaded: 0,
648
- failedFiles: 0,
649
- isEmpty: true,
650
- };
651
- }
652
-
653
- // Filter for blob type (files)
654
- const files = contents.filter((item) => item.type === "blob");
655
- const totalFiles = files.length;
656
-
657
- if (totalFiles === 0) {
658
- const message = `No files found in ${
659
- folderPath || "repository root"
660
- } (only directories)`;
661
- console.log(chalk.yellow(message));
662
- // This is a legitimate case - directory exists but contains only subdirectories
663
- console.log(chalk.green(`Directory structure downloaded successfully!`));
664
- return {
665
- success: true,
666
- filesDownloaded: 0,
667
- failedFiles: 0,
668
- isEmpty: true,
669
- };
670
- }
671
-
672
- // Create new checkpoint if none exists
673
- if (!checkpoint) {
674
- checkpoint = resumeManager.createNewCheckpoint(
675
- url,
676
- outputDir,
677
- totalFiles
678
- );
679
- console.log(
680
- chalk.cyan(
681
- `Starting download of ${totalFiles} files from ${chalk.white(
682
- owner + "/" + repo
683
- )}...`
684
- )
685
- );
686
- } else {
687
- // Update total files in case repository changed
688
- checkpoint.totalFiles = totalFiles;
689
- console.log(chalk.cyan(`Resuming download...`));
690
- }
691
-
692
- // Get remaining files to download
693
- const remainingFiles = files.filter((item) => {
694
- let relativePath = item.path;
695
- if (folderPath && folderPath.trim() !== "") {
696
- relativePath = item.path
697
- .substring(folderPath.length)
698
- .replace(/^\//, "");
699
- }
700
- return !checkpoint.downloadedFiles.includes(relativePath);
701
- });
702
-
703
- if (remainingFiles.length === 0) {
704
- console.log(chalk.green(`All files already downloaded!`));
705
- resumeManager.cleanupCheckpoint(url, outputDir);
706
- return;
707
- }
708
-
709
- console.log(
710
- chalk.cyan(`Downloading ${remainingFiles.length} remaining files...`)
711
- );
712
-
713
- // Setup progress bar
714
- const progressBar = new cliProgress.SingleBar({
715
- format: createProgressRenderer(owner, repo, folderPath),
716
- hideCursor: true,
717
- clearOnComplete: false,
718
- stopOnComplete: true,
719
- forceRedraw: true,
720
- });
721
-
722
- // Calculate already downloaded size
723
- let downloadedSize = 0;
724
- for (const filename of checkpoint.downloadedFiles) {
725
- const filepath = path.join(outputDir, filename);
726
- try {
727
- downloadedSize += fs.statSync(filepath).size;
728
- } catch {
729
- // File might be missing, will be re-downloaded
730
- }
731
- }
732
-
733
- const startTime = Date.now();
734
- let failedFiles = [...(checkpoint.failedFiles || [])];
735
-
736
- // Start progress bar with current progress
737
- progressBar.start(totalFiles, checkpoint.downloadedFiles.length, {
738
- downloadedSize,
739
- startTime,
740
- });
741
-
742
- // Process remaining files
743
- let processedCount = 0;
744
- for (const item of remainingFiles) {
745
- try {
746
- let relativePath = item.path;
747
- if (folderPath && folderPath.trim() !== "") {
748
- relativePath = item.path
749
- .substring(folderPath.length)
750
- .replace(/^\//, "");
751
- }
752
- const outputFilePath = path.join(outputDir, relativePath);
753
-
754
- const result = await downloadFile(
755
- owner,
756
- repo,
757
- branch,
758
- item.path,
759
- outputFilePath,
760
- token
761
- );
762
-
763
- if (result.success) {
764
- // Calculate file hash for integrity checking
765
- const fileContent = fs.readFileSync(outputFilePath);
766
- const fileHash = resumeManager.calculateHash(fileContent);
767
-
768
- // Update checkpoint
769
- checkpoint.downloadedFiles.push(relativePath);
770
- checkpoint.fileHashes[relativePath] = fileHash;
771
- downloadedSize += result.size || 0;
772
- } else {
773
- // Track failed files
774
- failedFiles.push({
775
- path: relativePath,
776
- error: result.error,
777
- });
778
- checkpoint.failedFiles = failedFiles;
779
- }
780
-
781
- processedCount++;
782
-
783
- // Save checkpoint every 10 files
784
- if (processedCount % 10 === 0) {
785
- resumeManager.saveCheckpoint(checkpoint);
786
- }
787
-
788
- // Update progress bar
789
- progressBar.increment(1, { downloadedSize });
790
- } catch (error) {
791
- // Handle interruption gracefully
792
- if (error.name === "SIGINT") {
793
- resumeManager.saveCheckpoint(checkpoint);
794
- progressBar.stop();
795
- console.log(chalk.blue(`\nDownload interrupted. Progress saved.`));
796
- console.log(chalk.blue(`Run the same command again to resume.`));
797
- return;
798
- }
799
-
800
- failedFiles.push({
801
- path: item.path,
802
- error: error.message,
803
- });
804
- checkpoint.failedFiles = failedFiles;
805
- progressBar.increment(1, { downloadedSize });
806
- }
807
- }
808
-
809
- progressBar.stop();
810
- console.log(); // Add an empty line after progress bar
811
-
812
- // Final checkpoint save
813
- resumeManager.saveCheckpoint(checkpoint);
814
-
815
- // Count results
816
- const succeeded = checkpoint.downloadedFiles.length;
817
- const failed = failedFiles.length;
818
- if (failed > 0) {
819
- console.log(
820
- chalk.yellow(
821
- `Downloaded ${succeeded} files successfully, ${failed} files failed`
822
- )
823
- );
824
-
825
- if (failed <= 5) {
826
- console.log(chalk.yellow("Failed files:"));
827
- failedFiles.forEach((file) => {
828
- console.log(chalk.yellow(` - ${file.path}: ${file.error}`));
829
- });
830
- }
831
-
832
- console.log(
833
- chalk.blue(`Run the same command again to retry failed downloads`)
834
- );
835
-
836
- // Don't claim success if files failed to download
837
- if (succeeded === 0) {
838
- console.log(
839
- chalk.red(`Download failed: No files were downloaded successfully`)
840
- );
841
- return {
842
- success: false,
843
- filesDownloaded: succeeded,
844
- failedFiles: failed,
845
- isEmpty: false,
846
- };
847
- } else {
848
- console.log(chalk.yellow(`Download completed with errors`));
849
- return {
850
- success: false,
851
- filesDownloaded: succeeded,
852
- failedFiles: failed,
853
- isEmpty: false,
854
- };
855
- }
856
- } else {
857
- console.log(
858
- chalk.green(`All ${succeeded} files downloaded successfully!`)
859
- );
860
- resumeManager.cleanupCheckpoint(url, outputDir);
861
- console.log(chalk.green(`Folder cloned successfully!`));
862
- return {
863
- success: true,
864
- filesDownloaded: succeeded,
865
- failedFiles: failed,
866
- isEmpty: false,
867
- };
868
- }
869
- } catch (error) {
870
- // Save checkpoint on any error
871
- if (checkpoint) {
872
- resumeManager.saveCheckpoint(checkpoint);
873
- }
874
-
875
- console.error(chalk.red(`Error downloading folder: ${error.message}`));
876
- throw error;
877
- }
878
- };
1
+ import axios from "axios";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname } from "node:path";
7
+ import cliProgress from "cli-progress";
8
+ import pLimit from "p-limit";
9
+ import chalk from "chalk";
10
+ import prettyBytes from "pretty-bytes";
11
+ import { ResumeManager } from "./resumeManager.js";
12
+
13
+ // Set concurrency limit (adjustable based on network performance)
14
+ // Reduced from 500 to 5 to prevent GitHub API rate limiting
15
+ const limit = pLimit(5);
16
+
17
+ // Ensure __dirname and __filename are available in ESM
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ // Define spinner animation frames
22
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
+ // Alternative progress bar characters for more visual appeal
24
+ const progressChars = {
25
+ complete: "▰", // Alternative: '■', '●', '◆', '▣'
26
+ incomplete: "▱", // Alternative: '□', '○', '◇', '▢'
27
+ };
28
+
29
+ // Track frame index for spinner animation
30
+ let spinnerFrameIndex = 0;
31
+
32
+ /**
33
+ * Returns the next spinner frame for animation
34
+ * @returns {string} - The spinner character
35
+ */
36
+ const getSpinnerFrame = () => {
37
+ const frame = spinnerFrames[spinnerFrameIndex];
38
+ spinnerFrameIndex = (spinnerFrameIndex + 1) % spinnerFrames.length;
39
+ return frame;
40
+ };
41
+
42
+ /**
43
+ * Fetches the contents of a folder from a GitHub repository
44
+ * @param {string} owner - Repository owner
45
+ * @param {string} repo - Repository name
46
+ * @param {string} branch - Branch name
47
+ * @param {string} folderPath - Path to the folder
48
+ * @param {string} [token] - GitHub Personal Access Token
49
+ * @returns {Promise<Array>} - Promise resolving to an array of file objects
50
+ * @throws {Error} - Throws error on API failures instead of returning empty array
51
+ */
52
+ const fetchFolderContents = async (owner, repo, branch, folderPath, token) => {
53
+ const headers = {
54
+ Accept: "application/vnd.github+json",
55
+ "X-GitHub-Api-Version": "2022-11-28",
56
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
57
+ };
58
+ let effectiveBranch = branch;
59
+ if (!effectiveBranch) {
60
+ // If no branch is specified, fetch the default branch for the repository
61
+ try {
62
+ const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
63
+ owner
64
+ )}/${encodeURIComponent(repo)}`;
65
+ const repoInfoResponse = await axios.get(repoInfoUrl, { headers });
66
+ effectiveBranch = repoInfoResponse.data.default_branch;
67
+ if (!effectiveBranch) {
68
+ throw new Error(
69
+ `Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
70
+ );
71
+ }
72
+ console.log(
73
+ chalk.blue(
74
+ `No branch specified, using default branch: ${effectiveBranch}`
75
+ )
76
+ );
77
+ } catch (error) {
78
+ if (error.message.includes("Could not determine default branch")) {
79
+ throw error;
80
+ }
81
+ throw new Error(
82
+ `Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
83
+ );
84
+ }
85
+ }
86
+
87
+ const apiUrl = `https://api.github.com/repos/${encodeURIComponent(
88
+ owner
89
+ )}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(
90
+ effectiveBranch
91
+ )}?recursive=1`;
92
+
93
+ try {
94
+ const response = await axios.get(apiUrl, { headers });
95
+
96
+ // Check if GitHub API returned truncated results
97
+ if (response.data.truncated) {
98
+ console.warn(
99
+ chalk.yellow(
100
+ `Warning: The repository is too large and some files may be missing. ` +
101
+ `Consider using git clone for complete repositories.`
102
+ )
103
+ );
104
+ }
105
+
106
+ // Original filter:
107
+ // return response.data.tree.filter((item) =>
108
+ // item.path.startsWith(folderPath)
109
+ // );
110
+
111
+ // New filter logic:
112
+ if (folderPath === "") {
113
+ // For the root directory, all items from the recursive tree are relevant.
114
+ // item.path.startsWith("") would also achieve this.
115
+ return response.data.tree;
116
+ } else {
117
+ // For a specific folder, items must be *inside* that folder.
118
+ // Ensure folderPath is treated as a directory prefix by adding a trailing slash if not present.
119
+ const prefix = folderPath.endsWith("/") ? folderPath : folderPath + "/";
120
+ return response.data.tree.filter((item) => item.path.startsWith(prefix));
121
+ }
122
+ } catch (error) {
123
+ let errorMessage = "";
124
+ let isRateLimit = false;
125
+
126
+ if (error.response) {
127
+ // Handle specific HTTP error codes
128
+ switch (error.response.status) {
129
+ case 403:
130
+ if (error.response.headers["x-ratelimit-remaining"] === "0") {
131
+ isRateLimit = true;
132
+ errorMessage = `GitHub API rate limit exceeded. Please wait until ${new Date(
133
+ parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
134
+ ).toLocaleTimeString()} or use the --gh-token option to increase your rate limit.`;
135
+ } else {
136
+ errorMessage = `Access forbidden: ${
137
+ error.response.data.message ||
138
+ "Repository may be private or you may not have access"
139
+ }`;
140
+ }
141
+ break;
142
+ case 404:
143
+ errorMessage = `Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`;
144
+ break;
145
+ default:
146
+ errorMessage = `API error (${error.response.status}): ${
147
+ error.response.data.message || error.message
148
+ }`;
149
+ }
150
+ } else if (error.request) {
151
+ errorMessage = `Network error: No response received from GitHub. Please check your internet connection.`;
152
+ } else {
153
+ errorMessage = `Error preparing request: ${error.message}`;
154
+ }
155
+
156
+ // Always throw the error instead of returning empty array
157
+ const enrichedError = new Error(errorMessage);
158
+ enrichedError.isRateLimit = isRateLimit;
159
+ enrichedError.statusCode = error.response?.status;
160
+ throw enrichedError;
161
+ }
162
+ };
163
+
164
+ /**
165
+ * Downloads a single file from a GitHub repository
166
+ * @param {string} owner - Repository owner
167
+ * @param {string} repo - Repository name
168
+ * @param {string} branch - Branch name
169
+ * @param {string} filePath - Path to the file
170
+ * @param {string} outputPath - Path where the file should be saved
171
+ * @param {string} [token] - GitHub Personal Access Token
172
+ * @returns {Promise<Object>} - Object containing download status
173
+ */
174
+ const downloadFile = async (
175
+ owner,
176
+ repo,
177
+ branch,
178
+ filePath,
179
+ outputPath,
180
+ token
181
+ ) => {
182
+ const headers = {
183
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
184
+ };
185
+ let effectiveBranch = branch;
186
+ if (!effectiveBranch) {
187
+ // If no branch is specified, fetch the default branch for the repository
188
+ // This check might be redundant if fetchFolderContents already resolved it,
189
+ // but it's a good fallback for direct downloadFile calls if any.
190
+ try {
191
+ const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
192
+ owner
193
+ )}/${encodeURIComponent(repo)}`;
194
+ const repoInfoResponse = await axios.get(repoInfoUrl, { headers });
195
+ effectiveBranch = repoInfoResponse.data.default_branch;
196
+ if (!effectiveBranch) {
197
+ // console.error(chalk.red(`Could not determine default branch for ${owner}/${repo} for file ${filePath}.`));
198
+ // Do not log error here as it might be a root file download where branch is not in URL
199
+ }
200
+ } catch (error) {
201
+ // console.error(chalk.red(`Failed to fetch default branch for ${owner}/${repo} for file ${filePath}: ${error.message}`));
202
+ // Do not log error here
203
+ }
204
+ // If still no branch, the raw URL might work for default branch, or fail.
205
+ // The original code didn't explicitly handle this for downloadFile, relying on raw.githubusercontent default behavior.
206
+ // For robustness, we should ensure effectiveBranch is set. If not, the URL will be malformed or use GitHub's default.
207
+ if (!effectiveBranch) {
208
+ // Fallback to a common default, or let the API call fail if truly ambiguous
209
+ // For raw content, GitHub often defaults to the main branch if not specified,
210
+ // but it's better to be explicit if we can.
211
+ // However, altering the URL structure for raw.githubusercontent.com without a branch
212
+ // might be tricky if the original URL didn't have it.
213
+ // The existing raw URL construction assumes branch is present or GitHub handles its absence.
214
+ // Let's stick to the original logic for raw URL construction if branch is not found,
215
+ // as `https://raw.githubusercontent.com/${owner}/${repo}/${filePath}` might work for root files on default branch.
216
+ // The critical part is `fetchFolderContents` determining the branch for listing.
217
+ }
218
+ }
219
+
220
+ const baseUrl = `https://raw.githubusercontent.com/${encodeURIComponent(
221
+ owner
222
+ )}/${encodeURIComponent(repo)}`;
223
+ const encodedFilePath = filePath
224
+ .split("/")
225
+ .map((part) => encodeURIComponent(part))
226
+ .join("/");
227
+ const fileUrlPath = effectiveBranch
228
+ ? `/${encodeURIComponent(effectiveBranch)}/${encodedFilePath}`
229
+ : `/${encodedFilePath}`; // filePath might be at root
230
+ const url = `${baseUrl}${fileUrlPath}`;
231
+
232
+ try {
233
+ const response = await axios.get(url, {
234
+ responseType: "arraybuffer",
235
+ headers,
236
+ });
237
+
238
+ // Ensure the directory exists
239
+ try {
240
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
241
+ } catch (dirError) {
242
+ return {
243
+ filePath,
244
+ success: false,
245
+ error: `Failed to create directory: ${dirError.message}`,
246
+ size: 0,
247
+ };
248
+ }
249
+
250
+ // Write the file
251
+ try {
252
+ fs.writeFileSync(outputPath, Buffer.from(response.data));
253
+ } catch (fileError) {
254
+ return {
255
+ filePath,
256
+ success: false,
257
+ error: `Failed to write file: ${fileError.message}`,
258
+ size: 0,
259
+ };
260
+ }
261
+
262
+ return {
263
+ filePath,
264
+ success: true,
265
+ size: response.data.length,
266
+ };
267
+ } catch (error) {
268
+ // More detailed error handling for network requests
269
+ let errorMessage = error.message;
270
+
271
+ if (error.response) {
272
+ // The request was made and the server responded with an error status
273
+ switch (error.response.status) {
274
+ case 403:
275
+ errorMessage = "Access forbidden (possibly rate limited)";
276
+ break;
277
+ case 404:
278
+ errorMessage = "File not found";
279
+ break;
280
+ default:
281
+ errorMessage = `HTTP error ${error.response.status}`;
282
+ }
283
+ } else if (error.request) {
284
+ // The request was made but no response was received
285
+ errorMessage = "No response from server";
286
+ }
287
+
288
+ return {
289
+ filePath,
290
+ success: false,
291
+ error: errorMessage,
292
+ size: 0,
293
+ };
294
+ }
295
+ };
296
+
297
+ /**
298
+ * Creates a simplified progress bar renderer with animation
299
+ * @param {string} owner - Repository owner
300
+ * @param {string} repo - Repository name
301
+ * @param {string} folderPath - Path to the folder
302
+ * @returns {Function} - Function to render progress bar
303
+ */
304
+ const createProgressRenderer = (owner, repo, folderPath) => {
305
+ // Default terminal width
306
+ const terminalWidth = process.stdout.columns || 80;
307
+
308
+ return (options, params, payload) => {
309
+ try {
310
+ const { value, total, startTime } = params;
311
+ const { downloadedSize = 0 } = payload || { downloadedSize: 0 };
312
+
313
+ // Calculate progress percentage
314
+ const progress = Math.min(1, Math.max(0, value / Math.max(1, total)));
315
+ const percentage = Math.floor(progress * 100);
316
+
317
+ // Calculate elapsed time
318
+ const elapsedSecs = Math.max(0.1, (Date.now() - startTime) / 1000);
319
+
320
+ // Create the progress bar
321
+ const barLength = Math.max(
322
+ 20,
323
+ Math.min(40, Math.floor(terminalWidth / 2))
324
+ );
325
+ const completedLength = Math.round(barLength * progress);
326
+ const remainingLength = barLength - completedLength;
327
+
328
+ // Build the bar with custom progress characters
329
+ const completedBar = chalk.greenBright(
330
+ progressChars.complete.repeat(completedLength)
331
+ );
332
+ const remainingBar = chalk.gray(
333
+ progressChars.incomplete.repeat(remainingLength)
334
+ );
335
+
336
+ // Add spinner for animation
337
+ const spinner = chalk.cyanBright(getSpinnerFrame());
338
+
339
+ // Format the output
340
+ const progressInfo = `${chalk.cyan(`${value}/${total}`)} files`;
341
+ const sizeInfo = prettyBytes(downloadedSize || 0);
342
+
343
+ return `${spinner} ${completedBar}${remainingBar} ${chalk.yellow(
344
+ percentage + "%"
345
+ )} | ${progressInfo} | ${chalk.magenta(sizeInfo)}`;
346
+ } catch (error) {
347
+ // Fallback to a very simple progress indicator
348
+ return `${Math.floor((params.value / params.total) * 100)}% complete`;
349
+ }
350
+ };
351
+ };
352
+
353
+ /**
354
+ * Downloads all files from a folder in a GitHub repository
355
+ * @param {Object} repoInfo - Object containing repository information
356
+ * @param {string} repoInfo.owner - Repository owner
357
+ * @param {string} repoInfo.repo - Repository name
358
+ * @param {string} repoInfo.branch - Branch name
359
+ * @param {string} repoInfo.folderPath - Path to the folder
360
+ * @param {string} outputDir - Directory where files should be saved
361
+ * @param {Object} [options] - Download options
362
+ * @param {string} [options.token] - GitHub Personal Access Token
363
+ * @returns {Promise<void>} - Promise that resolves when all files are downloaded
364
+ */
365
+ const downloadFolder = async (
366
+ { owner, repo, branch, folderPath },
367
+ outputDir,
368
+ options = {}
369
+ ) => {
370
+ const { token } = options;
371
+ console.log(
372
+ chalk.cyan(`Analyzing repository structure for ${owner}/${repo}...`)
373
+ );
374
+
375
+ try {
376
+ const contents = await fetchFolderContents(
377
+ owner,
378
+ repo,
379
+ branch,
380
+ folderPath,
381
+ token
382
+ );
383
+
384
+ if (!contents || contents.length === 0) {
385
+ const message = `No files found in ${folderPath || "repository root"}`;
386
+ console.log(chalk.yellow(message));
387
+ // Don't print success message when no files are found - this might indicate an error
388
+ return {
389
+ success: true,
390
+ filesDownloaded: 0,
391
+ failedFiles: 0,
392
+ isEmpty: true,
393
+ };
394
+ }
395
+
396
+ // Filter for blob type (files)
397
+ const files = contents.filter((item) => item.type === "blob");
398
+ const totalFiles = files.length;
399
+
400
+ if (totalFiles === 0) {
401
+ const message = `No files found in ${
402
+ folderPath || "repository root"
403
+ } (only directories)`;
404
+ console.log(chalk.yellow(message));
405
+ // This is a legitimate case - directory exists but contains only subdirectories
406
+ console.log(chalk.green(`Directory structure downloaded successfully!`));
407
+ return {
408
+ success: true,
409
+ filesDownloaded: 0,
410
+ failedFiles: 0,
411
+ isEmpty: true,
412
+ };
413
+ }
414
+
415
+ console.log(
416
+ chalk.cyan(
417
+ `Downloading ${totalFiles} files from ${chalk.white(
418
+ owner + "/" + repo
419
+ )}...`
420
+ )
421
+ );
422
+
423
+ // Simplified progress bar setup
424
+ const progressBar = new cliProgress.SingleBar({
425
+ format: createProgressRenderer(owner, repo, folderPath),
426
+ hideCursor: true,
427
+ clearOnComplete: false,
428
+ stopOnComplete: true,
429
+ forceRedraw: true,
430
+ });
431
+
432
+ // Track download metrics
433
+ let downloadedSize = 0;
434
+ const startTime = Date.now();
435
+ let failedFiles = [];
436
+
437
+ // Start progress bar
438
+ progressBar.start(totalFiles, 0, {
439
+ downloadedSize: 0,
440
+ startTime,
441
+ });
442
+
443
+ // Create download promises with concurrency control
444
+ const fileDownloadPromises = files.map((item) => {
445
+ // Keep the original structure by preserving the folder name
446
+ let relativePath = item.path;
447
+ if (folderPath && folderPath.trim() !== "") {
448
+ relativePath = item.path
449
+ .substring(folderPath.length)
450
+ .replace(/^\//, "");
451
+ }
452
+ const outputFilePath = path.join(outputDir, relativePath);
453
+
454
+ return limit(async () => {
455
+ try {
456
+ const result = await downloadFile(
457
+ owner,
458
+ repo,
459
+ branch,
460
+ item.path,
461
+ outputFilePath,
462
+ token
463
+ );
464
+
465
+ // Update progress metrics
466
+ if (result.success) {
467
+ downloadedSize += result.size || 0;
468
+ } else {
469
+ // Track failed files for reporting
470
+ failedFiles.push({
471
+ path: item.path,
472
+ error: result.error,
473
+ });
474
+ }
475
+
476
+ // Update progress bar with current metrics
477
+ progressBar.increment(1, {
478
+ downloadedSize,
479
+ });
480
+
481
+ return result;
482
+ } catch (error) {
483
+ failedFiles.push({
484
+ path: item.path,
485
+ error: error.message,
486
+ });
487
+
488
+ progressBar.increment(1, { downloadedSize });
489
+ return {
490
+ filePath: item.path,
491
+ success: false,
492
+ error: error.message,
493
+ size: 0,
494
+ };
495
+ }
496
+ });
497
+ });
498
+
499
+ // Execute downloads in parallel with controlled concurrency
500
+ const results = await Promise.all(fileDownloadPromises);
501
+ progressBar.stop();
502
+
503
+ console.log(); // Add an empty line after progress bar
504
+
505
+ // Count successful and failed downloads
506
+ const succeeded = results.filter((r) => r.success).length;
507
+ const failed = failedFiles.length;
508
+ if (failed > 0) {
509
+ console.log(
510
+ chalk.yellow(
511
+ `Downloaded ${succeeded} files successfully, ${failed} files failed`
512
+ )
513
+ );
514
+
515
+ // Show detailed errors if there aren't too many
516
+ if (failed <= 5) {
517
+ console.log(chalk.yellow("Failed files:"));
518
+ failedFiles.forEach((file) => {
519
+ console.log(chalk.yellow(` - ${file.path}: ${file.error}`));
520
+ });
521
+ } else {
522
+ console.log(
523
+ chalk.yellow(
524
+ `${failed} files failed to download. Check your connection or repository access.`
525
+ )
526
+ );
527
+ }
528
+
529
+ // Don't claim success if files failed to download
530
+ if (succeeded === 0) {
531
+ console.log(
532
+ chalk.red(`Download failed: No files were downloaded successfully`)
533
+ );
534
+ return {
535
+ success: false,
536
+ filesDownloaded: succeeded,
537
+ failedFiles: failed,
538
+ isEmpty: false,
539
+ };
540
+ } else {
541
+ console.log(chalk.yellow(`Download completed with errors`));
542
+ return {
543
+ success: false,
544
+ filesDownloaded: succeeded,
545
+ failedFiles: failed,
546
+ isEmpty: false,
547
+ };
548
+ }
549
+ } else {
550
+ console.log(
551
+ chalk.green(`All ${succeeded} files downloaded successfully!`)
552
+ );
553
+ console.log(chalk.green(`Folder cloned successfully!`));
554
+ return {
555
+ success: true,
556
+ filesDownloaded: succeeded,
557
+ failedFiles: failed,
558
+ isEmpty: false,
559
+ };
560
+ }
561
+ } catch (error) {
562
+ // Log the specific error details
563
+ console.error(chalk.red(`Error downloading folder: ${error.message}`));
564
+
565
+ // Re-throw the error so the main CLI can exit with proper error code
566
+ throw error;
567
+ }
568
+ };
569
+
570
+ // Export functions in ESM format
571
+ export { downloadFolder, downloadFolderWithResume, downloadFile };
572
+
573
+ /**
574
+ * Downloads all files from a folder in a GitHub repository with resume capability
575
+ */
576
+ const downloadFolderWithResume = async (
577
+ { owner, repo, branch, folderPath },
578
+ outputDir,
579
+ options = { resume: true, forceRestart: false }
580
+ ) => {
581
+ const { resume = true, forceRestart = false, token } = options;
582
+
583
+ if (!resume) {
584
+ return downloadFolder(
585
+ { owner, repo, branch, folderPath },
586
+ outputDir,
587
+ options
588
+ );
589
+ }
590
+
591
+ const resumeManager = new ResumeManager();
592
+ const encodedFolderPath = folderPath
593
+ ? folderPath
594
+ .split("/")
595
+ .map((part) => encodeURIComponent(part))
596
+ .join("/")
597
+ : "";
598
+ const url = `https://github.com/${encodeURIComponent(
599
+ owner
600
+ )}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(
601
+ branch || "main"
602
+ )}/${encodedFolderPath}`;
603
+
604
+ // Clear checkpoint if force restart is requested
605
+ if (forceRestart) {
606
+ resumeManager.cleanupCheckpoint(url, outputDir);
607
+ }
608
+
609
+ // Check for existing checkpoint
610
+ let checkpoint = resumeManager.loadCheckpoint(url, outputDir);
611
+
612
+ if (checkpoint) {
613
+ console.log(
614
+ chalk.blue(
615
+ `Found previous download from ${new Date(
616
+ checkpoint.timestamp
617
+ ).toLocaleString()}`
618
+ )
619
+ );
620
+ console.log(
621
+ chalk.blue(
622
+ `Progress: ${checkpoint.downloadedFiles.length}/${checkpoint.totalFiles} files completed`
623
+ )
624
+ );
625
+
626
+ // Verify integrity of existing files
627
+ const validFiles = [];
628
+ let corruptedCount = 0;
629
+
630
+ for (const filename of checkpoint.downloadedFiles) {
631
+ const filepath = path.join(outputDir, filename);
632
+ const expectedHash = checkpoint.fileHashes[filename];
633
+
634
+ if (
635
+ expectedHash &&
636
+ resumeManager.verifyFileIntegrity(filepath, expectedHash)
637
+ ) {
638
+ validFiles.push(filename);
639
+ } else {
640
+ corruptedCount++;
641
+ }
642
+ }
643
+
644
+ checkpoint.downloadedFiles = validFiles;
645
+ if (corruptedCount > 0) {
646
+ console.log(
647
+ chalk.yellow(
648
+ `Detected ${corruptedCount} corrupted files, will re-download`
649
+ )
650
+ );
651
+ }
652
+ console.log(chalk.green(`Verified ${validFiles.length} existing files`));
653
+ }
654
+
655
+ console.log(
656
+ chalk.cyan(`Analyzing repository structure for ${owner}/${repo}...`)
657
+ );
658
+
659
+ try {
660
+ const contents = await fetchFolderContents(
661
+ owner,
662
+ repo,
663
+ branch,
664
+ folderPath,
665
+ token
666
+ );
667
+ if (!contents || contents.length === 0) {
668
+ const message = `No files found in ${folderPath || "repository root"}`;
669
+ console.log(chalk.yellow(message));
670
+ // Don't print success message when no files are found - this might indicate an error
671
+ return {
672
+ success: true,
673
+ filesDownloaded: 0,
674
+ failedFiles: 0,
675
+ isEmpty: true,
676
+ };
677
+ }
678
+
679
+ // Filter for blob type (files)
680
+ const files = contents.filter((item) => item.type === "blob");
681
+ const totalFiles = files.length;
682
+
683
+ if (totalFiles === 0) {
684
+ const message = `No files found in ${
685
+ folderPath || "repository root"
686
+ } (only directories)`;
687
+ console.log(chalk.yellow(message));
688
+ // This is a legitimate case - directory exists but contains only subdirectories
689
+ console.log(chalk.green(`Directory structure downloaded successfully!`));
690
+ return {
691
+ success: true,
692
+ filesDownloaded: 0,
693
+ failedFiles: 0,
694
+ isEmpty: true,
695
+ };
696
+ }
697
+
698
+ // Create new checkpoint if none exists
699
+ if (!checkpoint) {
700
+ checkpoint = resumeManager.createNewCheckpoint(
701
+ url,
702
+ outputDir,
703
+ totalFiles
704
+ );
705
+ console.log(
706
+ chalk.cyan(
707
+ `Starting download of ${totalFiles} files from ${chalk.white(
708
+ owner + "/" + repo
709
+ )}...`
710
+ )
711
+ );
712
+ } else {
713
+ // Update total files in case repository changed
714
+ checkpoint.totalFiles = totalFiles;
715
+ console.log(chalk.cyan(`Resuming download...`));
716
+ }
717
+
718
+ // Get remaining files to download
719
+ const remainingFiles = files.filter((item) => {
720
+ let relativePath = item.path;
721
+ if (folderPath && folderPath.trim() !== "") {
722
+ relativePath = item.path
723
+ .substring(folderPath.length)
724
+ .replace(/^\//, "");
725
+ }
726
+ return !checkpoint.downloadedFiles.includes(relativePath);
727
+ });
728
+
729
+ if (remainingFiles.length === 0) {
730
+ console.log(chalk.green(`All files already downloaded!`));
731
+ resumeManager.cleanupCheckpoint(url, outputDir);
732
+ return;
733
+ }
734
+
735
+ console.log(
736
+ chalk.cyan(`Downloading ${remainingFiles.length} remaining files...`)
737
+ );
738
+
739
+ // Setup progress bar
740
+ const progressBar = new cliProgress.SingleBar({
741
+ format: createProgressRenderer(owner, repo, folderPath),
742
+ hideCursor: true,
743
+ clearOnComplete: false,
744
+ stopOnComplete: true,
745
+ forceRedraw: true,
746
+ });
747
+
748
+ // Calculate already downloaded size
749
+ let downloadedSize = 0;
750
+ for (const filename of checkpoint.downloadedFiles) {
751
+ const filepath = path.join(outputDir, filename);
752
+ try {
753
+ downloadedSize += fs.statSync(filepath).size;
754
+ } catch {
755
+ // File might be missing, will be re-downloaded
756
+ }
757
+ }
758
+
759
+ const startTime = Date.now();
760
+ let failedFiles = [...(checkpoint.failedFiles || [])];
761
+
762
+ // Start progress bar with current progress
763
+ progressBar.start(totalFiles, checkpoint.downloadedFiles.length, {
764
+ downloadedSize,
765
+ startTime,
766
+ });
767
+
768
+ // Process remaining files
769
+ let processedCount = 0;
770
+ for (const item of remainingFiles) {
771
+ try {
772
+ let relativePath = item.path;
773
+ if (folderPath && folderPath.trim() !== "") {
774
+ relativePath = item.path
775
+ .substring(folderPath.length)
776
+ .replace(/^\//, "");
777
+ }
778
+ const outputFilePath = path.join(outputDir, relativePath);
779
+
780
+ const result = await downloadFile(
781
+ owner,
782
+ repo,
783
+ branch,
784
+ item.path,
785
+ outputFilePath,
786
+ token
787
+ );
788
+
789
+ if (result.success) {
790
+ // Calculate file hash for integrity checking
791
+ const fileContent = fs.readFileSync(outputFilePath);
792
+ const fileHash = resumeManager.calculateHash(fileContent);
793
+
794
+ // Update checkpoint
795
+ checkpoint.downloadedFiles.push(relativePath);
796
+ checkpoint.fileHashes[relativePath] = fileHash;
797
+ downloadedSize += result.size || 0;
798
+ } else {
799
+ // Track failed files
800
+ failedFiles.push({
801
+ path: relativePath,
802
+ error: result.error,
803
+ });
804
+ checkpoint.failedFiles = failedFiles;
805
+ }
806
+
807
+ processedCount++;
808
+
809
+ // Save checkpoint every 10 files
810
+ if (processedCount % 10 === 0) {
811
+ resumeManager.saveCheckpoint(checkpoint);
812
+ }
813
+
814
+ // Update progress bar
815
+ progressBar.increment(1, { downloadedSize });
816
+ } catch (error) {
817
+ // Handle interruption gracefully
818
+ if (error.name === "SIGINT") {
819
+ resumeManager.saveCheckpoint(checkpoint);
820
+ progressBar.stop();
821
+ console.log(chalk.blue(`\nDownload interrupted. Progress saved.`));
822
+ console.log(chalk.blue(`Run the same command again to resume.`));
823
+ return;
824
+ }
825
+
826
+ failedFiles.push({
827
+ path: item.path,
828
+ error: error.message,
829
+ });
830
+ checkpoint.failedFiles = failedFiles;
831
+ progressBar.increment(1, { downloadedSize });
832
+ }
833
+ }
834
+
835
+ progressBar.stop();
836
+ console.log(); // Add an empty line after progress bar
837
+
838
+ // Final checkpoint save
839
+ resumeManager.saveCheckpoint(checkpoint);
840
+
841
+ // Count results
842
+ const succeeded = checkpoint.downloadedFiles.length;
843
+ const failed = failedFiles.length;
844
+ if (failed > 0) {
845
+ console.log(
846
+ chalk.yellow(
847
+ `Downloaded ${succeeded} files successfully, ${failed} files failed`
848
+ )
849
+ );
850
+
851
+ if (failed <= 5) {
852
+ console.log(chalk.yellow("Failed files:"));
853
+ failedFiles.forEach((file) => {
854
+ console.log(chalk.yellow(` - ${file.path}: ${file.error}`));
855
+ });
856
+ }
857
+
858
+ console.log(
859
+ chalk.blue(`Run the same command again to retry failed downloads`)
860
+ );
861
+
862
+ // Don't claim success if files failed to download
863
+ if (succeeded === 0) {
864
+ console.log(
865
+ chalk.red(`Download failed: No files were downloaded successfully`)
866
+ );
867
+ return {
868
+ success: false,
869
+ filesDownloaded: succeeded,
870
+ failedFiles: failed,
871
+ isEmpty: false,
872
+ };
873
+ } else {
874
+ console.log(chalk.yellow(`Download completed with errors`));
875
+ return {
876
+ success: false,
877
+ filesDownloaded: succeeded,
878
+ failedFiles: failed,
879
+ isEmpty: false,
880
+ };
881
+ }
882
+ } else {
883
+ console.log(
884
+ chalk.green(`All ${succeeded} files downloaded successfully!`)
885
+ );
886
+ resumeManager.cleanupCheckpoint(url, outputDir);
887
+ console.log(chalk.green(`Folder cloned successfully!`));
888
+ return {
889
+ success: true,
890
+ filesDownloaded: succeeded,
891
+ failedFiles: failed,
892
+ isEmpty: false,
893
+ };
894
+ }
895
+ } catch (error) {
896
+ // Save checkpoint on any error
897
+ if (checkpoint) {
898
+ resumeManager.saveCheckpoint(checkpoint);
899
+ }
900
+
901
+ console.error(chalk.red(`Error downloading folder: ${error.message}`));
902
+ throw error;
903
+ }
904
+ };