git-ripper 1.4.0 → 1.4.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/README.md +30 -23
- package/package.json +60 -58
- package/src/archiver.js +83 -83
- package/src/downloader.js +247 -90
- package/src/index.js +33 -46
- package/src/parser.js +23 -20
package/README.md
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/git-ripper)
|
|
6
6
|
[](https://github.com/sairajB/git-ripper/blob/main/LICENSE)
|
|
7
7
|
[](https://www.npmjs.com/package/git-ripper)
|
|
8
|
+
[](https://www.npmjs.com/package/git-ripper)
|
|
9
|
+
[](https://bundlephobia.com/package/git-ripper)
|
|
8
10
|
[](https://github.com/sairajB/git-ripper/issues)
|
|
9
11
|
[](https://github.com/sairajB/git-ripper/stargazers)
|
|
12
|
+
[](https://github.com/sairajB/git-ripper/network)
|
|
13
|
+
[](https://github.com/sairajB/git-ripper/commits/master)
|
|
10
14
|
|
|
11
15
|
**Download specific folders from GitHub repositories without cloning the entire codebase**
|
|
12
16
|
|
|
@@ -25,19 +29,34 @@
|
|
|
25
29
|
|
|
26
30
|
Have you ever needed just a single component from a massive repository? Or wanted to reference a specific configuration directory without downloading gigabytes of code? Git-ripper solves this problem by letting you extract and download only the folders you need, saving bandwidth, time, and disk space.
|
|
27
31
|
|
|
32
|
+
## Project Stats
|
|
33
|
+
|
|
34
|
+
Git-ripper has grown to become a trusted tool in the developer ecosystem:
|
|
35
|
+
|
|
36
|
+
- **Total Downloads**: Thousands of developers worldwide have downloaded Git-ripper to optimize their workflow and save time when working with large repositories.
|
|
37
|
+
- **Weekly Active Users**: Our weekly download statistics show consistent adoption and usage among developers, indicating the tool's reliability and usefulness.
|
|
38
|
+
- **Minimal Bundle Size**: Git-ripper is designed to be lightweight and efficient, with a minimal bundle size that ensures quick installation and minimal impact on your system resources.
|
|
39
|
+
- **Active Maintenance**: The project is actively maintained with regular updates and improvements, ensuring compatibility with the latest GitHub API changes and addressing user feedback.
|
|
40
|
+
- **Community Support**: With growing stars and forks on GitHub, Git-ripper has built a supportive community of users who contribute to its ongoing development and share their success stories.
|
|
41
|
+
- **Enterprise Adoption**: Used by teams in various organizations, from startups to large enterprises, Git-ripper helps development teams streamline their workflows when working with modular components from large codebases.
|
|
42
|
+
|
|
28
43
|
## Features
|
|
29
44
|
|
|
30
45
|
- **Selective Downloads**: Fetch specific folders instead of entire repositories
|
|
31
46
|
- **Directory Structure**: Preserves complete folder structure
|
|
32
47
|
- **Custom Output**: Specify your preferred output directory
|
|
33
48
|
- **Branch Support**: Works with any branch, not just the default one
|
|
34
|
-
- **Archive Export**: Create ZIP
|
|
49
|
+
- **Archive Export**: Create ZIP archives of downloaded content
|
|
35
50
|
- **Simple Interface**: Clean, intuitive command-line experience
|
|
36
51
|
- **Lightweight**: Minimal dependencies and fast execution
|
|
37
52
|
- **No Authentication**: Works with public repositories without requiring credentials
|
|
38
53
|
|
|
39
54
|
## Installation
|
|
40
55
|
|
|
56
|
+
### Requirements
|
|
57
|
+
|
|
58
|
+
Git-ripper requires Node.js >=16.0.0 due to its use of modern JavaScript features and built-in Node.js modules.
|
|
59
|
+
|
|
41
60
|
### Global Installation (Recommended)
|
|
42
61
|
|
|
43
62
|
```bash
|
|
@@ -74,22 +93,20 @@ git-ripper https://github.com/username/repository/tree/branch/folder -o ./my-out
|
|
|
74
93
|
git-ripper https://github.com/username/repository/tree/branch/folder --zip
|
|
75
94
|
```
|
|
76
95
|
|
|
77
|
-
### Creating
|
|
96
|
+
### Creating ZIP Archive with Custom Name
|
|
78
97
|
|
|
79
98
|
```bash
|
|
80
|
-
git-ripper https://github.com/username/repository/tree/branch/folder --
|
|
99
|
+
git-ripper https://github.com/username/repository/tree/branch/folder --zip="my-archive.zip"
|
|
81
100
|
```
|
|
82
101
|
|
|
83
102
|
### Command Line Options
|
|
84
103
|
|
|
85
|
-
| Option
|
|
86
|
-
|
|
87
|
-
| `-o, --output <directory>` | Specify output directory
|
|
88
|
-
| `--zip [filename]`
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
| `-V, --version` | Show version number | - |
|
|
92
|
-
| `-h, --help` | Show help | - |
|
|
104
|
+
| Option | Description | Default |
|
|
105
|
+
| -------------------------- | ---------------------------------------- | ----------------- |
|
|
106
|
+
| `-o, --output <directory>` | Specify output directory | Current directory |
|
|
107
|
+
| `--zip [filename]` | Create ZIP archive of downloaded content | - |
|
|
108
|
+
| `-V, --version` | Show version number | - |
|
|
109
|
+
| `-h, --help` | Show help | - |
|
|
93
110
|
|
|
94
111
|
## Examples
|
|
95
112
|
|
|
@@ -127,8 +144,8 @@ git-ripper https://github.com/tailwindlabs/tailwindcss/tree/master/src/component
|
|
|
127
144
|
# Download React DOM package and create a ZIP archive
|
|
128
145
|
git-ripper https://github.com/facebook/react/tree/main/packages/react-dom --zip
|
|
129
146
|
|
|
130
|
-
# Extract VS Code build configuration with
|
|
131
|
-
git-ripper https://github.com/microsoft/vscode/tree/main/build --
|
|
147
|
+
# Extract VS Code build configuration with custom archive name
|
|
148
|
+
git-ripper https://github.com/microsoft/vscode/tree/main/build --zip="vscode-build.zip"
|
|
132
149
|
```
|
|
133
150
|
|
|
134
151
|
## How It Works
|
|
@@ -184,16 +201,6 @@ Contributions make the open-source community an amazing place to learn, inspire,
|
|
|
184
201
|
|
|
185
202
|
See the [open issues](https://github.com/sairajB/git-ripper/issues) for a list of proposed features and known issues.
|
|
186
203
|
|
|
187
|
-
## Roadmap
|
|
188
|
-
|
|
189
|
-
- [x] Add archive export options (ZIP/TAR)
|
|
190
|
-
- [ ] Add GitHub token authentication
|
|
191
|
-
- [ ] Support for GitLab and Bitbucket repositories
|
|
192
|
-
- [ ] Download from specific commits or tags
|
|
193
|
-
- [ ] Dry run mode
|
|
194
|
-
- [ ] File filtering options
|
|
195
|
-
- [ ] CLI interactive mode
|
|
196
|
-
|
|
197
204
|
## License
|
|
198
205
|
|
|
199
206
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
package/package.json
CHANGED
|
@@ -1,60 +1,62 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
2
|
+
"name": "git-ripper",
|
|
3
|
+
"version": "1.4.2",
|
|
4
|
+
"description": "CLI tool that lets you download specific folders from GitHub repositories without cloning the entire repo.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"git-ripper": "bin/git-ripper.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
12
|
+
"dev": "node bin/git-ripper.js",
|
|
13
|
+
"lint": "eslint ."
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"git",
|
|
17
|
+
"clone",
|
|
18
|
+
"github",
|
|
19
|
+
"subfolder",
|
|
20
|
+
"repository",
|
|
21
|
+
"download",
|
|
22
|
+
"partial-clone",
|
|
23
|
+
"directory-download",
|
|
24
|
+
"folder-download",
|
|
25
|
+
"git-utilities",
|
|
26
|
+
"github-api",
|
|
27
|
+
"monorepo-tools",
|
|
28
|
+
"sparse-checkout"
|
|
29
|
+
],
|
|
30
|
+
"author": "sairajb",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ansi-styles": "^6.2.1",
|
|
34
|
+
"archiver": "^6.0.1",
|
|
35
|
+
"axios": "^1.6.7",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"cli-progress": "^3.12.0",
|
|
38
|
+
"commander": "^12.0.0",
|
|
39
|
+
"p-limit": "^6.2.0",
|
|
40
|
+
"pretty-bytes": "^6.1.1",
|
|
41
|
+
"supports-color": "^9.3.1"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/sairajB/git-ripper.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/sairajB/git-ripper/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/sairajB/git-ripper",
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=16.0.0"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"bin/",
|
|
56
|
+
"src/",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
60
62
|
}
|
package/src/archiver.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import archiver from
|
|
4
|
-
import chalk from
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import archiver from "archiver";
|
|
4
|
+
import chalk from "chalk";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Validates the output path for an archive file
|
|
@@ -12,29 +12,33 @@ import chalk from 'chalk';
|
|
|
12
12
|
const validateArchivePath = (outputPath) => {
|
|
13
13
|
// Check if path is provided
|
|
14
14
|
if (!outputPath) {
|
|
15
|
-
throw new Error(
|
|
15
|
+
throw new Error("Output path is required");
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
// Check if the output directory exists or can be created
|
|
19
19
|
const outputDir = path.dirname(outputPath);
|
|
20
20
|
try {
|
|
21
21
|
if (!fs.existsSync(outputDir)) {
|
|
22
22
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
// Check if the directory is writable
|
|
26
26
|
fs.accessSync(outputDir, fs.constants.W_OK);
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
// Check if file already exists and is writable
|
|
29
29
|
if (fs.existsSync(outputPath)) {
|
|
30
30
|
fs.accessSync(outputPath, fs.constants.W_OK);
|
|
31
31
|
// File exists and is writable, so we'll overwrite it
|
|
32
|
-
console.warn(
|
|
32
|
+
console.warn(
|
|
33
|
+
chalk.yellow(
|
|
34
|
+
`Warning: File ${outputPath} already exists and will be overwritten`
|
|
35
|
+
)
|
|
36
|
+
);
|
|
33
37
|
}
|
|
34
|
-
|
|
38
|
+
|
|
35
39
|
return true;
|
|
36
40
|
} catch (error) {
|
|
37
|
-
if (error.code ===
|
|
41
|
+
if (error.code === "EACCES") {
|
|
38
42
|
throw new Error(`Permission denied: Cannot write to ${outputPath}`);
|
|
39
43
|
}
|
|
40
44
|
throw new Error(`Invalid output path: ${error.message}`);
|
|
@@ -42,86 +46,74 @@ const validateArchivePath = (outputPath) => {
|
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
|
-
* Creates
|
|
46
|
-
*
|
|
49
|
+
* Creates a ZIP archive from a directory with standard compression
|
|
50
|
+
*
|
|
47
51
|
* @param {string} sourceDir - Source directory to archive
|
|
48
52
|
* @param {string} outputPath - Path where the archive should be saved
|
|
49
|
-
* @param {object} options - Archive options
|
|
50
|
-
* @param {string} options.format - Archive format ('zip' or 'tar')
|
|
51
|
-
* @param {number} options.compressionLevel - Compression level (0-9, default: 6)
|
|
52
53
|
* @returns {Promise<string>} - Path to the created archive
|
|
53
54
|
*/
|
|
54
|
-
export const createArchive = (sourceDir, outputPath
|
|
55
|
+
export const createArchive = (sourceDir, outputPath) => {
|
|
55
56
|
return new Promise((resolve, reject) => {
|
|
56
57
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// Fixed compression level of 5 (balanced between speed and size)
|
|
59
|
+
const compressionLevel = 5;
|
|
60
|
+
|
|
59
61
|
// Validate source directory
|
|
60
62
|
if (!fs.existsSync(sourceDir)) {
|
|
61
|
-
return reject(
|
|
63
|
+
return reject(
|
|
64
|
+
new Error(`Source directory does not exist: ${sourceDir}`)
|
|
65
|
+
);
|
|
62
66
|
}
|
|
63
|
-
|
|
67
|
+
|
|
64
68
|
const stats = fs.statSync(sourceDir);
|
|
65
69
|
if (!stats.isDirectory()) {
|
|
66
|
-
return reject(
|
|
70
|
+
return reject(
|
|
71
|
+
new Error(`Source path is not a directory: ${sourceDir}`)
|
|
72
|
+
);
|
|
67
73
|
}
|
|
68
|
-
|
|
74
|
+
|
|
69
75
|
// Validate output path
|
|
70
76
|
validateArchivePath(outputPath);
|
|
71
|
-
|
|
72
|
-
// Ensure the output directory exists
|
|
73
|
-
const outputDir = path.dirname(outputPath);
|
|
74
|
-
if (!fs.existsSync(outputDir)) {
|
|
75
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
76
|
-
}
|
|
77
|
-
|
|
77
|
+
|
|
78
78
|
// Create output stream
|
|
79
79
|
const output = fs.createWriteStream(outputPath);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
});
|
|
87
|
-
} else if (format === 'tar') {
|
|
88
|
-
archive = archiver('tar');
|
|
89
|
-
// Use gzip compression for tar if compressionLevel > 0
|
|
90
|
-
if (compressionLevel > 0) {
|
|
91
|
-
archive = archiver('tar', {
|
|
92
|
-
gzip: true,
|
|
93
|
-
gzipOptions: { level: compressionLevel }
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
} else {
|
|
97
|
-
return reject(new Error(`Unsupported archive format: ${format}`));
|
|
98
|
-
}
|
|
99
|
-
|
|
80
|
+
|
|
81
|
+
// Create ZIP archive with standard compression
|
|
82
|
+
const archive = archiver("zip", {
|
|
83
|
+
zlib: { level: compressionLevel },
|
|
84
|
+
});
|
|
85
|
+
|
|
100
86
|
// Listen for archive events
|
|
101
|
-
output.on(
|
|
87
|
+
output.on("close", () => {
|
|
102
88
|
const size = archive.pointer();
|
|
103
|
-
console.log(
|
|
89
|
+
console.log(
|
|
90
|
+
chalk.green(
|
|
91
|
+
`✓ Archive created: ${outputPath} (${(size / 1024 / 1024).toFixed(
|
|
92
|
+
2
|
|
93
|
+
)} MB)`
|
|
94
|
+
)
|
|
95
|
+
);
|
|
104
96
|
resolve(outputPath);
|
|
105
97
|
});
|
|
106
|
-
|
|
107
|
-
archive.on(
|
|
98
|
+
|
|
99
|
+
archive.on("error", (err) => {
|
|
108
100
|
reject(err);
|
|
109
101
|
});
|
|
110
|
-
|
|
111
|
-
archive.on(
|
|
112
|
-
if (err.code ===
|
|
102
|
+
|
|
103
|
+
archive.on("warning", (err) => {
|
|
104
|
+
if (err.code === "ENOENT") {
|
|
113
105
|
console.warn(chalk.yellow(`Warning: ${err.message}`));
|
|
114
106
|
} else {
|
|
115
107
|
reject(err);
|
|
116
108
|
}
|
|
117
109
|
});
|
|
118
|
-
|
|
110
|
+
|
|
119
111
|
// Pipe archive data to the output file
|
|
120
112
|
archive.pipe(output);
|
|
121
|
-
|
|
113
|
+
|
|
122
114
|
// Add the directory contents to the archive
|
|
123
115
|
archive.directory(sourceDir, false);
|
|
124
|
-
|
|
116
|
+
|
|
125
117
|
// Finalize the archive
|
|
126
118
|
archive.finalize();
|
|
127
119
|
} catch (error) {
|
|
@@ -131,47 +123,51 @@ export const createArchive = (sourceDir, outputPath, options = {}) => {
|
|
|
131
123
|
};
|
|
132
124
|
|
|
133
125
|
/**
|
|
134
|
-
* Downloads folder contents and creates
|
|
135
|
-
*
|
|
126
|
+
* Downloads folder contents and creates a ZIP archive
|
|
127
|
+
*
|
|
136
128
|
* @param {object} repoInfo - Repository information object
|
|
137
129
|
* @param {string} outputDir - Directory where files should be downloaded
|
|
138
|
-
* @param {string}
|
|
139
|
-
* @param {string} archiveName - Custom name for the archive file
|
|
140
|
-
* @param {number} compressionLevel - Compression level (0-9)
|
|
130
|
+
* @param {string} archiveName - Custom name for the archive file (optional)
|
|
141
131
|
* @returns {Promise<string>} - Path to the created archive
|
|
142
132
|
*/
|
|
143
|
-
export const downloadAndArchive = async (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
133
|
+
export const downloadAndArchive = async (
|
|
134
|
+
repoInfo,
|
|
135
|
+
outputDir,
|
|
136
|
+
archiveName = null
|
|
137
|
+
) => {
|
|
138
|
+
const { downloadFolder } = await import("./downloader.js");
|
|
139
|
+
|
|
140
|
+
console.log(
|
|
141
|
+
chalk.cyan(`Downloading folder and preparing to create ZIP archive...`)
|
|
142
|
+
);
|
|
143
|
+
|
|
148
144
|
// Create a temporary directory for the download
|
|
149
145
|
const tempDir = path.join(outputDir, `.temp-${Date.now()}`);
|
|
150
146
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
151
|
-
|
|
147
|
+
|
|
152
148
|
try {
|
|
153
149
|
// Download the folder contents
|
|
154
150
|
await downloadFolder(repoInfo, tempDir);
|
|
155
|
-
|
|
151
|
+
|
|
156
152
|
// Determine archive filename
|
|
157
153
|
let archiveFileName = archiveName;
|
|
158
154
|
if (!archiveFileName) {
|
|
159
155
|
const { owner, repo, folderPath } = repoInfo;
|
|
160
|
-
const folderName = folderPath ? folderPath.split(
|
|
156
|
+
const folderName = folderPath ? folderPath.split("/").pop() : repo;
|
|
161
157
|
archiveFileName = `${folderName || repo}-${owner}`;
|
|
162
158
|
}
|
|
163
|
-
|
|
159
|
+
|
|
164
160
|
// Add extension if not present
|
|
165
|
-
if (!archiveFileName.endsWith(
|
|
166
|
-
archiveFileName +=
|
|
161
|
+
if (!archiveFileName.endsWith(`.zip`)) {
|
|
162
|
+
archiveFileName += `.zip`;
|
|
167
163
|
}
|
|
168
|
-
|
|
164
|
+
|
|
169
165
|
const archivePath = path.join(outputDir, archiveFileName);
|
|
170
|
-
|
|
166
|
+
|
|
171
167
|
// Create the archive
|
|
172
|
-
console.log(chalk.cyan(`Creating
|
|
173
|
-
await createArchive(tempDir, archivePath
|
|
174
|
-
|
|
168
|
+
console.log(chalk.cyan(`Creating ZIP archive...`));
|
|
169
|
+
await createArchive(tempDir, archivePath);
|
|
170
|
+
|
|
175
171
|
return archivePath;
|
|
176
172
|
} catch (error) {
|
|
177
173
|
throw new Error(`Failed to create archive: ${error.message}`);
|
|
@@ -180,7 +176,11 @@ export const downloadAndArchive = async (repoInfo, outputDir, archiveFormat = 'z
|
|
|
180
176
|
try {
|
|
181
177
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
182
178
|
} catch (err) {
|
|
183
|
-
console.warn(
|
|
179
|
+
console.warn(
|
|
180
|
+
chalk.yellow(
|
|
181
|
+
`Warning: Failed to clean up temporary directory: ${err.message}`
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
-
};
|
|
186
|
+
};
|
package/src/downloader.js
CHANGED
|
@@ -17,11 +17,11 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
17
17
|
const __dirname = dirname(__filename);
|
|
18
18
|
|
|
19
19
|
// Define spinner animation frames
|
|
20
|
-
const spinnerFrames = [
|
|
20
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
21
21
|
// Alternative progress bar characters for more visual appeal
|
|
22
22
|
const progressChars = {
|
|
23
|
-
complete:
|
|
24
|
-
incomplete:
|
|
23
|
+
complete: "▰", // Alternative: '■', '●', '◆', '▣'
|
|
24
|
+
incomplete: "▱", // Alternative: '□', '○', '◇', '▢'
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// Track frame index for spinner animation
|
|
@@ -46,43 +46,112 @@ const getSpinnerFrame = () => {
|
|
|
46
46
|
* @returns {Promise<Array>} - Promise resolving to an array of file objects
|
|
47
47
|
*/
|
|
48
48
|
const fetchFolderContents = async (owner, repo, branch, folderPath) => {
|
|
49
|
-
|
|
49
|
+
let effectiveBranch = branch;
|
|
50
|
+
if (!effectiveBranch) {
|
|
51
|
+
// If no branch is specified, fetch the default branch for the repository
|
|
52
|
+
try {
|
|
53
|
+
const repoInfoUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
|
54
|
+
const repoInfoResponse = await axios.get(repoInfoUrl);
|
|
55
|
+
effectiveBranch = repoInfoResponse.data.default_branch;
|
|
56
|
+
if (!effectiveBranch) {
|
|
57
|
+
console.error(
|
|
58
|
+
chalk.red(
|
|
59
|
+
`Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
console.log(
|
|
65
|
+
chalk.blue(
|
|
66
|
+
`No branch specified, using default branch: ${effectiveBranch}`
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(
|
|
71
|
+
chalk.red(
|
|
72
|
+
`Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${effectiveBranch}?recursive=1`;
|
|
50
80
|
|
|
51
81
|
try {
|
|
52
82
|
const response = await axios.get(apiUrl);
|
|
53
|
-
|
|
83
|
+
|
|
54
84
|
// Check if GitHub API returned truncated results
|
|
55
85
|
if (response.data.truncated) {
|
|
56
|
-
console.warn(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
console.warn(
|
|
87
|
+
chalk.yellow(
|
|
88
|
+
`Warning: The repository is too large and some files may be missing. ` +
|
|
89
|
+
`Consider using git clone for complete repositories.`
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Original filter:
|
|
95
|
+
// return response.data.tree.filter((item) =>
|
|
96
|
+
// item.path.startsWith(folderPath)
|
|
97
|
+
// );
|
|
98
|
+
|
|
99
|
+
// New filter logic:
|
|
100
|
+
if (folderPath === "") {
|
|
101
|
+
// For the root directory, all items from the recursive tree are relevant.
|
|
102
|
+
// item.path.startsWith("") would also achieve this.
|
|
103
|
+
return response.data.tree;
|
|
104
|
+
} else {
|
|
105
|
+
// For a specific folder, items must be *inside* that folder.
|
|
106
|
+
// Ensure folderPath is treated as a directory prefix by adding a trailing slash if not present.
|
|
107
|
+
const prefix = folderPath.endsWith("/") ? folderPath : folderPath + "/";
|
|
108
|
+
return response.data.tree.filter((item) => item.path.startsWith(prefix));
|
|
60
109
|
}
|
|
61
|
-
|
|
62
|
-
return response.data.tree.filter((item) => item.path.startsWith(folderPath));
|
|
63
110
|
} catch (error) {
|
|
64
111
|
if (error.response) {
|
|
65
112
|
// Handle specific HTTP error codes
|
|
66
|
-
switch(error.response.status) {
|
|
113
|
+
switch (error.response.status) {
|
|
67
114
|
case 403:
|
|
68
|
-
if (error.response.headers[
|
|
69
|
-
console.error(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
115
|
+
if (error.response.headers["x-ratelimit-remaining"] === "0") {
|
|
116
|
+
console.error(
|
|
117
|
+
chalk.red(
|
|
118
|
+
`GitHub API rate limit exceeded. Please wait until ${new Date(
|
|
119
|
+
parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
|
|
120
|
+
).toLocaleTimeString()} or add a GitHub token (feature coming soon).`
|
|
121
|
+
)
|
|
122
|
+
);
|
|
74
123
|
} else {
|
|
75
|
-
console.error(
|
|
124
|
+
console.error(
|
|
125
|
+
chalk.red(
|
|
126
|
+
`Access forbidden: ${
|
|
127
|
+
error.response.data.message || "Unknown reason"
|
|
128
|
+
}`
|
|
129
|
+
)
|
|
130
|
+
);
|
|
76
131
|
}
|
|
77
132
|
break;
|
|
78
133
|
case 404:
|
|
79
|
-
console.error(
|
|
134
|
+
console.error(
|
|
135
|
+
chalk.red(
|
|
136
|
+
`Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`
|
|
137
|
+
)
|
|
138
|
+
);
|
|
80
139
|
break;
|
|
81
140
|
default:
|
|
82
|
-
console.error(
|
|
141
|
+
console.error(
|
|
142
|
+
chalk.red(
|
|
143
|
+
`API error (${error.response.status}): ${
|
|
144
|
+
error.response.data.message || error.message
|
|
145
|
+
}`
|
|
146
|
+
)
|
|
147
|
+
);
|
|
83
148
|
}
|
|
84
149
|
} else if (error.request) {
|
|
85
|
-
console.error(
|
|
150
|
+
console.error(
|
|
151
|
+
chalk.red(
|
|
152
|
+
`Network error: No response received from GitHub. Please check your internet connection.`
|
|
153
|
+
)
|
|
154
|
+
);
|
|
86
155
|
} else {
|
|
87
156
|
console.error(chalk.red(`Error preparing request: ${error.message}`));
|
|
88
157
|
}
|
|
@@ -100,44 +169,81 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
|
|
|
100
169
|
* @returns {Promise<Object>} - Object containing download status
|
|
101
170
|
*/
|
|
102
171
|
const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
|
|
103
|
-
|
|
172
|
+
let effectiveBranch = branch;
|
|
173
|
+
if (!effectiveBranch) {
|
|
174
|
+
// If no branch is specified, fetch the default branch for the repository
|
|
175
|
+
// This check might be redundant if fetchFolderContents already resolved it,
|
|
176
|
+
// but it's a good fallback for direct downloadFile calls if any.
|
|
177
|
+
try {
|
|
178
|
+
const repoInfoUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
|
179
|
+
const repoInfoResponse = await axios.get(repoInfoUrl);
|
|
180
|
+
effectiveBranch = repoInfoResponse.data.default_branch;
|
|
181
|
+
if (!effectiveBranch) {
|
|
182
|
+
// console.error(chalk.red(`Could not determine default branch for ${owner}/${repo} for file ${filePath}.`));
|
|
183
|
+
// Do not log error here as it might be a root file download where branch is not in URL
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// console.error(chalk.red(`Failed to fetch default branch for ${owner}/${repo} for file ${filePath}: ${error.message}`));
|
|
187
|
+
// Do not log error here
|
|
188
|
+
}
|
|
189
|
+
// If still no branch, the raw URL might work for default branch, or fail.
|
|
190
|
+
// The original code didn't explicitly handle this for downloadFile, relying on raw.githubusercontent default behavior.
|
|
191
|
+
// For robustness, we should ensure effectiveBranch is set. If not, the URL will be malformed or use GitHub's default.
|
|
192
|
+
if (!effectiveBranch) {
|
|
193
|
+
// Fallback to a common default, or let the API call fail if truly ambiguous
|
|
194
|
+
// For raw content, GitHub often defaults to the main branch if not specified,
|
|
195
|
+
// but it's better to be explicit if we can.
|
|
196
|
+
// However, altering the URL structure for raw.githubusercontent.com without a branch
|
|
197
|
+
// might be tricky if the original URL didn't have it.
|
|
198
|
+
// The existing raw URL construction assumes branch is present or GitHub handles its absence.
|
|
199
|
+
// Let's stick to the original logic for raw URL construction if branch is not found,
|
|
200
|
+
// as `https://raw.githubusercontent.com/${owner}/${repo}/${filePath}` might work for root files on default branch.
|
|
201
|
+
// The critical part is `fetchFolderContents` determining the branch for listing.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}`;
|
|
206
|
+
const fileUrlPath = effectiveBranch
|
|
207
|
+
? `/${effectiveBranch}/${filePath}`
|
|
208
|
+
: `/${filePath}`; // filePath might be at root
|
|
209
|
+
const url = `${baseUrl}${fileUrlPath}`;
|
|
104
210
|
|
|
105
211
|
try {
|
|
106
212
|
const response = await axios.get(url, { responseType: "arraybuffer" });
|
|
107
|
-
|
|
213
|
+
|
|
108
214
|
// Ensure the directory exists
|
|
109
215
|
try {
|
|
110
216
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
111
217
|
} catch (dirError) {
|
|
112
|
-
return {
|
|
113
|
-
filePath,
|
|
114
|
-
success: false,
|
|
218
|
+
return {
|
|
219
|
+
filePath,
|
|
220
|
+
success: false,
|
|
115
221
|
error: `Failed to create directory: ${dirError.message}`,
|
|
116
|
-
size: 0
|
|
222
|
+
size: 0,
|
|
117
223
|
};
|
|
118
224
|
}
|
|
119
|
-
|
|
225
|
+
|
|
120
226
|
// Write the file
|
|
121
227
|
try {
|
|
122
228
|
fs.writeFileSync(outputPath, Buffer.from(response.data));
|
|
123
229
|
} catch (fileError) {
|
|
124
|
-
return {
|
|
125
|
-
filePath,
|
|
126
|
-
success: false,
|
|
230
|
+
return {
|
|
231
|
+
filePath,
|
|
232
|
+
success: false,
|
|
127
233
|
error: `Failed to write file: ${fileError.message}`,
|
|
128
|
-
size: 0
|
|
234
|
+
size: 0,
|
|
129
235
|
};
|
|
130
236
|
}
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
filePath,
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
filePath,
|
|
134
240
|
success: true,
|
|
135
|
-
size: response.data.length
|
|
241
|
+
size: response.data.length,
|
|
136
242
|
};
|
|
137
243
|
} catch (error) {
|
|
138
244
|
// More detailed error handling for network requests
|
|
139
245
|
let errorMessage = error.message;
|
|
140
|
-
|
|
246
|
+
|
|
141
247
|
if (error.response) {
|
|
142
248
|
// The request was made and the server responded with an error status
|
|
143
249
|
switch (error.response.status) {
|
|
@@ -154,12 +260,12 @@ const downloadFile = async (owner, repo, branch, filePath, outputPath) => {
|
|
|
154
260
|
// The request was made but no response was received
|
|
155
261
|
errorMessage = "No response from server";
|
|
156
262
|
}
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
filePath,
|
|
160
|
-
success: false,
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
filePath,
|
|
266
|
+
success: false,
|
|
161
267
|
error: errorMessage,
|
|
162
|
-
size: 0
|
|
268
|
+
size: 0,
|
|
163
269
|
};
|
|
164
270
|
}
|
|
165
271
|
};
|
|
@@ -179,31 +285,40 @@ const createProgressRenderer = (owner, repo, folderPath) => {
|
|
|
179
285
|
try {
|
|
180
286
|
const { value, total, startTime } = params;
|
|
181
287
|
const { downloadedSize = 0 } = payload || { downloadedSize: 0 };
|
|
182
|
-
|
|
288
|
+
|
|
183
289
|
// Calculate progress percentage
|
|
184
290
|
const progress = Math.min(1, Math.max(0, value / Math.max(1, total)));
|
|
185
291
|
const percentage = Math.floor(progress * 100);
|
|
186
|
-
|
|
292
|
+
|
|
187
293
|
// Calculate elapsed time
|
|
188
294
|
const elapsedSecs = Math.max(0.1, (Date.now() - startTime) / 1000);
|
|
189
|
-
|
|
295
|
+
|
|
190
296
|
// Create the progress bar
|
|
191
|
-
const barLength = Math.max(
|
|
297
|
+
const barLength = Math.max(
|
|
298
|
+
20,
|
|
299
|
+
Math.min(40, Math.floor(terminalWidth / 2))
|
|
300
|
+
);
|
|
192
301
|
const completedLength = Math.round(barLength * progress);
|
|
193
302
|
const remainingLength = barLength - completedLength;
|
|
194
|
-
|
|
303
|
+
|
|
195
304
|
// Build the bar with custom progress characters
|
|
196
|
-
const completedBar = chalk.greenBright(
|
|
197
|
-
|
|
198
|
-
|
|
305
|
+
const completedBar = chalk.greenBright(
|
|
306
|
+
progressChars.complete.repeat(completedLength)
|
|
307
|
+
);
|
|
308
|
+
const remainingBar = chalk.gray(
|
|
309
|
+
progressChars.incomplete.repeat(remainingLength)
|
|
310
|
+
);
|
|
311
|
+
|
|
199
312
|
// Add spinner for animation
|
|
200
313
|
const spinner = chalk.cyanBright(getSpinnerFrame());
|
|
201
|
-
|
|
314
|
+
|
|
202
315
|
// Format the output
|
|
203
316
|
const progressInfo = `${chalk.cyan(`${value}/${total}`)} files`;
|
|
204
317
|
const sizeInfo = prettyBytes(downloadedSize || 0);
|
|
205
|
-
|
|
206
|
-
return `${spinner} ${completedBar}${remainingBar} ${chalk.yellow(
|
|
318
|
+
|
|
319
|
+
return `${spinner} ${completedBar}${remainingBar} ${chalk.yellow(
|
|
320
|
+
percentage + "%"
|
|
321
|
+
)} | ${progressInfo} | ${chalk.magenta(sizeInfo)}`;
|
|
207
322
|
} catch (error) {
|
|
208
323
|
// Fallback to a very simple progress indicator
|
|
209
324
|
return `${Math.floor((params.value / params.total) * 100)}% complete`;
|
|
@@ -221,88 +336,120 @@ const createProgressRenderer = (owner, repo, folderPath) => {
|
|
|
221
336
|
* @param {string} outputDir - Directory where files should be saved
|
|
222
337
|
* @returns {Promise<void>} - Promise that resolves when all files are downloaded
|
|
223
338
|
*/
|
|
224
|
-
const downloadFolder = async (
|
|
225
|
-
|
|
339
|
+
const downloadFolder = async (
|
|
340
|
+
{ owner, repo, branch, folderPath },
|
|
341
|
+
outputDir
|
|
342
|
+
) => {
|
|
343
|
+
console.log(
|
|
344
|
+
chalk.cyan(`Analyzing repository structure for ${owner}/${repo}...`)
|
|
345
|
+
);
|
|
226
346
|
|
|
227
347
|
try {
|
|
228
348
|
const contents = await fetchFolderContents(owner, repo, branch, folderPath);
|
|
229
|
-
|
|
349
|
+
|
|
230
350
|
if (!contents || contents.length === 0) {
|
|
231
|
-
console.log(
|
|
351
|
+
console.log(
|
|
352
|
+
chalk.yellow(`No files found in ${folderPath || "repository root"}`)
|
|
353
|
+
);
|
|
232
354
|
console.log(chalk.green(`Folder cloned successfully!`));
|
|
233
355
|
return;
|
|
234
356
|
}
|
|
235
357
|
|
|
236
358
|
// Filter for blob type (files)
|
|
237
|
-
const files = contents.filter(item => item.type === "blob");
|
|
359
|
+
const files = contents.filter((item) => item.type === "blob");
|
|
238
360
|
const totalFiles = files.length;
|
|
239
|
-
|
|
361
|
+
|
|
240
362
|
if (totalFiles === 0) {
|
|
241
|
-
console.log(
|
|
363
|
+
console.log(
|
|
364
|
+
chalk.yellow(
|
|
365
|
+
`No files found in ${
|
|
366
|
+
folderPath || "repository root"
|
|
367
|
+
} (only directories)`
|
|
368
|
+
)
|
|
369
|
+
);
|
|
242
370
|
console.log(chalk.green(`Folder cloned successfully!`));
|
|
243
371
|
return;
|
|
244
372
|
}
|
|
245
|
-
|
|
246
|
-
console.log(
|
|
247
|
-
|
|
373
|
+
|
|
374
|
+
console.log(
|
|
375
|
+
chalk.cyan(
|
|
376
|
+
`Downloading ${totalFiles} files from ${chalk.white(
|
|
377
|
+
owner + "/" + repo
|
|
378
|
+
)}...`
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
|
|
248
382
|
// Simplified progress bar setup
|
|
249
383
|
const progressBar = new cliProgress.SingleBar({
|
|
250
384
|
format: createProgressRenderer(owner, repo, folderPath),
|
|
251
385
|
hideCursor: true,
|
|
252
386
|
clearOnComplete: false,
|
|
253
387
|
stopOnComplete: true,
|
|
254
|
-
forceRedraw: true
|
|
388
|
+
forceRedraw: true,
|
|
255
389
|
});
|
|
256
|
-
|
|
390
|
+
|
|
257
391
|
// Track download metrics
|
|
258
392
|
let downloadedSize = 0;
|
|
259
393
|
const startTime = Date.now();
|
|
260
394
|
let failedFiles = [];
|
|
261
|
-
|
|
395
|
+
|
|
262
396
|
// Start progress bar
|
|
263
397
|
progressBar.start(totalFiles, 0, {
|
|
264
398
|
downloadedSize: 0,
|
|
265
|
-
startTime
|
|
399
|
+
startTime,
|
|
266
400
|
});
|
|
267
|
-
|
|
401
|
+
|
|
268
402
|
// Create download promises with concurrency control
|
|
269
403
|
const fileDownloadPromises = files.map((item) => {
|
|
270
404
|
// Keep the original structure by preserving the folder name
|
|
271
405
|
let relativePath = item.path;
|
|
272
|
-
if (folderPath && folderPath.trim() !==
|
|
273
|
-
relativePath = item.path
|
|
406
|
+
if (folderPath && folderPath.trim() !== "") {
|
|
407
|
+
relativePath = item.path
|
|
408
|
+
.substring(folderPath.length)
|
|
409
|
+
.replace(/^\//, "");
|
|
274
410
|
}
|
|
275
411
|
const outputFilePath = path.join(outputDir, relativePath);
|
|
276
|
-
|
|
412
|
+
|
|
277
413
|
return limit(async () => {
|
|
278
414
|
try {
|
|
279
|
-
const result = await downloadFile(
|
|
280
|
-
|
|
415
|
+
const result = await downloadFile(
|
|
416
|
+
owner,
|
|
417
|
+
repo,
|
|
418
|
+
branch,
|
|
419
|
+
item.path,
|
|
420
|
+
outputFilePath
|
|
421
|
+
);
|
|
422
|
+
|
|
281
423
|
// Update progress metrics
|
|
282
424
|
if (result.success) {
|
|
283
|
-
downloadedSize +=
|
|
425
|
+
downloadedSize += result.size || 0;
|
|
284
426
|
} else {
|
|
285
427
|
// Track failed files for reporting
|
|
286
428
|
failedFiles.push({
|
|
287
429
|
path: item.path,
|
|
288
|
-
error: result.error
|
|
430
|
+
error: result.error,
|
|
289
431
|
});
|
|
290
432
|
}
|
|
291
|
-
|
|
433
|
+
|
|
292
434
|
// Update progress bar with current metrics
|
|
293
435
|
progressBar.increment(1, {
|
|
294
|
-
downloadedSize
|
|
436
|
+
downloadedSize,
|
|
295
437
|
});
|
|
296
|
-
|
|
438
|
+
|
|
297
439
|
return result;
|
|
298
440
|
} catch (error) {
|
|
299
441
|
failedFiles.push({
|
|
300
442
|
path: item.path,
|
|
301
|
-
error: error.message
|
|
443
|
+
error: error.message,
|
|
302
444
|
});
|
|
303
|
-
|
|
445
|
+
|
|
304
446
|
progressBar.increment(1, { downloadedSize });
|
|
305
|
-
return {
|
|
447
|
+
return {
|
|
448
|
+
filePath: item.path,
|
|
449
|
+
success: false,
|
|
450
|
+
error: error.message,
|
|
451
|
+
size: 0,
|
|
452
|
+
};
|
|
306
453
|
}
|
|
307
454
|
});
|
|
308
455
|
});
|
|
@@ -310,7 +457,7 @@ const downloadFolder = async ({ owner, repo, branch, folderPath }, outputDir) =>
|
|
|
310
457
|
// Execute downloads in parallel with controlled concurrency
|
|
311
458
|
const results = await Promise.all(fileDownloadPromises);
|
|
312
459
|
progressBar.stop();
|
|
313
|
-
|
|
460
|
+
|
|
314
461
|
console.log(); // Add an empty line after progress bar
|
|
315
462
|
|
|
316
463
|
// Count successful and failed downloads
|
|
@@ -318,21 +465,31 @@ const downloadFolder = async ({ owner, repo, branch, folderPath }, outputDir) =>
|
|
|
318
465
|
const failed = failedFiles.length;
|
|
319
466
|
|
|
320
467
|
if (failed > 0) {
|
|
321
|
-
console.log(
|
|
322
|
-
|
|
468
|
+
console.log(
|
|
469
|
+
chalk.yellow(
|
|
470
|
+
`Downloaded ${succeeded} files successfully, ${failed} files failed`
|
|
471
|
+
)
|
|
472
|
+
);
|
|
473
|
+
|
|
323
474
|
// Show detailed errors if there aren't too many
|
|
324
475
|
if (failed <= 5) {
|
|
325
|
-
console.log(chalk.yellow(
|
|
326
|
-
failedFiles.forEach(file => {
|
|
476
|
+
console.log(chalk.yellow("Failed files:"));
|
|
477
|
+
failedFiles.forEach((file) => {
|
|
327
478
|
console.log(chalk.yellow(` - ${file.path}: ${file.error}`));
|
|
328
479
|
});
|
|
329
480
|
} else {
|
|
330
|
-
console.log(
|
|
481
|
+
console.log(
|
|
482
|
+
chalk.yellow(
|
|
483
|
+
`${failed} files failed to download. Check your connection or repository access.`
|
|
484
|
+
)
|
|
485
|
+
);
|
|
331
486
|
}
|
|
332
487
|
} else {
|
|
333
|
-
console.log(
|
|
488
|
+
console.log(
|
|
489
|
+
chalk.green(` All ${succeeded} files downloaded successfully!`)
|
|
490
|
+
);
|
|
334
491
|
}
|
|
335
|
-
|
|
492
|
+
|
|
336
493
|
console.log(chalk.green(`Folder cloned successfully!`));
|
|
337
494
|
} catch (error) {
|
|
338
495
|
console.error(chalk.red(`Error downloading folder: ${error.message}`));
|
package/src/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { program } from
|
|
2
|
-
import { parseGitHubUrl } from
|
|
3
|
-
import { downloadFolder } from
|
|
4
|
-
import { downloadAndArchive } from
|
|
5
|
-
import { fileURLToPath } from
|
|
6
|
-
import { dirname, join, resolve } from
|
|
7
|
-
import fs from
|
|
1
|
+
import { program } from "commander";
|
|
2
|
+
import { parseGitHubUrl } from "./parser.js";
|
|
3
|
+
import { downloadFolder } from "./downloader.js";
|
|
4
|
+
import { downloadAndArchive } from "./archiver.js";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname, join, resolve } from "path";
|
|
7
|
+
import fs from "fs";
|
|
8
8
|
|
|
9
9
|
// Get package.json for version
|
|
10
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
11
|
const __dirname = dirname(__filename);
|
|
12
|
-
const packagePath = join(__dirname,
|
|
13
|
-
const packageJson = JSON.parse(fs.readFileSync(packagePath,
|
|
12
|
+
const packagePath = join(__dirname, "..", "package.json");
|
|
13
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Validates and ensures the output directory exists
|
|
@@ -20,12 +20,12 @@ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
|
20
20
|
*/
|
|
21
21
|
const validateOutputDirectory = (outputDir) => {
|
|
22
22
|
if (!outputDir) {
|
|
23
|
-
throw new Error(
|
|
23
|
+
throw new Error("Output directory is required");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Resolve to absolute path
|
|
27
27
|
const resolvedDir = resolve(outputDir);
|
|
28
|
-
|
|
28
|
+
|
|
29
29
|
try {
|
|
30
30
|
// Check if directory exists, if not try to create it
|
|
31
31
|
if (!fs.existsSync(resolvedDir)) {
|
|
@@ -34,16 +34,18 @@ const validateOutputDirectory = (outputDir) => {
|
|
|
34
34
|
// Check if it's actually a directory
|
|
35
35
|
const stats = fs.statSync(resolvedDir);
|
|
36
36
|
if (!stats.isDirectory()) {
|
|
37
|
-
throw new Error(
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Output path exists but is not a directory: ${outputDir}`
|
|
39
|
+
);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
|
-
|
|
42
|
+
|
|
41
43
|
// Check if the directory is writable
|
|
42
44
|
fs.accessSync(resolvedDir, fs.constants.W_OK);
|
|
43
|
-
|
|
45
|
+
|
|
44
46
|
return resolvedDir;
|
|
45
47
|
} catch (error) {
|
|
46
|
-
if (error.code ===
|
|
48
|
+
if (error.code === "EACCES") {
|
|
47
49
|
throw new Error(`Permission denied: Cannot write to ${outputDir}`);
|
|
48
50
|
}
|
|
49
51
|
throw new Error(`Invalid output directory: ${error.message}`);
|
|
@@ -53,53 +55,38 @@ const validateOutputDirectory = (outputDir) => {
|
|
|
53
55
|
const initializeCLI = () => {
|
|
54
56
|
program
|
|
55
57
|
.version(packageJson.version)
|
|
56
|
-
.description(
|
|
57
|
-
.argument(
|
|
58
|
-
.option(
|
|
59
|
-
.option(
|
|
60
|
-
.option('--tar [filename]', 'Create TAR archive of downloaded files')
|
|
61
|
-
.option('--compression-level <level>', 'Compression level (1-9)', '6')
|
|
58
|
+
.description("Clone specific folders from GitHub repositories")
|
|
59
|
+
.argument("<url>", "GitHub URL of the folder to clone")
|
|
60
|
+
.option("-o, --output <directory>", "Output directory", process.cwd())
|
|
61
|
+
.option("--zip [filename]", "Create ZIP archive of downloaded files")
|
|
62
62
|
.action(async (url, options) => {
|
|
63
63
|
try {
|
|
64
64
|
console.log(`Parsing URL: ${url}`);
|
|
65
65
|
const parsedUrl = parseGitHubUrl(url);
|
|
66
|
-
|
|
67
|
-
// Validate options
|
|
68
|
-
if (options.compressionLevel) {
|
|
69
|
-
const level = parseInt(options.compressionLevel, 10);
|
|
70
|
-
if (isNaN(level) || level < 1 || level > 9) {
|
|
71
|
-
throw new Error('Compression level must be a number between 1 and 9');
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
66
|
|
|
75
|
-
if (options.zip && options.tar) {
|
|
76
|
-
throw new Error('Cannot specify both --zip and --tar options at the same time');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
67
|
// Validate output directory
|
|
80
68
|
try {
|
|
81
69
|
options.output = validateOutputDirectory(options.output);
|
|
82
70
|
} catch (dirError) {
|
|
83
71
|
throw new Error(`Output directory error: ${dirError.message}`);
|
|
84
72
|
}
|
|
85
|
-
|
|
86
|
-
// Handle archive
|
|
87
|
-
const
|
|
88
|
-
const archiveName =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
await downloadAndArchive(parsedUrl, options.output, archiveFormat, archiveName, compressionLevel);
|
|
73
|
+
|
|
74
|
+
// Handle archive option
|
|
75
|
+
const createArchive = options.zip !== undefined;
|
|
76
|
+
const archiveName =
|
|
77
|
+
typeof options.zip === "string" ? options.zip : null;
|
|
78
|
+
|
|
79
|
+
if (createArchive) {
|
|
80
|
+
console.log(`Creating ZIP archive...`);
|
|
81
|
+
await downloadAndArchive(parsedUrl, options.output, archiveName);
|
|
95
82
|
} else {
|
|
96
83
|
console.log(`Downloading folder to: ${options.output}`);
|
|
97
84
|
await downloadFolder(parsedUrl, options.output);
|
|
98
85
|
}
|
|
99
|
-
|
|
100
|
-
console.log(
|
|
86
|
+
|
|
87
|
+
console.log("Operation completed successfully!");
|
|
101
88
|
} catch (error) {
|
|
102
|
-
console.error(
|
|
89
|
+
console.error("Error:", error.message);
|
|
103
90
|
process.exit(1);
|
|
104
91
|
}
|
|
105
92
|
});
|
package/src/parser.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
export function parseGitHubUrl(url) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// Validate the URL format
|
|
3
|
+
if (!url || typeof url !== "string") {
|
|
4
|
+
throw new Error("Invalid URL: URL must be a non-empty string");
|
|
5
|
+
}
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Validate if it's a GitHub URL
|
|
8
|
+
const githubUrlPattern =
|
|
9
|
+
/^https?:\/\/(?:www\.)?github\.com\/([^\/]+)\/([^\/]+)(?:\/(?:tree|blob)\/([^\/]+)(?:\/(.+))?)?$/;
|
|
10
|
+
const match = url.match(githubUrlPattern);
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"Invalid GitHub URL format. Expected: https://github.com/owner/repo[/tree|/blob]/branch/folder_or_file"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
// Additional validation
|
|
25
|
+
if (!owner || !repo) {
|
|
26
|
+
throw new Error("Invalid GitHub URL: Missing repository owner or name");
|
|
27
|
+
}
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
return { owner, repo, branch, folderPath };
|
|
27
30
|
}
|