@jetstart/cli 1.1.4 → 1.4.0

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.
Files changed (68) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/android-emulator.d.ts +8 -0
  4. package/dist/commands/android-emulator.d.ts.map +1 -0
  5. package/dist/commands/android-emulator.js +280 -0
  6. package/dist/commands/android-emulator.js.map +1 -0
  7. package/dist/commands/create.d.ts +1 -6
  8. package/dist/commands/create.d.ts.map +1 -1
  9. package/dist/commands/create.js +119 -0
  10. package/dist/commands/create.js.map +1 -1
  11. package/dist/commands/dev.d.ts +1 -7
  12. package/dist/commands/dev.d.ts.map +1 -1
  13. package/dist/commands/dev.js +69 -10
  14. package/dist/commands/dev.js.map +1 -1
  15. package/dist/commands/index.d.ts +2 -0
  16. package/dist/commands/index.d.ts.map +1 -1
  17. package/dist/commands/index.js +5 -1
  18. package/dist/commands/index.js.map +1 -1
  19. package/dist/commands/install-audit.d.ts +9 -0
  20. package/dist/commands/install-audit.d.ts.map +1 -0
  21. package/dist/commands/install-audit.js +185 -0
  22. package/dist/commands/install-audit.js.map +1 -0
  23. package/dist/types/index.d.ts +22 -0
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/index.js +8 -0
  26. package/dist/types/index.js.map +1 -1
  27. package/dist/utils/android-sdk.d.ts +81 -0
  28. package/dist/utils/android-sdk.d.ts.map +1 -0
  29. package/dist/utils/android-sdk.js +432 -0
  30. package/dist/utils/android-sdk.js.map +1 -0
  31. package/dist/utils/downloader.d.ts +35 -0
  32. package/dist/utils/downloader.d.ts.map +1 -0
  33. package/dist/utils/downloader.js +214 -0
  34. package/dist/utils/downloader.js.map +1 -0
  35. package/dist/utils/emulator-deployer.d.ts +29 -0
  36. package/dist/utils/emulator-deployer.d.ts.map +1 -0
  37. package/dist/utils/emulator-deployer.js +224 -0
  38. package/dist/utils/emulator-deployer.js.map +1 -0
  39. package/dist/utils/emulator.d.ts +101 -0
  40. package/dist/utils/emulator.d.ts.map +1 -0
  41. package/dist/utils/emulator.js +410 -0
  42. package/dist/utils/emulator.js.map +1 -0
  43. package/dist/utils/java.d.ts +25 -0
  44. package/dist/utils/java.d.ts.map +1 -0
  45. package/dist/utils/java.js +363 -0
  46. package/dist/utils/java.js.map +1 -0
  47. package/dist/utils/system-tools.d.ts +93 -0
  48. package/dist/utils/system-tools.d.ts.map +1 -0
  49. package/dist/utils/system-tools.js +599 -0
  50. package/dist/utils/system-tools.js.map +1 -0
  51. package/dist/utils/template.d.ts.map +1 -1
  52. package/dist/utils/template.js +777 -748
  53. package/dist/utils/template.js.map +1 -1
  54. package/package.json +7 -3
  55. package/src/cli.ts +20 -2
  56. package/src/commands/android-emulator.ts +304 -0
  57. package/src/commands/create.ts +128 -5
  58. package/src/commands/dev.ts +71 -18
  59. package/src/commands/index.ts +3 -1
  60. package/src/commands/install-audit.ts +227 -0
  61. package/src/types/index.ts +30 -0
  62. package/src/utils/android-sdk.ts +478 -0
  63. package/src/utils/downloader.ts +201 -0
  64. package/src/utils/emulator-deployer.ts +210 -0
  65. package/src/utils/emulator.ts +463 -0
  66. package/src/utils/java.ts +369 -0
  67. package/src/utils/system-tools.ts +648 -0
  68. package/src/utils/template.ts +875 -867
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Android SDK Manager wrapper
3
+ */
4
+
5
+ import { spawn, ChildProcess } from 'child_process';
6
+ import * as fs from 'fs-extra';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { downloadAndExtract } from './downloader';
10
+ import { findAndroidSDK, getDefaultSDKPath } from './system-tools';
11
+ import { startSpinner, stopSpinner, updateSpinner } from './spinner';
12
+ import { success, error as logError, warning, info } from './logger';
13
+
14
+ export interface SDKComponent {
15
+ name: string;
16
+ path: string;
17
+ version?: string;
18
+ installed: boolean;
19
+ }
20
+
21
+ /**
22
+ * Required SDK components for JetStart
23
+ */
24
+ export const REQUIRED_SDK_COMPONENTS = [
25
+ 'platform-tools', // adb, fastboot
26
+ 'platforms;android-34', // Target API
27
+ 'platforms;android-24', // Minimum API
28
+ 'build-tools;34.0.0', // Latest build tools
29
+ 'emulator', // Android Emulator
30
+ 'system-images;android-34;google_apis;x86_64', // For AVD
31
+ ];
32
+
33
+ /**
34
+ * Android cmdline-tools download URLs
35
+ */
36
+ const CMDLINE_TOOLS_URLS = {
37
+ win32: 'https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip',
38
+ darwin: 'https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip',
39
+ linux: 'https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip',
40
+ };
41
+
42
+ /**
43
+ * Android SDK Manager class
44
+ */
45
+ export class AndroidSDKManager {
46
+ private sdkRoot: string;
47
+
48
+ constructor(sdkRoot?: string) {
49
+ this.sdkRoot = sdkRoot || '';
50
+ }
51
+
52
+ /**
53
+ * Initialize SDK root, detecting or creating as needed
54
+ */
55
+ async ensureSDKRoot(): Promise<string> {
56
+ if (this.sdkRoot && await fs.pathExists(this.sdkRoot)) {
57
+ return this.sdkRoot;
58
+ }
59
+
60
+ // Try to find existing SDK
61
+ const existingSDK = await findAndroidSDK();
62
+ if (existingSDK) {
63
+ this.sdkRoot = existingSDK;
64
+ return this.sdkRoot;
65
+ }
66
+
67
+ // Create new SDK at default location
68
+ this.sdkRoot = getDefaultSDKPath();
69
+ await fs.ensureDir(this.sdkRoot);
70
+
71
+ info(`Creating Android SDK at: ${this.sdkRoot}`);
72
+
73
+ return this.sdkRoot;
74
+ }
75
+
76
+ /**
77
+ * Install Android cmdline-tools
78
+ */
79
+ async installCmdlineTools(): Promise<void> {
80
+ const sdkRoot = await this.ensureSDKRoot();
81
+ const platform = os.platform() as 'win32' | 'darwin' | 'linux';
82
+
83
+ const url = CMDLINE_TOOLS_URLS[platform];
84
+ if (!url) {
85
+ throw new Error(`Unsupported platform: ${platform}`);
86
+ }
87
+
88
+ const extractPath = path.join(sdkRoot, 'cmdline-tools');
89
+ const latestPath = path.join(extractPath, 'latest');
90
+
91
+ // Check if already installed
92
+ if (await fs.pathExists(latestPath)) {
93
+ success('Android cmdline-tools already installed');
94
+ return;
95
+ }
96
+
97
+ // Download and extract
98
+ await downloadAndExtract(url, extractPath, 'Downloading Android cmdline-tools');
99
+
100
+ // The extracted folder is named 'cmdline-tools', need to move it to 'latest'
101
+ const extractedPath = path.join(extractPath, 'cmdline-tools');
102
+ if (await fs.pathExists(extractedPath)) {
103
+ await fs.move(extractedPath, latestPath);
104
+ }
105
+
106
+ // Set environment variables for this process
107
+ process.env.ANDROID_HOME = sdkRoot;
108
+ process.env.ANDROID_SDK_ROOT = sdkRoot;
109
+
110
+ success('Android cmdline-tools installed');
111
+ }
112
+
113
+ /**
114
+ * Get path to sdkmanager executable
115
+ */
116
+ private getSDKManagerPath(): string {
117
+ const sdkmanagerName = os.platform() === 'win32' ? 'sdkmanager.bat' : 'sdkmanager';
118
+ return path.join(this.sdkRoot, 'cmdline-tools', 'latest', 'bin', sdkmanagerName);
119
+ }
120
+
121
+ /**
122
+ * Get path to avdmanager executable
123
+ */
124
+ private getAVDManagerPath(): string {
125
+ const avdmanagerName = os.platform() === 'win32' ? 'avdmanager.bat' : 'avdmanager';
126
+ return path.join(this.sdkRoot, 'cmdline-tools', 'latest', 'bin', avdmanagerName);
127
+ }
128
+
129
+ /**
130
+ * Run sdkmanager command
131
+ */
132
+ private async runSDKManager(
133
+ args: string[],
134
+ onProgress?: (progress: number, message: string) => void
135
+ ): Promise<string> {
136
+ const sdkmanagerPath = this.getSDKManagerPath();
137
+
138
+ if (!(await fs.pathExists(sdkmanagerPath))) {
139
+ throw new Error('sdkmanager not found. Install cmdline-tools first.');
140
+ }
141
+
142
+ return new Promise((resolve, reject) => {
143
+ const proc = spawn(sdkmanagerPath, args, {
144
+ env: {
145
+ ...process.env,
146
+ ANDROID_HOME: this.sdkRoot,
147
+ ANDROID_SDK_ROOT: this.sdkRoot,
148
+ // Accept licenses automatically
149
+ JAVA_OPTS:
150
+ '-Dcom.android.sdkmanager.toolsdir=' +
151
+ path.join(this.sdkRoot, 'cmdline-tools', 'latest'),
152
+ },
153
+ shell: true,
154
+ });
155
+
156
+ let output = '';
157
+ let errorOutput = '';
158
+
159
+ proc.stdout?.on('data', (data) => {
160
+ output += data.toString();
161
+ });
162
+
163
+ // Parse stderr for progress updates
164
+ proc.stderr?.on('data', (data) => {
165
+ const text = data.toString();
166
+ errorOutput += text;
167
+
168
+ if (onProgress) {
169
+ // Parse progress percentage: "10% ▋...................."
170
+ const progressMatch = text.match(/(\d+)%/);
171
+ if (progressMatch) {
172
+ const percent = parseInt(progressMatch[1], 10);
173
+
174
+ // Extract current operation
175
+ const lines = text.split('\n');
176
+ const currentOp =
177
+ lines.find(
178
+ (l: string) =>
179
+ l.includes('Downloading') ||
180
+ l.includes('Installing') ||
181
+ l.includes('Extracting') ||
182
+ l.includes('Unzipping')
183
+ ) || 'Processing...';
184
+
185
+ onProgress(percent, currentOp.trim());
186
+ }
187
+ }
188
+ });
189
+
190
+ proc.on('close', (code) => {
191
+ if (code === 0) {
192
+ resolve(output);
193
+ } else {
194
+ reject(new Error(`sdkmanager exited with code ${code}: ${errorOutput}`));
195
+ }
196
+ });
197
+
198
+ proc.on('error', (err) => {
199
+ reject(err);
200
+ });
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Accept all SDK licenses
206
+ */
207
+ async acceptLicenses(): Promise<void> {
208
+ const spinner = startSpinner('Accepting SDK licenses...');
209
+
210
+ try {
211
+ const sdkmanagerPath = this.getSDKManagerPath();
212
+
213
+ // Add path validation (like runSDKManager does)
214
+ if (!(await fs.pathExists(sdkmanagerPath))) {
215
+ throw new Error('sdkmanager not found. Install cmdline-tools first.');
216
+ }
217
+
218
+ await new Promise<void>((resolve, reject) => {
219
+ const proc = spawn(sdkmanagerPath, ['--licenses'], {
220
+ env: {
221
+ ...process.env,
222
+ ANDROID_HOME: this.sdkRoot,
223
+ ANDROID_SDK_ROOT: this.sdkRoot,
224
+ // ADD MISSING JAVA_OPTS (critical fix)
225
+ JAVA_OPTS:
226
+ '-Dcom.android.sdkmanager.toolsdir=' +
227
+ path.join(this.sdkRoot, 'cmdline-tools', 'latest'),
228
+ },
229
+ // ADD explicit stdio configuration
230
+ stdio: ['pipe', 'pipe', 'pipe'],
231
+ shell: true,
232
+ });
233
+
234
+ // Check if stdin is writable before writing
235
+ if (proc.stdin) {
236
+ const success = proc.stdin.write('y\n'.repeat(100));
237
+ if (!success) {
238
+ // Handle backpressure - wait for drain
239
+ proc.stdin.once('drain', () => {
240
+ proc.stdin?.end();
241
+ });
242
+ } else {
243
+ proc.stdin.end();
244
+ }
245
+ } else {
246
+ reject(new Error('stdin not available'));
247
+ return;
248
+ }
249
+
250
+ // Collect stderr for debugging
251
+ let errorOutput = '';
252
+ proc.stderr?.on('data', (data) => {
253
+ errorOutput += data.toString();
254
+ });
255
+
256
+ proc.on('close', (code) => {
257
+ if (code === 0 || code === null) {
258
+ resolve();
259
+ } else {
260
+ reject(
261
+ new Error(`Failed to accept licenses: exit code ${code}\n${errorOutput}`)
262
+ );
263
+ }
264
+ });
265
+
266
+ proc.on('error', (err) => {
267
+ reject(err);
268
+ });
269
+ });
270
+
271
+ stopSpinner(spinner, true, 'SDK licenses accepted');
272
+ } catch (error) {
273
+ stopSpinner(spinner, false, 'Failed to accept licenses');
274
+ throw error;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Install an SDK component
280
+ */
281
+ async installComponent(component: string, progressLabel?: string): Promise<void> {
282
+ const label = progressLabel || `Installing ${component}`;
283
+ const spinner = startSpinner(label);
284
+
285
+ try {
286
+ // Accept licenses first
287
+ await this.acceptLicenses();
288
+
289
+ // Install component with progress updates
290
+ await this.runSDKManager(['--install', component], (percent, message) => {
291
+ // Update spinner text with progress
292
+ spinner.text = `${label} (${percent}%) - ${message}`;
293
+ });
294
+
295
+ stopSpinner(spinner, true, `${component} installed`);
296
+ } catch (error) {
297
+ stopSpinner(spinner, false, `Failed to install ${component}`);
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * List installed SDK components
304
+ */
305
+ async listInstalled(): Promise<SDKComponent[]> {
306
+ try {
307
+ const output = await this.runSDKManager(['--list_installed']);
308
+ return this.parseSDKList(output);
309
+ } catch (error) {
310
+ return [];
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Parse sdkmanager list output
316
+ */
317
+ private parseSDKList(output: string): SDKComponent[] {
318
+ const components: SDKComponent[] = [];
319
+ const lines = output.split('\n');
320
+
321
+ for (const line of lines) {
322
+ // Example: "build-tools;34.0.0 | 34.0.0 | Android SDK Build-Tools 34"
323
+ const match = line.match(/^([^|]+)\|([^|]+)\|(.+)$/);
324
+ if (match) {
325
+ const name = match[1].trim();
326
+ const version = match[2].trim();
327
+
328
+ components.push({
329
+ name,
330
+ version,
331
+ path: path.join(this.sdkRoot, name.replace(/;/g, path.sep)),
332
+ installed: true,
333
+ });
334
+ }
335
+ }
336
+
337
+ return components;
338
+ }
339
+
340
+ /**
341
+ * Update all installed components
342
+ */
343
+ async updateAll(): Promise<void> {
344
+ const spinner = startSpinner('Updating SDK components...');
345
+
346
+ try {
347
+ await this.runSDKManager(['--update']);
348
+ stopSpinner(spinner, true, 'SDK components updated');
349
+ } catch (error) {
350
+ stopSpinner(spinner, false, 'Failed to update components');
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Check if a component is installed
357
+ */
358
+ async isComponentInstalled(component: string): Promise<boolean> {
359
+ const installed = await this.listInstalled();
360
+ return installed.some((c) => c.name === component);
361
+ }
362
+
363
+ /**
364
+ * Install all required components for JetStart
365
+ */
366
+ async installRequiredComponents(): Promise<void> {
367
+ info('Installing required Android SDK components...');
368
+ console.log();
369
+
370
+ for (let i = 0; i < REQUIRED_SDK_COMPONENTS.length; i++) {
371
+ const component = REQUIRED_SDK_COMPONENTS[i];
372
+ const overallProgress = `[${i + 1}/${REQUIRED_SDK_COMPONENTS.length}]`;
373
+
374
+ try {
375
+ await this.installComponent(component, `${overallProgress} Installing ${component}`);
376
+ } catch (error) {
377
+ warning(`Failed to install ${component}: ${(error as Error).message}`);
378
+ }
379
+ }
380
+
381
+ console.log();
382
+ success('All required SDK components installed');
383
+ }
384
+
385
+ /**
386
+ * Detect missing required components
387
+ */
388
+ async detectMissingComponents(): Promise<string[]> {
389
+ const installed = await this.listInstalled();
390
+ const installedNames = installed.map((c) => c.name);
391
+
392
+ return REQUIRED_SDK_COMPONENTS.filter((c) => !installedNames.includes(c));
393
+ }
394
+
395
+ /**
396
+ * Create local.properties file with SDK path
397
+ */
398
+ async createLocalProperties(projectPath: string): Promise<void> {
399
+ const localPropertiesPath = path.join(projectPath, 'local.properties');
400
+
401
+ // Check if already exists
402
+ if (await fs.pathExists(localPropertiesPath)) {
403
+ return;
404
+ }
405
+
406
+ const sdkRoot = await this.ensureSDKRoot();
407
+
408
+ // Escape backslashes for Windows paths
409
+ const sdkPathEscaped = sdkRoot.replace(/\\/g, '\\\\');
410
+
411
+ const content = `# Automatically generated by JetStart
412
+ sdk.dir=${sdkPathEscaped}
413
+ `;
414
+
415
+ await fs.writeFile(localPropertiesPath, content, 'utf8');
416
+ info(`Created local.properties with SDK path`);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Create an SDK manager instance
422
+ */
423
+ export function createSDKManager(sdkRoot?: string): AndroidSDKManager {
424
+ // Check for mock mode (for testing)
425
+ if (process.env.JETSTART_MOCK_SDK === 'true') {
426
+ return new MockAndroidSDKManager() as any;
427
+ }
428
+
429
+ return new AndroidSDKManager(sdkRoot);
430
+ }
431
+
432
+ /**
433
+ * Mock SDK manager for testing
434
+ */
435
+ class MockAndroidSDKManager {
436
+ async ensureSDKRoot(): Promise<string> {
437
+ return '/mock/android/sdk';
438
+ }
439
+
440
+ async installCmdlineTools(): Promise<void> {
441
+ info('[MOCK] Installing cmdline-tools');
442
+ }
443
+
444
+ async acceptLicenses(): Promise<void> {
445
+ info('[MOCK] Accepting licenses');
446
+ }
447
+
448
+ async installComponent(component: string): Promise<void> {
449
+ info(`[MOCK] Installing ${component}`);
450
+ }
451
+
452
+ async listInstalled(): Promise<SDKComponent[]> {
453
+ return [
454
+ { name: 'platforms;android-34', version: '1', path: '/mock/platforms/android-34', installed: true },
455
+ { name: 'build-tools;34.0.0', version: '34.0.0', path: '/mock/build-tools/34.0.0', installed: true },
456
+ ];
457
+ }
458
+
459
+ async updateAll(): Promise<void> {
460
+ info('[MOCK] Updating all components');
461
+ }
462
+
463
+ async isComponentInstalled(component: string): Promise<boolean> {
464
+ return true;
465
+ }
466
+
467
+ async installRequiredComponents(): Promise<void> {
468
+ info('[MOCK] Installing required components');
469
+ }
470
+
471
+ async detectMissingComponents(): Promise<string[]> {
472
+ return [];
473
+ }
474
+
475
+ async createLocalProperties(projectPath: string): Promise<void> {
476
+ info(`[MOCK] Creating local.properties at ${projectPath}`);
477
+ }
478
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Download utilities with progress tracking
3
+ */
4
+
5
+ import axios from 'axios';
6
+ import * as fs from 'fs-extra';
7
+ import * as path from 'path';
8
+ import { startSpinner, stopSpinner } from './spinner';
9
+ import { error as logError } from './logger';
10
+
11
+ export interface DownloadOptions {
12
+ url: string;
13
+ destination: string;
14
+ progressLabel?: string;
15
+ expectedSize?: number;
16
+ timeout?: number;
17
+ }
18
+
19
+ /**
20
+ * Format bytes into human-readable format
21
+ */
22
+ export function formatBytes(bytes: number): string {
23
+ if (bytes === 0) return '0 B';
24
+ if (bytes < 1024) return `${bytes} B`;
25
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
26
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
27
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
28
+ }
29
+
30
+ /**
31
+ * Download a file with progress tracking
32
+ */
33
+ export async function downloadWithProgress(options: DownloadOptions): Promise<void> {
34
+ const {
35
+ url,
36
+ destination,
37
+ progressLabel = 'Downloading',
38
+ timeout = 300000, // 5 minutes default
39
+ } = options;
40
+
41
+ // Ensure destination directory exists
42
+ await fs.ensureDir(path.dirname(destination));
43
+
44
+ const response = await axios({
45
+ url,
46
+ method: 'GET',
47
+ responseType: 'stream',
48
+ timeout,
49
+ headers: {
50
+ 'User-Agent': 'JetStart-CLI',
51
+ },
52
+ });
53
+
54
+ const totalSize = parseInt(response.headers['content-length'] || '0', 10);
55
+ let downloadedSize = 0;
56
+
57
+ const spinner = startSpinner(`${progressLabel} (0%)`);
58
+
59
+ return new Promise((resolve, reject) => {
60
+ const writer = fs.createWriteStream(destination);
61
+
62
+ response.data.on('data', (chunk: Buffer) => {
63
+ downloadedSize += chunk.length;
64
+
65
+ if (totalSize > 0) {
66
+ const percent = Math.round((downloadedSize / totalSize) * 100);
67
+ const downloaded = formatBytes(downloadedSize);
68
+ const total = formatBytes(totalSize);
69
+ spinner.text = `${progressLabel} (${percent}%) - ${downloaded} / ${total}`;
70
+ } else {
71
+ const downloaded = formatBytes(downloadedSize);
72
+ spinner.text = `${progressLabel} - ${downloaded}`;
73
+ }
74
+ });
75
+
76
+ response.data.pipe(writer);
77
+
78
+ writer.on('finish', () => {
79
+ stopSpinner(spinner, true, `${progressLabel} completed`);
80
+ resolve();
81
+ });
82
+
83
+ writer.on('error', (err) => {
84
+ stopSpinner(spinner, false, `${progressLabel} failed`);
85
+ reject(err);
86
+ });
87
+
88
+ response.data.on('error', (err: Error) => {
89
+ stopSpinner(spinner, false, `${progressLabel} failed`);
90
+ reject(err);
91
+ });
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Sleep for a specified number of milliseconds
97
+ */
98
+ function sleep(ms: number): Promise<void> {
99
+ return new Promise((resolve) => setTimeout(resolve, ms));
100
+ }
101
+
102
+ /**
103
+ * Download a file with retry logic and exponential backoff
104
+ */
105
+ export async function downloadWithRetry(
106
+ options: DownloadOptions,
107
+ maxRetries: number = 3
108
+ ): Promise<void> {
109
+ let lastError: Error | null = null;
110
+
111
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
112
+ try {
113
+ await downloadWithProgress(options);
114
+ return; // Success!
115
+ } catch (err) {
116
+ lastError = err as Error;
117
+
118
+ if (attempt < maxRetries) {
119
+ const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
120
+ console.log(`\nDownload failed (attempt ${attempt}/${maxRetries}). Retrying in ${delay / 1000}s...`);
121
+ await sleep(delay);
122
+ }
123
+ }
124
+ }
125
+
126
+ logError(`Download failed after ${maxRetries} attempts: ${lastError?.message}`);
127
+ throw lastError;
128
+ }
129
+
130
+ /**
131
+ * Download and extract a ZIP file
132
+ */
133
+ export async function downloadAndExtract(
134
+ url: string,
135
+ extractPath: string,
136
+ progressLabel?: string
137
+ ): Promise<void> {
138
+ const extract = require('extract-zip');
139
+ const tempZip = path.join(require('os').tmpdir(), `jetstart-download-${Date.now()}.zip`);
140
+
141
+ try {
142
+ // Download
143
+ await downloadWithRetry({
144
+ url,
145
+ destination: tempZip,
146
+ progressLabel: progressLabel || 'Downloading archive',
147
+ });
148
+
149
+ // Extract
150
+ const spinner = startSpinner('Extracting archive...');
151
+ try {
152
+ await extract(tempZip, { dir: path.resolve(extractPath) });
153
+ stopSpinner(spinner, true, 'Archive extracted successfully');
154
+ } catch (err) {
155
+ stopSpinner(spinner, false, 'Failed to extract archive');
156
+ throw err;
157
+ }
158
+ } finally {
159
+ // Clean up temp file
160
+ try {
161
+ await fs.remove(tempZip);
162
+ } catch {
163
+ // Ignore cleanup errors
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Check if a URL is accessible
170
+ */
171
+ export async function checkUrlAccessible(url: string): Promise<boolean> {
172
+ try {
173
+ const response = await axios.head(url, {
174
+ timeout: 10000,
175
+ headers: {
176
+ 'User-Agent': 'JetStart-CLI',
177
+ },
178
+ });
179
+ return response.status >= 200 && response.status < 400;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Get file size from URL without downloading
187
+ */
188
+ export async function getRemoteFileSize(url: string): Promise<number | null> {
189
+ try {
190
+ const response = await axios.head(url, {
191
+ timeout: 10000,
192
+ headers: {
193
+ 'User-Agent': 'JetStart-CLI',
194
+ },
195
+ });
196
+ const size = response.headers['content-length'];
197
+ return size ? parseInt(size, 10) : null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }