@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.
- package/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/android-emulator.d.ts +8 -0
- package/dist/commands/android-emulator.d.ts.map +1 -0
- package/dist/commands/android-emulator.js +280 -0
- package/dist/commands/android-emulator.js.map +1 -0
- package/dist/commands/create.d.ts +1 -6
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +119 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/dev.d.ts +1 -7
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +69 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +5 -1
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/install-audit.d.ts +9 -0
- package/dist/commands/install-audit.d.ts.map +1 -0
- package/dist/commands/install-audit.js +185 -0
- package/dist/commands/install-audit.js.map +1 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -1
- package/dist/utils/android-sdk.d.ts +81 -0
- package/dist/utils/android-sdk.d.ts.map +1 -0
- package/dist/utils/android-sdk.js +432 -0
- package/dist/utils/android-sdk.js.map +1 -0
- package/dist/utils/downloader.d.ts +35 -0
- package/dist/utils/downloader.d.ts.map +1 -0
- package/dist/utils/downloader.js +214 -0
- package/dist/utils/downloader.js.map +1 -0
- package/dist/utils/emulator-deployer.d.ts +29 -0
- package/dist/utils/emulator-deployer.d.ts.map +1 -0
- package/dist/utils/emulator-deployer.js +224 -0
- package/dist/utils/emulator-deployer.js.map +1 -0
- package/dist/utils/emulator.d.ts +101 -0
- package/dist/utils/emulator.d.ts.map +1 -0
- package/dist/utils/emulator.js +410 -0
- package/dist/utils/emulator.js.map +1 -0
- package/dist/utils/java.d.ts +25 -0
- package/dist/utils/java.d.ts.map +1 -0
- package/dist/utils/java.js +363 -0
- package/dist/utils/java.js.map +1 -0
- package/dist/utils/system-tools.d.ts +93 -0
- package/dist/utils/system-tools.d.ts.map +1 -0
- package/dist/utils/system-tools.js +599 -0
- package/dist/utils/system-tools.js.map +1 -0
- package/dist/utils/template.d.ts.map +1 -1
- package/dist/utils/template.js +777 -748
- package/dist/utils/template.js.map +1 -1
- package/package.json +7 -3
- package/src/cli.ts +20 -2
- package/src/commands/android-emulator.ts +304 -0
- package/src/commands/create.ts +128 -5
- package/src/commands/dev.ts +71 -18
- package/src/commands/index.ts +3 -1
- package/src/commands/install-audit.ts +227 -0
- package/src/types/index.ts +30 -0
- package/src/utils/android-sdk.ts +478 -0
- package/src/utils/downloader.ts +201 -0
- package/src/utils/emulator-deployer.ts +210 -0
- package/src/utils/emulator.ts +463 -0
- package/src/utils/java.ts +369 -0
- package/src/utils/system-tools.ts +648 -0
- 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
|
+
}
|