git-ripper 1.4.13 → 1.5.1

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/README.md CHANGED
@@ -96,6 +96,7 @@ git-ripper https://github.com/username/repository/tree/branch/folder --zip="my-a
96
96
  | Option | Description | Default |
97
97
  | -------------------------- | ---------------------------------------- | ----------------- |
98
98
  | `-o, --output <directory>` | Specify output directory | Current directory |
99
+ | `--gh-token <token>` | GitHub Personal Access Token | - |
99
100
  | `--zip [filename]` | Create ZIP archive of downloaded content | - |
100
101
  | `--no-resume` | Disable resume functionality | - |
101
102
  | `--force-restart` | Ignore existing checkpoints and restart | - |
@@ -103,6 +104,46 @@ git-ripper https://github.com/username/repository/tree/branch/folder --zip="my-a
103
104
  | `-V, --version` | Show version number | - |
104
105
  | `-h, --help` | Show help | - |
105
106
 
107
+ ## Authentication (Private Repositories & Rate Limits)
108
+
109
+ To download from private repositories or to increase your API rate limit, you need to provide a GitHub Personal Access Token (PAT).
110
+
111
+ ### How to Generate a Token
112
+
113
+ You can use either a **Fine-grained token** (Recommended) or a **Classic token**.
114
+
115
+ #### Option A: Fine-grained Token (Recommended)
116
+
117
+ 1. Go to **Settings** > **Developer settings** > **Personal access tokens** > **Fine-grained tokens**.
118
+ 2. Click **Generate new token**.
119
+ 3. Name it (e.g., "Git-ripper").
120
+ 4. **Resource owner**: Select your user.
121
+ 5. **Repository access**: Select **Only select repositories** and choose the private repository you want to download from.
122
+ 6. **Permissions**:
123
+ - Click on **Repository permissions**.
124
+ - Find **Contents** and change Access to **Read-only**.
125
+ - _Note: Metadata permission is selected automatically._
126
+ 7. Click **Generate token**.
127
+
128
+ #### Option B: Classic Token
129
+
130
+ 1. Go to **Settings** > **Developer settings** > **Personal access tokens** > **Tokens (classic)**.
131
+ 2. Click **Generate new token** > **Generate new token (classic)**.
132
+ 3. Give your token a descriptive name.
133
+ 4. **Select Scopes:**
134
+ - **For Private Repositories:** Select the **`repo`** scope (Full control of private repositories).
135
+ 5. Click **Generate token**.
136
+
137
+ ### Using the Token
138
+
139
+ Pass the token using the `--gh-token` flag:
140
+
141
+ ```bash
142
+ git-ripper https://github.com/username/private-repo/tree/main/src --gh-token ghp_YourTokenHere
143
+ ```
144
+
145
+ > **Security Note:** Be careful not to share your token or commit it to public repositories.
146
+
106
147
  ## Examples
107
148
 
108
149
  ### Extract a Component Library
@@ -133,6 +174,13 @@ git-ripper https://github.com/nodejs/node/tree/main/doc -o ./node-docs
133
174
  git-ripper https://github.com/tailwindlabs/tailwindcss/tree/master/src/components -o ./tailwind-components
134
175
  ```
135
176
 
177
+ ### Download from Private Repository
178
+
179
+ ```bash
180
+ # Download from a private repository using a token
181
+ git-ripper https://github.com/my-org/private-project/tree/main/src --gh-token ghp_abc123...
182
+ ```
183
+
136
184
  ### Download and Create Archive
137
185
 
138
186
  ```bash
@@ -207,7 +255,7 @@ The resume functionality uses checkpoint files stored in `.git_ripper_checkpoint
207
255
 
208
256
  ## Configuration
209
257
 
210
- Git-ripper works out of the box without configuration. For rate-limited GitHub API usage, authentication support is under development.
258
+ Git-ripper works out of the box without configuration. For rate-limited GitHub API usage or private repositories, use the `--gh-token` option as described in the [Authentication](#authentication-private-repositories--rate-limits) section.
211
259
 
212
260
  ## Troubleshooting
213
261
 
@@ -219,7 +267,10 @@ Git-ripper works out of the box without configuration. For rate-limited GitHub A
219
267
  Error: Request failed with status code 403
220
268
  ```
221
269
 
222
- **Solution**: GitHub limits unauthenticated API requests. Wait a few minutes and try again.
270
+ **Solution**: GitHub limits unauthenticated API requests. You can either:
271
+
272
+ 1. Wait a few minutes and try again
273
+ 2. Use the `--gh-token` option with a Personal Access Token to significantly increase your rate limit
223
274
 
224
275
  #### Invalid URL Format
225
276
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ripper",
3
- "version": "1.4.13",
3
+ "version": "1.5.1",
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
@@ -128,12 +128,14 @@ export const createArchive = (sourceDir, outputPath) => {
128
128
  * @param {object} repoInfo - Repository information object
129
129
  * @param {string} outputDir - Directory where files should be downloaded
130
130
  * @param {string} archiveName - Custom name for the archive file (optional)
131
+ * @param {object} [options] - Download options including token
131
132
  * @returns {Promise<string>} - Path to the created archive
132
133
  */
133
134
  export const downloadAndArchive = async (
134
135
  repoInfo,
135
136
  outputDir,
136
- archiveName = null
137
+ archiveName = null,
138
+ options = {}
137
139
  ) => {
138
140
  const { downloadFolder } = await import("./downloader.js");
139
141
 
@@ -146,7 +148,7 @@ export const downloadAndArchive = async (
146
148
  fs.mkdirSync(tempDir, { recursive: true });
147
149
  try {
148
150
  // Download the folder contents
149
- const downloadResult = await downloadFolder(repoInfo, tempDir);
151
+ const downloadResult = await downloadFolder(repoInfo, tempDir, options);
150
152
 
151
153
  // Check if download failed
152
154
  if (downloadResult && !downloadResult.success) {
package/src/downloader.js CHANGED
@@ -45,10 +45,16 @@ const getSpinnerFrame = () => {
45
45
  * @param {string} repo - Repository name
46
46
  * @param {string} branch - Branch name
47
47
  * @param {string} folderPath - Path to the folder
48
+ * @param {string} [token] - GitHub Personal Access Token
48
49
  * @returns {Promise<Array>} - Promise resolving to an array of file objects
49
50
  * @throws {Error} - Throws error on API failures instead of returning empty array
50
51
  */
51
- const fetchFolderContents = async (owner, repo, branch, folderPath) => {
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
+ };
52
58
  let effectiveBranch = branch;
53
59
  if (!effectiveBranch) {
54
60
  // If no branch is specified, fetch the default branch for the repository
@@ -56,7 +62,7 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
56
62
  const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
57
63
  owner
58
64
  )}/${encodeURIComponent(repo)}`;
59
- const repoInfoResponse = await axios.get(repoInfoUrl);
65
+ const repoInfoResponse = await axios.get(repoInfoUrl, { headers });
60
66
  effectiveBranch = repoInfoResponse.data.default_branch;
61
67
  if (!effectiveBranch) {
62
68
  throw new Error(
@@ -85,7 +91,7 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
85
91
  )}?recursive=1`;
86
92
 
87
93
  try {
88
- const response = await axios.get(apiUrl);
94
+ const response = await axios.get(apiUrl, { headers });
89
95
 
90
96
  // Check if GitHub API returned truncated results
91
97
  if (response.data.truncated) {
@@ -125,7 +131,7 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
125
131
  isRateLimit = true;
126
132
  errorMessage = `GitHub API rate limit exceeded. Please wait until ${new Date(
127
133
  parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
128
- ).toLocaleTimeString()} or add a GitHub token (feature coming soon).`;
134
+ ).toLocaleTimeString()} or use the --gh-token option to increase your rate limit.`;
129
135
  } else {
130
136
  errorMessage = `Access forbidden: ${
131
137
  error.response.data.message ||
@@ -162,9 +168,20 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
162
168
  * @param {string} branch - Branch name
163
169
  * @param {string} filePath - Path to the file
164
170
  * @param {string} outputPath - Path where the file should be saved
171
+ * @param {string} [token] - GitHub Personal Access Token
165
172
  * @returns {Promise<Object>} - Object containing download status
166
173
  */
167
- const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
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
+ };
168
185
  let effectiveBranch = branch;
169
186
  if (!effectiveBranch) {
170
187
  // If no branch is specified, fetch the default branch for the repository
@@ -174,7 +191,7 @@ const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
174
191
  const repoInfoUrl = `https://api.github.com/repos/${encodeURIComponent(
175
192
  owner
176
193
  )}/${encodeURIComponent(repo)}`;
177
- const repoInfoResponse = await axios.get(repoInfoUrl);
194
+ const repoInfoResponse = await axios.get(repoInfoUrl, { headers });
178
195
  effectiveBranch = repoInfoResponse.data.default_branch;
179
196
  if (!effectiveBranch) {
180
197
  // console.error(chalk.red(`Could not determine default branch for ${owner}/${repo} for file ${filePath}.`));
@@ -213,7 +230,10 @@ const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
213
230
  const url = `${baseUrl}${fileUrlPath}`;
214
231
 
215
232
  try {
216
- const response = await axios.get(url, { responseType: "arraybuffer" });
233
+ const response = await axios.get(url, {
234
+ responseType: "arraybuffer",
235
+ headers,
236
+ });
217
237
 
218
238
  // Ensure the directory exists
219
239
  try {
@@ -338,18 +358,28 @@ const createProgressRenderer = (owner, repo, folderPath) => {
338
358
  * @param {string} repoInfo.branch - Branch name
339
359
  * @param {string} repoInfo.folderPath - Path to the folder
340
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
341
363
  * @returns {Promise<void>} - Promise that resolves when all files are downloaded
342
364
  */
343
365
  const downloadFolder = async (
344
366
  { owner, repo, branch, folderPath },
345
- outputDir
367
+ outputDir,
368
+ options = {}
346
369
  ) => {
370
+ const { token } = options;
347
371
  console.log(
348
372
  chalk.cyan(`Analyzing repository structure for ${owner}/${repo}...`)
349
373
  );
350
374
 
351
375
  try {
352
- const contents = await fetchFolderContents(owner, repo, branch, folderPath);
376
+ const contents = await fetchFolderContents(
377
+ owner,
378
+ repo,
379
+ branch,
380
+ folderPath,
381
+ token
382
+ );
353
383
 
354
384
  if (!contents || contents.length === 0) {
355
385
  const message = `No files found in ${folderPath || "repository root"}`;
@@ -428,7 +458,8 @@ const downloadFolder = async (
428
458
  repo,
429
459
  branch,
430
460
  item.path,
431
- outputFilePath
461
+ outputFilePath,
462
+ token
432
463
  );
433
464
 
434
465
  // Update progress metrics
@@ -537,7 +568,7 @@ const downloadFolder = async (
537
568
  };
538
569
 
539
570
  // Export functions in ESM format
540
- export { downloadFolder, downloadFolderWithResume };
571
+ export { downloadFolder, downloadFolderWithResume, downloadFile };
541
572
 
542
573
  /**
543
574
  * Downloads all files from a folder in a GitHub repository with resume capability
@@ -547,10 +578,14 @@ const downloadFolderWithResume = async (
547
578
  outputDir,
548
579
  options = { resume: true, forceRestart: false }
549
580
  ) => {
550
- const { resume = true, forceRestart = false } = options;
581
+ const { resume = true, forceRestart = false, token } = options;
551
582
 
552
583
  if (!resume) {
553
- return downloadFolder({ owner, repo, branch, folderPath }, outputDir);
584
+ return downloadFolder(
585
+ { owner, repo, branch, folderPath },
586
+ outputDir,
587
+ options
588
+ );
554
589
  }
555
590
 
556
591
  const resumeManager = new ResumeManager();
@@ -622,7 +657,13 @@ const downloadFolderWithResume = async (
622
657
  );
623
658
 
624
659
  try {
625
- const contents = await fetchFolderContents(owner, repo, branch, folderPath);
660
+ const contents = await fetchFolderContents(
661
+ owner,
662
+ repo,
663
+ branch,
664
+ folderPath,
665
+ token
666
+ );
626
667
  if (!contents || contents.length === 0) {
627
668
  const message = `No files found in ${folderPath || "repository root"}`;
628
669
  console.log(chalk.yellow(message));
@@ -741,7 +782,8 @@ const downloadFolderWithResume = async (
741
782
  repo,
742
783
  branch,
743
784
  item.path,
744
- outputFilePath
785
+ outputFilePath,
786
+ token
745
787
  );
746
788
 
747
789
  if (result.success) {
package/src/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { program } from "commander";
2
2
  import { parseGitHubUrl } from "./parser.js";
3
- import { downloadFolder, downloadFolderWithResume } from "./downloader.js";
3
+ import { downloadFolder, downloadFolderWithResume, downloadFile } from "./downloader.js";
4
4
  import { downloadAndArchive } from "./archiver.js";
5
5
  import { ResumeManager } from "./resumeManager.js";
6
6
  import { fileURLToPath } from "node:url";
7
- import { dirname, join, resolve } from "node:path";
7
+ import { dirname, join, resolve, basename } from "node:path";
8
8
  import fs from "node:fs";
9
9
  import process from "node:process";
10
10
  import chalk from "chalk";
@@ -61,6 +61,7 @@ const initializeCLI = () => {
61
61
  .description("Clone specific folders from GitHub repositories")
62
62
  .argument("[url]", "GitHub URL of the folder to clone")
63
63
  .option("-o, --output <directory>", "Output directory", process.cwd())
64
+ .option("--gh-token <token>", "GitHub Personal Access Token for private repositories")
64
65
  .option("--zip [filename]", "Create ZIP archive of downloaded files")
65
66
  .option("--no-resume", "Disable resume functionality")
66
67
  .option("--force-restart", "Ignore existing checkpoints and start fresh")
@@ -122,6 +123,7 @@ const initializeCLI = () => {
122
123
  const downloadOptions = {
123
124
  resume: options.resume !== false, // Default to true unless --no-resume
124
125
  forceRestart: options.forceRestart || false,
126
+ token: options.ghToken,
125
127
  };
126
128
 
127
129
  let operationType = createArchive ? "archive" : "download";
@@ -131,7 +133,19 @@ const initializeCLI = () => {
131
133
  try {
132
134
  if (createArchive) {
133
135
  console.log(`Creating ZIP archive...`);
134
- await downloadAndArchive(parsedUrl, options.output, archiveName);
136
+ await downloadAndArchive(parsedUrl, options.output, archiveName, downloadOptions);
137
+ } else if (parsedUrl.type === "blob") {
138
+ console.log(`Downloading file to: ${options.output}`);
139
+ const fileName = basename(parsedUrl.folderPath);
140
+ const outputPath = join(options.output, fileName);
141
+ result = await downloadFile(
142
+ parsedUrl.owner,
143
+ parsedUrl.repo,
144
+ parsedUrl.branch,
145
+ parsedUrl.folderPath,
146
+ outputPath,
147
+ options.ghToken
148
+ );
135
149
  } else {
136
150
  console.log(`Downloading folder to: ${options.output}`);
137
151
  if (downloadOptions.resume) {
@@ -141,7 +155,7 @@ const initializeCLI = () => {
141
155
  downloadOptions
142
156
  );
143
157
  } else {
144
- result = await downloadFolder(parsedUrl, options.output);
158
+ result = await downloadFolder(parsedUrl, options.output, downloadOptions);
145
159
  }
146
160
  }
147
161
  } catch (opError) {
package/src/parser.js CHANGED
@@ -6,7 +6,7 @@ export function parseGitHubUrl(url) {
6
6
 
7
7
  // Validate if it's a GitHub URL
8
8
  const githubUrlPattern =
9
- /^https?:\/\/(?:www\.)?github\.com\/([^\/]+)\/([^\/]+)(?:\/(?:tree|blob)\/([^\/]+)(?:\/(.+))?)?$/;
9
+ /^https?:\/\/(?:www\.)?github\.com\/([^\/]+)\/([^\/]+)(?:\/(tree|blob)\/([^\/]+)(?:\/(.+))?)?$/;
10
10
  const match = url.match(githubUrlPattern);
11
11
 
12
12
  if (!match) {
@@ -17,14 +17,21 @@ export function parseGitHubUrl(url) {
17
17
 
18
18
  // Extract components from the matched pattern
19
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
20
+ let repo = decodeURIComponent(match[2]);
21
+
22
+ // Remove .git suffix if present
23
+ if (repo.endsWith(".git")) {
24
+ repo = repo.slice(0, -4);
25
+ }
26
+
27
+ const type = match[3] || "tree"; // Default to tree if not present (root of repo)
28
+ const branch = match[4] ? decodeURIComponent(match[4]) : ""; // Branch is an empty string if not present
29
+ const folderPath = match[5] ? decodeURIComponent(match[5]) : ""; // Empty string if no folder path
23
30
 
24
31
  // Additional validation
25
32
  if (!owner || !repo) {
26
33
  throw new Error("Invalid GitHub URL: Missing repository owner or name");
27
34
  }
28
35
 
29
- return { owner, repo, branch, folderPath };
36
+ return { owner, repo, branch, folderPath, type };
30
37
  }