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/LICENSE +20 -20
- package/README.md +340 -330
- package/bin/git-ripper.js +3 -3
- package/package.json +62 -62
- package/src/archiver.js +210 -210
- package/src/downloader.js +904 -878
- package/src/index.js +195 -195
- package/src/parser.js +37 -37
- package/src/resumeManager.js +213 -213
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (
|
|
385
|
-
const message = `No files found in ${
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
console.log(chalk.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
+
};
|