git-ripper 1.4.5 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ripper",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "description": "CLI tool that lets you download specific folders from GitHub repositories without cloning the entire repo.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/archiver.js CHANGED
@@ -88,7 +88,7 @@ export const createArchive = (sourceDir, outputPath) => {
88
88
  const size = archive.pointer();
89
89
  console.log(
90
90
  chalk.green(
91
- `✓ Archive created: ${outputPath} (${(size / 1024 / 1024).toFixed(
91
+ `Archive created: ${outputPath} (${(size / 1024 / 1024).toFixed(
92
92
  2
93
93
  )} MB)`
94
94
  )
@@ -144,10 +144,32 @@ export const downloadAndArchive = async (
144
144
  // Create a temporary directory for the download
145
145
  const tempDir = path.join(outputDir, `.temp-${Date.now()}`);
146
146
  fs.mkdirSync(tempDir, { recursive: true });
147
-
148
147
  try {
149
148
  // Download the folder contents
150
- await downloadFolder(repoInfo, tempDir);
149
+ const downloadResult = await downloadFolder(repoInfo, tempDir);
150
+
151
+ // Check if download failed
152
+ if (downloadResult && !downloadResult.success) {
153
+ throw new Error(
154
+ `Download failed: ${
155
+ downloadResult.failedFiles || 0
156
+ } files could not be downloaded`
157
+ );
158
+ }
159
+
160
+ // Check if there's anything to archive
161
+ const files = fs
162
+ .readdirSync(tempDir, { recursive: true })
163
+ .filter((file) => {
164
+ const fullPath = path.join(tempDir, file);
165
+ return fs.statSync(fullPath).isFile();
166
+ });
167
+
168
+ if (files.length === 0) {
169
+ throw new Error(
170
+ `No files to archive - download may have failed or repository is empty`
171
+ );
172
+ }
151
173
 
152
174
  // Determine archive filename
153
175
  let archiveFileName = archiveName;
package/src/downloader.js CHANGED
@@ -45,22 +45,22 @@ const getSpinnerFrame = () => {
45
45
  * @param {string} branch - Branch name
46
46
  * @param {string} folderPath - Path to the folder
47
47
  * @returns {Promise<Array>} - Promise resolving to an array of file objects
48
+ * @throws {Error} - Throws error on API failures instead of returning empty array
48
49
  */
49
50
  const fetchFolderContents = async (owner, repo, branch, folderPath) => {
50
51
  let effectiveBranch = branch;
51
52
  if (!effectiveBranch) {
52
53
  // If no branch is specified, fetch the default branch for the repository
53
54
  try {
54
- const repoInfoUrl = `https://api.github.com/repos/${owner}/${repo}`;
55
+ const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
56
+ owner
57
+ )}/${encodeURIComponent(repo)}`;
55
58
  const repoInfoResponse = await axios.get(repoInfoUrl);
56
59
  effectiveBranch = repoInfoResponse.data.default_branch;
57
60
  if (!effectiveBranch) {
58
- console.error(
59
- chalk.red(
60
- `Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
61
- )
61
+ throw new Error(
62
+ `Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
62
63
  );
63
- return [];
64
64
  }
65
65
  console.log(
66
66
  chalk.blue(
@@ -68,16 +68,20 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
68
68
  )
69
69
  );
70
70
  } catch (error) {
71
- console.error(
72
- chalk.red(
73
- `Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
74
- )
71
+ if (error.message.includes("Could not determine default branch")) {
72
+ throw error;
73
+ }
74
+ throw new Error(
75
+ `Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
75
76
  );
76
- return [];
77
77
  }
78
78
  }
79
79
 
80
- const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${effectiveBranch}?recursive=1`;
80
+ const apiUrl = `https://api.github.com/repos/${encodeURIComponent(
81
+ owner
82
+ )}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(
83
+ effectiveBranch
84
+ )}?recursive=1`;
81
85
 
82
86
  try {
83
87
  const response = await axios.get(apiUrl);
@@ -109,54 +113,44 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
109
113
  return response.data.tree.filter((item) => item.path.startsWith(prefix));
110
114
  }
111
115
  } catch (error) {
116
+ let errorMessage = "";
117
+ let isRateLimit = false;
118
+
112
119
  if (error.response) {
113
120
  // Handle specific HTTP error codes
114
121
  switch (error.response.status) {
115
122
  case 403:
116
123
  if (error.response.headers["x-ratelimit-remaining"] === "0") {
117
- console.error(
118
- chalk.red(
119
- `GitHub API rate limit exceeded. Please wait until ${new Date(
120
- parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
121
- ).toLocaleTimeString()} or add a GitHub token (feature coming soon).`
122
- )
123
- );
124
+ isRateLimit = true;
125
+ errorMessage = `GitHub API rate limit exceeded. Please wait until ${new Date(
126
+ parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
127
+ ).toLocaleTimeString()} or add a GitHub token (feature coming soon).`;
124
128
  } else {
125
- console.error(
126
- chalk.red(
127
- `Access forbidden: ${
128
- error.response.data.message || "Unknown reason"
129
- }`
130
- )
131
- );
129
+ errorMessage = `Access forbidden: ${
130
+ error.response.data.message ||
131
+ "Repository may be private or you may not have access"
132
+ }`;
132
133
  }
133
134
  break;
134
135
  case 404:
135
- console.error(
136
- chalk.red(
137
- `Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`
138
- )
139
- );
136
+ errorMessage = `Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`;
140
137
  break;
141
138
  default:
142
- console.error(
143
- chalk.red(
144
- `API error (${error.response.status}): ${
145
- error.response.data.message || error.message
146
- }`
147
- )
148
- );
139
+ errorMessage = `API error (${error.response.status}): ${
140
+ error.response.data.message || error.message
141
+ }`;
149
142
  }
150
143
  } else if (error.request) {
151
- console.error(
152
- chalk.red(
153
- `Network error: No response received from GitHub. Please check your internet connection.`
154
- )
155
- );
144
+ errorMessage = `Network error: No response received from GitHub. Please check your internet connection.`;
156
145
  } else {
157
- console.error(chalk.red(`Error preparing request: ${error.message}`));
146
+ errorMessage = `Error preparing request: ${error.message}`;
158
147
  }
159
- return [];
148
+
149
+ // Always throw the error instead of returning empty array
150
+ const enrichedError = new Error(errorMessage);
151
+ enrichedError.isRateLimit = isRateLimit;
152
+ enrichedError.statusCode = error.response?.status;
153
+ throw enrichedError;
160
154
  }
161
155
  };
162
156
 
@@ -176,7 +170,9 @@ const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
176
170
  // This check might be redundant if fetchFolderContents already resolved it,
177
171
  // but it's a good fallback for direct downloadFile calls if any.
178
172
  try {
179
- const repoInfoUrl = `https://api.github.com/repos/${owner}/${repo}`;
173
+ const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
174
+ owner
175
+ )}/${encodeURIComponent(repo)}`;
180
176
  const repoInfoResponse = await axios.get(repoInfoUrl);
181
177
  effectiveBranch = repoInfoResponse.data.default_branch;
182
178
  if (!effectiveBranch) {
@@ -203,10 +199,16 @@ const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
203
199
  }
204
200
  }
205
201
 
206
- const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}`;
202
+ const baseUrl = `https://raw.githubusercontent.com/${encodeURIComponent(
203
+ owner
204
+ )}/${encodeURIComponent(repo)}`;
205
+ const encodedFilePath = filePath
206
+ .split("/")
207
+ .map((part) => encodeURIComponent(part))
208
+ .join("/");
207
209
  const fileUrlPath = effectiveBranch
208
- ? `/${effectiveBranch}/${filePath}`
209
- : `/${filePath}`; // filePath might be at root
210
+ ? `/${encodeURIComponent(effectiveBranch)}/${encodedFilePath}`
211
+ : `/${encodedFilePath}`; // filePath might be at root
210
212
  const url = `${baseUrl}${fileUrlPath}`;
211
213
 
212
214
  try {
@@ -349,11 +351,15 @@ const downloadFolder = async (
349
351
  const contents = await fetchFolderContents(owner, repo, branch, folderPath);
350
352
 
351
353
  if (!contents || contents.length === 0) {
352
- console.log(
353
- chalk.yellow(`No files found in ${folderPath || "repository root"}`)
354
- );
355
- console.log(chalk.green(`Folder cloned successfully!`));
356
- return;
354
+ const message = `No files found in ${folderPath || "repository root"}`;
355
+ console.log(chalk.yellow(message));
356
+ // Don't print success message when no files are found - this might indicate an error
357
+ return {
358
+ success: true,
359
+ filesDownloaded: 0,
360
+ failedFiles: 0,
361
+ isEmpty: true,
362
+ };
357
363
  }
358
364
 
359
365
  // Filter for blob type (files)
@@ -361,15 +367,18 @@ const downloadFolder = async (
361
367
  const totalFiles = files.length;
362
368
 
363
369
  if (totalFiles === 0) {
364
- console.log(
365
- chalk.yellow(
366
- `No files found in ${
367
- folderPath || "repository root"
368
- } (only directories)`
369
- )
370
- );
371
- console.log(chalk.green(`Folder cloned successfully!`));
372
- return;
370
+ const message = `No files found in ${
371
+ folderPath || "repository root"
372
+ } (only directories)`;
373
+ console.log(chalk.yellow(message));
374
+ // This is a legitimate case - directory exists but contains only subdirectories
375
+ console.log(chalk.green(`Directory structure downloaded successfully!`));
376
+ return {
377
+ success: true,
378
+ filesDownloaded: 0,
379
+ failedFiles: 0,
380
+ isEmpty: true,
381
+ };
373
382
  }
374
383
 
375
384
  console.log(
@@ -464,7 +473,6 @@ const downloadFolder = async (
464
473
  // Count successful and failed downloads
465
474
  const succeeded = results.filter((r) => r.success).length;
466
475
  const failed = failedFiles.length;
467
-
468
476
  if (failed > 0) {
469
477
  console.log(
470
478
  chalk.yellow(
@@ -485,15 +493,45 @@ const downloadFolder = async (
485
493
  )
486
494
  );
487
495
  }
496
+
497
+ // Don't claim success if files failed to download
498
+ if (succeeded === 0) {
499
+ console.log(
500
+ chalk.red(`Download failed: No files were downloaded successfully`)
501
+ );
502
+ return {
503
+ success: false,
504
+ filesDownloaded: succeeded,
505
+ failedFiles: failed,
506
+ isEmpty: false,
507
+ };
508
+ } else {
509
+ console.log(chalk.yellow(`Download completed with errors`));
510
+ return {
511
+ success: false,
512
+ filesDownloaded: succeeded,
513
+ failedFiles: failed,
514
+ isEmpty: false,
515
+ };
516
+ }
488
517
  } else {
489
518
  console.log(
490
- chalk.green(` All ${succeeded} files downloaded successfully!`)
519
+ chalk.green(`All ${succeeded} files downloaded successfully!`)
491
520
  );
521
+ console.log(chalk.green(`Folder cloned successfully!`));
522
+ return {
523
+ success: true,
524
+ filesDownloaded: succeeded,
525
+ failedFiles: failed,
526
+ isEmpty: false,
527
+ };
492
528
  }
493
-
494
- console.log(chalk.green(`Folder cloned successfully!`));
495
529
  } catch (error) {
530
+ // Log the specific error details
496
531
  console.error(chalk.red(`Error downloading folder: ${error.message}`));
532
+
533
+ // Re-throw the error so the main CLI can exit with proper error code
534
+ throw error;
497
535
  }
498
536
  };
499
537
 
@@ -515,9 +553,17 @@ const downloadFolderWithResume = async (
515
553
  }
516
554
 
517
555
  const resumeManager = new ResumeManager();
518
- const url = `https://github.com/${owner}/${repo}/tree/${branch || "main"}/${
519
- folderPath || ""
520
- }`;
556
+ const encodedFolderPath = folderPath
557
+ ? folderPath
558
+ .split("/")
559
+ .map((part) => encodeURIComponent(part))
560
+ .join("/")
561
+ : "";
562
+ const url = `https://github.com/${encodeURIComponent(
563
+ owner
564
+ )}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(
565
+ branch || "main"
566
+ )}/${encodedFolderPath}`;
521
567
 
522
568
  // Clear checkpoint if force restart is requested
523
569
  if (forceRestart) {
@@ -530,14 +576,14 @@ const downloadFolderWithResume = async (
530
576
  if (checkpoint) {
531
577
  console.log(
532
578
  chalk.blue(
533
- `🔄 Found previous download from ${new Date(
579
+ `Found previous download from ${new Date(
534
580
  checkpoint.timestamp
535
581
  ).toLocaleString()}`
536
582
  )
537
583
  );
538
584
  console.log(
539
585
  chalk.blue(
540
- `📊 Progress: ${checkpoint.downloadedFiles.length}/${checkpoint.totalFiles} files completed`
586
+ `Progress: ${checkpoint.downloadedFiles.length}/${checkpoint.totalFiles} files completed`
541
587
  )
542
588
  );
543
589
 
@@ -563,11 +609,11 @@ const downloadFolderWithResume = async (
563
609
  if (corruptedCount > 0) {
564
610
  console.log(
565
611
  chalk.yellow(
566
- `🔧 Detected ${corruptedCount} corrupted files, will re-download`
612
+ `Detected ${corruptedCount} corrupted files, will re-download`
567
613
  )
568
614
  );
569
615
  }
570
- console.log(chalk.green(`✅ Verified ${validFiles.length} existing files`));
616
+ console.log(chalk.green(`Verified ${validFiles.length} existing files`));
571
617
  }
572
618
 
573
619
  console.log(
@@ -576,13 +622,16 @@ const downloadFolderWithResume = async (
576
622
 
577
623
  try {
578
624
  const contents = await fetchFolderContents(owner, repo, branch, folderPath);
579
-
580
625
  if (!contents || contents.length === 0) {
581
- console.log(
582
- chalk.yellow(`No files found in ${folderPath || "repository root"}`)
583
- );
584
- console.log(chalk.green(`Folder cloned successfully!`));
585
- return;
626
+ const message = `No files found in ${folderPath || "repository root"}`;
627
+ console.log(chalk.yellow(message));
628
+ // Don't print success message when no files are found - this might indicate an error
629
+ return {
630
+ success: true,
631
+ filesDownloaded: 0,
632
+ failedFiles: 0,
633
+ isEmpty: true,
634
+ };
586
635
  }
587
636
 
588
637
  // Filter for blob type (files)
@@ -590,15 +639,18 @@ const downloadFolderWithResume = async (
590
639
  const totalFiles = files.length;
591
640
 
592
641
  if (totalFiles === 0) {
593
- console.log(
594
- chalk.yellow(
595
- `No files found in ${
596
- folderPath || "repository root"
597
- } (only directories)`
598
- )
599
- );
600
- console.log(chalk.green(`Folder cloned successfully!`));
601
- return;
642
+ const message = `No files found in ${
643
+ folderPath || "repository root"
644
+ } (only directories)`;
645
+ console.log(chalk.yellow(message));
646
+ // This is a legitimate case - directory exists but contains only subdirectories
647
+ console.log(chalk.green(`Directory structure downloaded successfully!`));
648
+ return {
649
+ success: true,
650
+ filesDownloaded: 0,
651
+ failedFiles: 0,
652
+ isEmpty: true,
653
+ };
602
654
  }
603
655
 
604
656
  // Create new checkpoint if none exists
@@ -610,7 +662,7 @@ const downloadFolderWithResume = async (
610
662
  );
611
663
  console.log(
612
664
  chalk.cyan(
613
- `📥 Starting download of ${totalFiles} files from ${chalk.white(
665
+ `Starting download of ${totalFiles} files from ${chalk.white(
614
666
  owner + "/" + repo
615
667
  )}...`
616
668
  )
@@ -618,7 +670,7 @@ const downloadFolderWithResume = async (
618
670
  } else {
619
671
  // Update total files in case repository changed
620
672
  checkpoint.totalFiles = totalFiles;
621
- console.log(chalk.cyan(`📥 Resuming download...`));
673
+ console.log(chalk.cyan(`Resuming download...`));
622
674
  }
623
675
 
624
676
  // Get remaining files to download
@@ -633,13 +685,13 @@ const downloadFolderWithResume = async (
633
685
  });
634
686
 
635
687
  if (remainingFiles.length === 0) {
636
- console.log(chalk.green(`🎉 All files already downloaded!`));
688
+ console.log(chalk.green(`All files already downloaded!`));
637
689
  resumeManager.cleanupCheckpoint(url, outputDir);
638
690
  return;
639
691
  }
640
692
 
641
693
  console.log(
642
- chalk.cyan(`📥 Downloading ${remainingFiles.length} remaining files...`)
694
+ chalk.cyan(`Downloading ${remainingFiles.length} remaining files...`)
643
695
  );
644
696
 
645
697
  // Setup progress bar
@@ -723,10 +775,8 @@ const downloadFolderWithResume = async (
723
775
  if (error.name === "SIGINT") {
724
776
  resumeManager.saveCheckpoint(checkpoint);
725
777
  progressBar.stop();
726
- console.log(
727
- chalk.blue(`\n⏸️ Download interrupted. Progress saved.`)
728
- );
729
- console.log(chalk.blue(`💡 Run the same command again to resume.`));
778
+ console.log(chalk.blue(`\nDownload interrupted. Progress saved.`));
779
+ console.log(chalk.blue(`Run the same command again to resume.`));
730
780
  return;
731
781
  }
732
782
 
@@ -748,7 +798,6 @@ const downloadFolderWithResume = async (
748
798
  // Count results
749
799
  const succeeded = checkpoint.downloadedFiles.length;
750
800
  const failed = failedFiles.length;
751
-
752
801
  if (failed > 0) {
753
802
  console.log(
754
803
  chalk.yellow(
@@ -764,16 +813,42 @@ const downloadFolderWithResume = async (
764
813
  }
765
814
 
766
815
  console.log(
767
- chalk.blue(`💡 Run the same command again to retry failed downloads`)
816
+ chalk.blue(`Run the same command again to retry failed downloads`)
768
817
  );
818
+
819
+ // Don't claim success if files failed to download
820
+ if (succeeded === 0) {
821
+ console.log(
822
+ chalk.red(`Download failed: No files were downloaded successfully`)
823
+ );
824
+ return {
825
+ success: false,
826
+ filesDownloaded: succeeded,
827
+ failedFiles: failed,
828
+ isEmpty: false,
829
+ };
830
+ } else {
831
+ console.log(chalk.yellow(`Download completed with errors`));
832
+ return {
833
+ success: false,
834
+ filesDownloaded: succeeded,
835
+ failedFiles: failed,
836
+ isEmpty: false,
837
+ };
838
+ }
769
839
  } else {
770
840
  console.log(
771
- chalk.green(`🎉 All ${succeeded} files downloaded successfully!`)
841
+ chalk.green(`All ${succeeded} files downloaded successfully!`)
772
842
  );
773
843
  resumeManager.cleanupCheckpoint(url, outputDir);
844
+ console.log(chalk.green(`Folder cloned successfully!`));
845
+ return {
846
+ success: true,
847
+ filesDownloaded: succeeded,
848
+ failedFiles: failed,
849
+ isEmpty: false,
850
+ };
774
851
  }
775
-
776
- console.log(chalk.green(`Folder cloned successfully!`));
777
852
  } catch (error) {
778
853
  // Save checkpoint on any error
779
854
  if (checkpoint) {
package/src/index.js CHANGED
@@ -76,7 +76,7 @@ const initializeCLI = () => {
76
76
  return;
77
77
  }
78
78
 
79
- console.log(chalk.cyan("\n📋 Download Checkpoints:"));
79
+ console.log(chalk.cyan("\nDownload Checkpoints:"));
80
80
  checkpoints.forEach((cp, index) => {
81
81
  console.log(chalk.blue(`\n${index + 1}. ID: ${cp.id}`));
82
82
  console.log(` URL: ${cp.url}`);
@@ -123,24 +123,46 @@ const initializeCLI = () => {
123
123
  forceRestart: options.forceRestart || false,
124
124
  };
125
125
 
126
- if (createArchive) {
127
- console.log(`Creating ZIP archive...`);
128
- await downloadAndArchive(parsedUrl, options.output, archiveName);
129
- } else {
130
- console.log(`Downloading folder to: ${options.output}`);
126
+ let operationType = createArchive ? "archive" : "download";
127
+ let result = null;
128
+ let error = null;
131
129
 
132
- if (downloadOptions.resume) {
133
- await downloadFolderWithResume(
134
- parsedUrl,
135
- options.output,
136
- downloadOptions
137
- );
130
+ try {
131
+ if (createArchive) {
132
+ console.log(`Creating ZIP archive...`);
133
+ await downloadAndArchive(parsedUrl, options.output, archiveName);
138
134
  } else {
139
- await downloadFolder(parsedUrl, options.output);
135
+ console.log(`Downloading folder to: ${options.output}`);
136
+ if (downloadOptions.resume) {
137
+ result = await downloadFolderWithResume(
138
+ parsedUrl,
139
+ options.output,
140
+ downloadOptions
141
+ );
142
+ } else {
143
+ result = await downloadFolder(parsedUrl, options.output);
144
+ }
140
145
  }
146
+ } catch (opError) {
147
+ error = opError;
141
148
  }
142
149
 
143
- console.log("Operation completed successfully!");
150
+ // Consolidated result and error handling
151
+ if (error) {
152
+ const failMsg =
153
+ operationType === "archive"
154
+ ? `Archive creation failed: ${error.message}`
155
+ : `Download failed: ${error.message}`;
156
+ console.error(chalk.red(failMsg));
157
+ process.exit(1);
158
+ } else if (!createArchive && result && !result.success) {
159
+ console.error(chalk.red(`Download failed`));
160
+ process.exit(1);
161
+ } else if (!createArchive && result && result.isEmpty) {
162
+ console.log("Operation completed - no files to download!");
163
+ } else {
164
+ console.log("Operation completed successfully!");
165
+ }
144
166
  } catch (error) {
145
167
  console.error("Error:", error.message);
146
168
  process.exit(1);
package/src/parser.js CHANGED
@@ -16,10 +16,10 @@ export function parseGitHubUrl(url) {
16
16
  }
17
17
 
18
18
  // Extract components from the matched pattern
19
- const owner = match[1];
20
- const repo = match[2];
21
- const branch = match[3]; // Branch might not be in the URL for root downloads
22
- const folderPath = match[4] || ""; // Empty string if no folder path
19
+ const owner = decodeURIComponent(match[1]);
20
+ const repo = decodeURIComponent(match[2]);
21
+ const branch = match[3] ? decodeURIComponent(match[3]) : ""; // Branch is an empty string if not present
22
+ const folderPath = match[4] ? decodeURIComponent(match[4]) : ""; // Empty string if no folder path
23
23
 
24
24
  // Additional validation
25
25
  if (!owner || !repo) {