@quicktvui/ai-cli 0.1.0 → 0.1.3

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/lib/index.js CHANGED
@@ -1,14 +1,33 @@
1
1
  const fs = require("fs");
2
2
  const os = require("os");
3
3
  const path = require("path");
4
+ const readline = require("node:readline/promises");
5
+ const http = require("http");
6
+ const https = require("https");
7
+ const net = require("net");
8
+ const { spawnSync, spawn } = require("child_process");
4
9
 
5
- const PACKAGE_VERSION = "0.1.0";
10
+ const PACKAGE_VERSION = "0.1.2";
6
11
  const DEFAULT_INSTALL_DIR = path.join(
7
12
  os.homedir(),
8
13
  ".agents",
9
14
  "skills",
10
15
  "quicktvui",
11
16
  );
17
+ const TEMPLATE_REPO_URL = "https://github.com/quicktvui/quicktvui-template.git";
18
+ const DEFAULT_QUICKTVUI_AI_VERSION = "^1.1.0";
19
+ const DEFAULT_ANDROID_AVD_NAME = "quicktvui_tv";
20
+ const DEFAULT_ANDROID_API_LEVEL = "android-35";
21
+ const DEFAULT_NODE_LTS_MAJOR = 20;
22
+ const DEFAULT_DEV_SERVER_PORT = 38989;
23
+ const RUNTIME_PACKAGE_NAME = "com.extscreen.runtime";
24
+ const RUNTIME_LAUNCH_ACTIVITY =
25
+ "com.extscreen.runtime/com.extscreen.runtime.LauncherAlias";
26
+ const RUNTIME_DEBUG_BROADCAST_ACTION =
27
+ "com.extscreen.runtime.ACTION_CHANGE_DEBUG_SERVER";
28
+ const RUNTIME_REPOSITORY_ROOT =
29
+ "http://hub.quicktvui.com/repository/maven-files/apk/runtime/dev";
30
+ const RUNTIME_REPOSITORY_METADATA_URL = `${RUNTIME_REPOSITORY_ROOT}/maven-metadata.xml`;
12
31
 
13
32
  const REQUIRED_SKILL_FILES = [
14
33
  "SKILL.md",
@@ -40,6 +59,2140 @@ function copyDirectoryRecursive(src, dest) {
40
59
  }
41
60
  }
42
61
 
62
+ function removeDirectoryIfExists(dirPath) {
63
+ if (exists(dirPath)) {
64
+ fs.rmSync(dirPath, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ function needsWindowsShell(command) {
69
+ return process.platform === "win32" && /\.(bat|cmd)$/i.test(String(command));
70
+ }
71
+
72
+ function withSpawnOptions(command, options) {
73
+ if (needsWindowsShell(command)) {
74
+ return { shell: true, ...options };
75
+ }
76
+ return options;
77
+ }
78
+
79
+ function runCommand(command, args, options = {}) {
80
+ const result = spawnSync(
81
+ command,
82
+ args,
83
+ withSpawnOptions(command, {
84
+ stdio: "inherit",
85
+ ...options,
86
+ }),
87
+ );
88
+ if (result.error) {
89
+ throw new Error(`Failed to run '${command}': ${result.error.message}`);
90
+ }
91
+ if (typeof result.status === "number" && result.status !== 0) {
92
+ throw new Error(`Command failed: ${command} ${args.join(" ")}`);
93
+ }
94
+ }
95
+
96
+ function runCommandCapture(command, args, options = {}) {
97
+ const result = spawnSync(
98
+ command,
99
+ args,
100
+ withSpawnOptions(command, {
101
+ encoding: "utf8",
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ ...options,
104
+ }),
105
+ );
106
+
107
+ if (result.error) {
108
+ throw new Error(`Failed to run '${command}': ${result.error.message}`);
109
+ }
110
+
111
+ if (typeof result.status === "number" && result.status !== 0) {
112
+ const stderr = (result.stderr || "").trim();
113
+ const stdout = (result.stdout || "").trim();
114
+ const reason = stderr || stdout || "unknown error";
115
+ throw new Error(`Command failed: ${command} ${args.join(" ")}\n${reason}`);
116
+ }
117
+
118
+ return {
119
+ stdout: result.stdout || "",
120
+ stderr: result.stderr || "",
121
+ };
122
+ }
123
+
124
+ function runCommandDetached(command, args, options = {}) {
125
+ let child;
126
+ try {
127
+ child = spawn(
128
+ command,
129
+ args,
130
+ withSpawnOptions(command, {
131
+ detached: true,
132
+ stdio: "ignore",
133
+ ...options,
134
+ }),
135
+ );
136
+ } catch (error) {
137
+ throw new Error(`Failed to run '${command}': ${error.message}`);
138
+ }
139
+ child.unref();
140
+ }
141
+
142
+ function commandExists(command) {
143
+ const result = spawnSync(command, ["--version"], { stdio: "ignore" });
144
+ return !result.error && result.status === 0;
145
+ }
146
+
147
+ function commandCanRun(command, args) {
148
+ const result = spawnSync(
149
+ command,
150
+ args,
151
+ withSpawnOptions(command, { stdio: "ignore" }),
152
+ );
153
+ return !result.error && result.status === 0;
154
+ }
155
+
156
+ function readJsonFile(filePath) {
157
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
158
+ }
159
+
160
+ function writeJsonFile(filePath, value) {
161
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
162
+ }
163
+
164
+ function resolveBundledTemplateSource() {
165
+ const packageTemplate = path.resolve(
166
+ __dirname,
167
+ "..",
168
+ "templates",
169
+ "quicktvui-template",
170
+ );
171
+ if (exists(packageTemplate)) return packageTemplate;
172
+
173
+ const monorepoFallback = path.resolve(
174
+ __dirname,
175
+ "..",
176
+ "..",
177
+ "..",
178
+ "packages",
179
+ "quicktvui-ai-cli",
180
+ "templates",
181
+ "quicktvui-template",
182
+ );
183
+ if (exists(monorepoFallback)) return monorepoFallback;
184
+
185
+ throw new Error(
186
+ "Bundled quicktvui-template snapshot is missing from @quicktvui/ai-cli.",
187
+ );
188
+ }
189
+
190
+ function ensureProjectPackageRules(projectDir, projectName) {
191
+ const packageJsonPath = path.join(projectDir, "package.json");
192
+ if (!exists(packageJsonPath)) {
193
+ throw new Error(`Missing package.json in created project: ${projectDir}`);
194
+ }
195
+
196
+ const packageJson = readJsonFile(packageJsonPath);
197
+ packageJson.name = projectName;
198
+ packageJson.version = "1.0.0";
199
+
200
+ if (!packageJson.devDependencies) {
201
+ packageJson.devDependencies = {};
202
+ }
203
+ if (!packageJson.devDependencies["@quicktvui/ai"]) {
204
+ packageJson.devDependencies["@quicktvui/ai"] = DEFAULT_QUICKTVUI_AI_VERSION;
205
+ }
206
+
207
+ writeJsonFile(packageJsonPath, packageJson);
208
+ }
209
+
210
+ function installProjectDependencies(projectDir, args) {
211
+ if (args["skip-install"]) {
212
+ console.log("Skip dependency installation due to --skip-install.");
213
+ return;
214
+ }
215
+
216
+ if (commandExists("yarn")) {
217
+ runCommand("yarn", ["install"], { cwd: projectDir });
218
+ return;
219
+ }
220
+
221
+ if (commandExists("npm")) {
222
+ runCommand("npm", ["install"], { cwd: projectDir });
223
+ return;
224
+ }
225
+
226
+ console.log(
227
+ "Warning: neither yarn nor npm found. Install dependencies manually in project directory.",
228
+ );
229
+ }
230
+
231
+ function isDirectoryEmpty(dirPath) {
232
+ if (!exists(dirPath)) return true;
233
+ const entries = fs.readdirSync(dirPath);
234
+ return entries.length === 0;
235
+ }
236
+
237
+ function delay(ms) {
238
+ return new Promise((resolve) => {
239
+ setTimeout(resolve, ms);
240
+ });
241
+ }
242
+
243
+ function pickPrimaryDeviceSerial(deviceState) {
244
+ if (!deviceState || !Array.isArray(deviceState.devices)) return null;
245
+ if (deviceState.devices.length === 0) return null;
246
+ const emulator = deviceState.devices.find((serial) =>
247
+ serial.startsWith("emulator-"),
248
+ );
249
+ return emulator || deviceState.devices[0];
250
+ }
251
+
252
+ function getLocalIPv4Address() {
253
+ const interfaces = os.networkInterfaces();
254
+ for (const name of Object.keys(interfaces)) {
255
+ const records = interfaces[name] || [];
256
+ for (const item of records) {
257
+ if (
258
+ item &&
259
+ item.family === "IPv4" &&
260
+ item.address !== "127.0.0.1" &&
261
+ !item.internal
262
+ ) {
263
+ return item.address;
264
+ }
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ function parseVersionSegments(version) {
271
+ return String(version)
272
+ .trim()
273
+ .split(".")
274
+ .map((segment) => {
275
+ const numberValue = Number(segment);
276
+ if (Number.isFinite(numberValue)) return numberValue;
277
+ return segment;
278
+ });
279
+ }
280
+
281
+ function compareVersionDesc(a, b) {
282
+ const aSeg = parseVersionSegments(a);
283
+ const bSeg = parseVersionSegments(b);
284
+ const max = Math.max(aSeg.length, bSeg.length);
285
+ for (let i = 0; i < max; i += 1) {
286
+ const left = typeof aSeg[i] === "undefined" ? 0 : aSeg[i];
287
+ const right = typeof bSeg[i] === "undefined" ? 0 : bSeg[i];
288
+ if (typeof left === "number" && typeof right === "number") {
289
+ if (left > right) return -1;
290
+ if (left < right) return 1;
291
+ continue;
292
+ }
293
+ const cmp = String(right).localeCompare(String(left), undefined, {
294
+ numeric: true,
295
+ sensitivity: "base",
296
+ });
297
+ if (cmp !== 0) return cmp;
298
+ }
299
+ return 0;
300
+ }
301
+
302
+ function fetchWithRedirect(url, redirectCount = 0, timeoutMs = 20000) {
303
+ return new Promise((resolve, reject) => {
304
+ if (redirectCount > 8) {
305
+ reject(new Error(`Too many redirects: ${url}`));
306
+ return;
307
+ }
308
+ const client = url.startsWith("https://") ? https : http;
309
+ const req = client.get(url, (res) => {
310
+ const status = res.statusCode || 0;
311
+ if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
312
+ const nextUrl = new URL(res.headers.location, url).toString();
313
+ res.resume();
314
+ resolve(fetchWithRedirect(nextUrl, redirectCount + 1, timeoutMs));
315
+ return;
316
+ }
317
+ if (status >= 400) {
318
+ const errorChunks = [];
319
+ res.on("data", (chunk) => errorChunks.push(chunk));
320
+ res.on("end", () => {
321
+ const detail = Buffer.concat(errorChunks).toString("utf8").trim();
322
+ reject(
323
+ new Error(
324
+ `Request failed (${status}) ${url}${detail ? `: ${detail}` : ""}`,
325
+ ),
326
+ );
327
+ });
328
+ return;
329
+ }
330
+ resolve(res);
331
+ });
332
+ req.setTimeout(timeoutMs, () => {
333
+ req.destroy(new Error(`Request timeout after ${timeoutMs}ms: ${url}`));
334
+ });
335
+ req.on("error", reject);
336
+ });
337
+ }
338
+
339
+ async function fetchText(url, timeoutMs = 20000) {
340
+ const response = await fetchWithRedirect(url, 0, timeoutMs);
341
+ return await new Promise((resolve, reject) => {
342
+ const chunks = [];
343
+ response.on("data", (chunk) => chunks.push(chunk));
344
+ response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
345
+ response.on("error", reject);
346
+ });
347
+ }
348
+
349
+ async function downloadToFile(url, filePath, timeoutMs = 60000) {
350
+ const response = await fetchWithRedirect(url, 0, timeoutMs);
351
+ await new Promise((resolve, reject) => {
352
+ const writer = fs.createWriteStream(filePath);
353
+ response.pipe(writer);
354
+ response.on("error", reject);
355
+ writer.on("error", reject);
356
+ writer.on("finish", resolve);
357
+ });
358
+ }
359
+
360
+ function parseRuntimeVersionList(metadataText) {
361
+ const matches = metadataText.match(/<version>[^<]+<\/version>/g) || [];
362
+ const versions = matches
363
+ .map((entry) =>
364
+ entry.replace("<version>", "").replace("</version>", "").trim(),
365
+ )
366
+ .filter(Boolean);
367
+ return Array.from(new Set(versions)).sort(compareVersionDesc);
368
+ }
369
+
370
+ async function fetchRuntimeVersions() {
371
+ const metadata = await fetchText(RUNTIME_REPOSITORY_METADATA_URL);
372
+ return parseRuntimeVersionList(metadata);
373
+ }
374
+
375
+ function buildRuntimeApkUrl(version) {
376
+ return `${RUNTIME_REPOSITORY_ROOT}/${version}/dev-${version}.apk`;
377
+ }
378
+
379
+ function waitForPort(host, port, timeoutMs) {
380
+ return new Promise((resolve, reject) => {
381
+ const startedAt = Date.now();
382
+
383
+ const tryConnect = () => {
384
+ const socket = new net.Socket();
385
+ let settled = false;
386
+ socket.setTimeout(2000);
387
+
388
+ socket.on("connect", () => {
389
+ if (settled) return;
390
+ settled = true;
391
+ socket.destroy();
392
+ resolve(true);
393
+ });
394
+
395
+ socket.on("timeout", () => {
396
+ socket.destroy();
397
+ });
398
+ socket.on("error", () => {
399
+ socket.destroy();
400
+ });
401
+ socket.on("close", () => {
402
+ if (settled) return;
403
+ if (Date.now() - startedAt >= timeoutMs) {
404
+ settled = true;
405
+ reject(new Error(`Timed out waiting for ${host}:${port}`));
406
+ return;
407
+ }
408
+ setTimeout(tryConnect, 1000);
409
+ });
410
+
411
+ socket.connect(port, host);
412
+ };
413
+
414
+ tryConnect();
415
+ });
416
+ }
417
+
418
+ function resolveNodeMajorVersion() {
419
+ const raw = process.versions && process.versions.node;
420
+ if (!raw) return null;
421
+ const major = Number(String(raw).split(".")[0]);
422
+ if (!Number.isFinite(major)) return null;
423
+ return major;
424
+ }
425
+
426
+ function commandExistsViaShell(shellCmd) {
427
+ const result = spawnSync(shellCmd, [], { stdio: "ignore", shell: true });
428
+ return !result.error && result.status === 0;
429
+ }
430
+
431
+ function formatBytes(bytes) {
432
+ const value = Number(bytes);
433
+ if (!Number.isFinite(value) || value <= 0) return "unknown";
434
+ const units = ["B", "KB", "MB", "GB", "TB"];
435
+ let current = value;
436
+ let index = 0;
437
+ while (current >= 1024 && index < units.length - 1) {
438
+ current /= 1024;
439
+ index += 1;
440
+ }
441
+ return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
442
+ }
443
+
444
+ function isInteractivePromptEnabled(args) {
445
+ const autoYes = toBooleanFlag(args.yes, false);
446
+ const noInteractive = toBooleanFlag(args["no-interactive"], false);
447
+ if (autoYes || noInteractive) return false;
448
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
449
+ }
450
+
451
+ async function askYesNo(question, defaultValue, args) {
452
+ if (!isInteractivePromptEnabled(args)) {
453
+ return defaultValue;
454
+ }
455
+
456
+ const rl = readline.createInterface({
457
+ input: process.stdin,
458
+ output: process.stdout,
459
+ });
460
+
461
+ try {
462
+ const suffix = defaultValue ? "[Y/n]" : "[y/N]";
463
+ const answer = (await rl.question(`${question} ${suffix} `))
464
+ .trim()
465
+ .toLowerCase();
466
+ if (!answer) return defaultValue;
467
+ if (answer === "y" || answer === "yes") return true;
468
+ if (answer === "n" || answer === "no") return false;
469
+ return defaultValue;
470
+ } finally {
471
+ rl.close();
472
+ }
473
+ }
474
+
475
+ async function askText(question, defaultValue, args) {
476
+ if (!isInteractivePromptEnabled(args)) {
477
+ return defaultValue || "";
478
+ }
479
+
480
+ const rl = readline.createInterface({
481
+ input: process.stdin,
482
+ output: process.stdout,
483
+ });
484
+
485
+ try {
486
+ const suffix = defaultValue ? ` [default: ${defaultValue}]` : "";
487
+ const answer = (await rl.question(`${question}${suffix} `)).trim();
488
+ return answer || defaultValue || "";
489
+ } finally {
490
+ rl.close();
491
+ }
492
+ }
493
+
494
+ function normalizeDeviceEndpoint(input) {
495
+ const raw = String(input || "").trim();
496
+ if (!raw) return "";
497
+ if (raw.includes(":")) return raw;
498
+ return `${raw}:5555`;
499
+ }
500
+
501
+ function tryAdbConnect(adbPath, endpoint) {
502
+ const result = spawnSync(adbPath, ["connect", endpoint], {
503
+ encoding: "utf8",
504
+ stdio: ["ignore", "pipe", "pipe"],
505
+ });
506
+ if (result.error) {
507
+ return {
508
+ connected: false,
509
+ output: result.error.message,
510
+ };
511
+ }
512
+ const stdout = (result.stdout || "").trim();
513
+ const stderr = (result.stderr || "").trim();
514
+ const output = [stdout, stderr].filter(Boolean).join("\n");
515
+ const connected = /connected to|already connected to/i.test(output);
516
+ return { connected, output };
517
+ }
518
+
519
+ async function connectRealDeviceByIp(adbPath, args) {
520
+ let defaultInput =
521
+ typeof args["device-ip"] === "string" ? args["device-ip"].trim() : "";
522
+ if (!isInteractivePromptEnabled(args) && !defaultInput) {
523
+ return null;
524
+ }
525
+ let attempt = 0;
526
+ while (attempt < 3) {
527
+ attempt += 1;
528
+ const rawInput = await askText(
529
+ "Enter Android device IP (example: 192.168.1.100 or 192.168.1.100:5555):",
530
+ defaultInput,
531
+ args,
532
+ );
533
+ const endpoint = normalizeDeviceEndpoint(rawInput);
534
+ if (!endpoint) {
535
+ const retryEmpty = await askYesNo(
536
+ "Device IP is empty. Do you want to retry input?",
537
+ attempt < 3,
538
+ args,
539
+ );
540
+ if (!retryEmpty) return null;
541
+ continue;
542
+ }
543
+
544
+ defaultInput = endpoint;
545
+ console.log(`Trying to connect device: ${endpoint}`);
546
+ const connectResult = tryAdbConnect(adbPath, endpoint);
547
+ if (connectResult.connected) {
548
+ console.log(`Device connected: ${endpoint}`);
549
+ return endpoint;
550
+ }
551
+
552
+ console.log(
553
+ `Connect failed: ${connectResult.output || "unknown adb connect error"}`,
554
+ );
555
+ const retry = await askYesNo(
556
+ "Failed to connect by IP. Try another IP?",
557
+ attempt < 3,
558
+ args,
559
+ );
560
+ if (!retry) return null;
561
+ }
562
+
563
+ return null;
564
+ }
565
+
566
+ function parseRemotePackageSizeMap(xmlText) {
567
+ const sizeMap = new Map();
568
+ const packageRegex =
569
+ /<remotePackage\s+path="([^"]+)">([\s\S]*?)<\/remotePackage>/g;
570
+ let packageMatch = packageRegex.exec(xmlText);
571
+ while (packageMatch) {
572
+ const pkgPath = packageMatch[1];
573
+ const pkgBody = packageMatch[2];
574
+ const sizeMatches = pkgBody.match(/<size>(\d+)<\/size>/g) || [];
575
+ let maxSize = 0;
576
+ for (const raw of sizeMatches) {
577
+ const num = Number(raw.replace("<size>", "").replace("</size>", ""));
578
+ if (Number.isFinite(num) && num > maxSize) {
579
+ maxSize = num;
580
+ }
581
+ }
582
+ if (maxSize > 0) {
583
+ sizeMap.set(pkgPath, maxSize);
584
+ }
585
+ packageMatch = packageRegex.exec(xmlText);
586
+ }
587
+ return sizeMap;
588
+ }
589
+
590
+ async function canReachGoogleAndroidRepo() {
591
+ try {
592
+ const res = await fetchWithRedirect(
593
+ "https://dl.google.com/android/repository/repository2-1.xml",
594
+ 0,
595
+ 8000,
596
+ );
597
+ res.resume();
598
+ return true;
599
+ } catch (error) {
600
+ return false;
601
+ }
602
+ }
603
+
604
+ async function fetchAndroidPackageSizeMap() {
605
+ const xml = await fetchText(
606
+ "https://dl.google.com/android/repository/repository2-1.xml",
607
+ );
608
+ return parseRemotePackageSizeMap(xml);
609
+ }
610
+
611
+ function printEmulatorDownloadEstimate(sizeMap, systemImageCandidates) {
612
+ const basePackages = ["platform-tools", "emulator"];
613
+ const packageLines = [];
614
+ let total = 0;
615
+
616
+ for (const pkg of basePackages) {
617
+ const size = sizeMap.get(pkg);
618
+ if (size) {
619
+ total += size;
620
+ packageLines.push(`- ${pkg}: ${formatBytes(size)}`);
621
+ } else {
622
+ packageLines.push(`- ${pkg}: unknown`);
623
+ }
624
+ }
625
+
626
+ const matchedSystemImage = systemImageCandidates.find((candidate) =>
627
+ sizeMap.has(candidate),
628
+ );
629
+ if (matchedSystemImage) {
630
+ const size = sizeMap.get(matchedSystemImage);
631
+ total += size;
632
+ packageLines.push(`- ${matchedSystemImage}: ${formatBytes(size)}`);
633
+ } else {
634
+ packageLines.push(`- system-image: unknown (candidate not found in index)`);
635
+ }
636
+
637
+ console.log("Estimated Android download size:");
638
+ for (const line of packageLines) {
639
+ console.log(line);
640
+ }
641
+ if (total > 0) {
642
+ console.log(`- total (known): ${formatBytes(total)}`);
643
+ }
644
+ console.log("Download progress will be shown by sdkmanager below.");
645
+ }
646
+
647
+ function printSdkPackageDownloadEstimate(sizeMap, packageNames, title) {
648
+ const packageLines = [];
649
+ let total = 0;
650
+ for (const packageName of packageNames) {
651
+ const size = sizeMap.get(packageName);
652
+ if (size) {
653
+ total += size;
654
+ packageLines.push(`- ${packageName}: ${formatBytes(size)}`);
655
+ } else {
656
+ packageLines.push(`- ${packageName}: unknown`);
657
+ }
658
+ }
659
+ console.log(title);
660
+ for (const line of packageLines) {
661
+ console.log(line);
662
+ }
663
+ if (total > 0) {
664
+ console.log(`- total (known): ${formatBytes(total)}`);
665
+ }
666
+ console.log("Download progress will be shown by sdkmanager below.");
667
+ }
668
+
669
+ function getDefaultAndroidSdkRoots() {
670
+ const home = os.homedir();
671
+ if (process.platform === "darwin") {
672
+ return [path.join(home, "Library", "Android", "sdk")];
673
+ }
674
+ if (process.platform === "win32") {
675
+ const localAppData = process.env.LOCALAPPDATA;
676
+ const roots = [];
677
+ if (localAppData) {
678
+ roots.push(path.join(localAppData, "Android", "sdk"));
679
+ }
680
+ roots.push(path.join(home, "AppData", "Local", "Android", "sdk"));
681
+ return roots;
682
+ }
683
+ return [path.join(home, "Android", "Sdk"), path.join(home, "Android", "sdk")];
684
+ }
685
+
686
+ function getPreferredAndroidSdkRootPath() {
687
+ const fromEnv =
688
+ process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || "";
689
+ if (fromEnv) {
690
+ return path.resolve(fromEnv);
691
+ }
692
+ const defaults = getDefaultAndroidSdkRoots();
693
+ if (defaults.length > 0) {
694
+ return path.resolve(defaults[0]);
695
+ }
696
+ return path.resolve(path.join(os.homedir(), "Android", "Sdk"));
697
+ }
698
+
699
+ function resolveAndroidSdkRoot() {
700
+ const fromEnv =
701
+ process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || "";
702
+ if (fromEnv && exists(fromEnv)) {
703
+ return {
704
+ sdkRoot: path.resolve(fromEnv),
705
+ source: "environment",
706
+ };
707
+ }
708
+
709
+ for (const sdkPath of getDefaultAndroidSdkRoots()) {
710
+ if (exists(sdkPath)) {
711
+ return {
712
+ sdkRoot: path.resolve(sdkPath),
713
+ source: "default-path",
714
+ };
715
+ }
716
+ }
717
+
718
+ return {
719
+ sdkRoot: null,
720
+ source: "missing",
721
+ };
722
+ }
723
+
724
+ function resolveAndroidSdkRootForSetup() {
725
+ const resolved = resolveAndroidSdkRoot();
726
+ if (resolved.sdkRoot) {
727
+ return resolved;
728
+ }
729
+
730
+ const autoPath = getPreferredAndroidSdkRootPath();
731
+ ensureDir(autoPath);
732
+ return {
733
+ sdkRoot: path.resolve(autoPath),
734
+ source: "auto-created",
735
+ };
736
+ }
737
+
738
+ function binaryExists(binaryPath, checkArgs = ["--version"]) {
739
+ if (!binaryPath) return false;
740
+ if (!exists(binaryPath)) return false;
741
+ const result = spawnSync(
742
+ binaryPath,
743
+ checkArgs,
744
+ withSpawnOptions(binaryPath, { stdio: "ignore" }),
745
+ );
746
+ return !result.error && result.status === 0;
747
+ }
748
+
749
+ function resolveAndroidTool(toolName, sdkRoot) {
750
+ const toolNameVariants = [];
751
+ if (process.platform === "win32") {
752
+ if (toolName === "sdkmanager" || toolName === "avdmanager") {
753
+ toolNameVariants.push(`${toolName}.bat`, `${toolName}.exe`);
754
+ } else {
755
+ toolNameVariants.push(`${toolName}.exe`);
756
+ }
757
+ } else {
758
+ toolNameVariants.push(toolName);
759
+ }
760
+
761
+ if (toolName === "adb" && commandCanRun("adb", ["version"])) {
762
+ return "adb";
763
+ }
764
+ if (toolName === "emulator" && commandCanRun("emulator", ["-version"])) {
765
+ return "emulator";
766
+ }
767
+ if (toolName === "avdmanager" && commandCanRun("avdmanager", ["--help"])) {
768
+ return "avdmanager";
769
+ }
770
+ if (toolName === "sdkmanager" && commandCanRun("sdkmanager", ["--help"])) {
771
+ return "sdkmanager";
772
+ }
773
+
774
+ if (!sdkRoot) return null;
775
+
776
+ const candidates = [];
777
+ if (toolName === "adb") {
778
+ for (const name of toolNameVariants) {
779
+ candidates.push(path.join(sdkRoot, "platform-tools", name));
780
+ }
781
+ } else if (toolName === "emulator") {
782
+ for (const name of toolNameVariants) {
783
+ candidates.push(path.join(sdkRoot, "emulator", name));
784
+ }
785
+ } else if (toolName === "sdkmanager" || toolName === "avdmanager") {
786
+ for (const name of toolNameVariants) {
787
+ candidates.push(
788
+ path.join(sdkRoot, "cmdline-tools", "latest", "bin", name),
789
+ );
790
+ candidates.push(path.join(sdkRoot, "tools", "bin", name));
791
+ }
792
+ }
793
+
794
+ for (const candidate of candidates) {
795
+ let checkArgs = ["--help"];
796
+ if (toolName === "adb") checkArgs = ["version"];
797
+ if (toolName === "emulator") checkArgs = ["-version"];
798
+ if (binaryExists(candidate, checkArgs)) {
799
+ return candidate;
800
+ }
801
+ if (
802
+ (toolName === "sdkmanager" || toolName === "avdmanager") &&
803
+ exists(candidate)
804
+ ) {
805
+ return candidate;
806
+ }
807
+ }
808
+
809
+ return null;
810
+ }
811
+
812
+ function getAndroidRepoHostOsTag() {
813
+ if (process.platform === "darwin") return "macosx";
814
+ if (process.platform === "win32") return "windows";
815
+ if (process.platform === "linux") return "linux";
816
+ return null;
817
+ }
818
+
819
+ function parseCommandLineToolsPathRank(packagePath) {
820
+ const token = String(packagePath || "").split(";")[1] || "";
821
+ if (token === "latest") return 1_000_000;
822
+ const num = Number(token.replace(/[^\d.]/g, ""));
823
+ if (Number.isFinite(num)) return num;
824
+ return 0;
825
+ }
826
+
827
+ function parseAndroidCommandLineToolsArchiveInfo(xmlText) {
828
+ const hostOs = getAndroidRepoHostOsTag();
829
+ if (!hostOs) {
830
+ throw new Error(
831
+ `Unsupported platform for Android Command-line Tools: ${process.platform}`,
832
+ );
833
+ }
834
+
835
+ const packageRegex =
836
+ /<remotePackage\s+path="(cmdline-tools;[^"]+)">([\s\S]*?)<\/remotePackage>/g;
837
+ const packageEntries = [];
838
+ let packageMatch = packageRegex.exec(xmlText);
839
+ while (packageMatch) {
840
+ const packagePath = packageMatch[1];
841
+ const body = packageMatch[2];
842
+ packageEntries.push({
843
+ packagePath,
844
+ body,
845
+ rank: parseCommandLineToolsPathRank(packagePath),
846
+ });
847
+ packageMatch = packageRegex.exec(xmlText);
848
+ }
849
+
850
+ if (packageEntries.length === 0) {
851
+ throw new Error("Unable to find Android cmdline-tools package metadata.");
852
+ }
853
+
854
+ packageEntries.sort((a, b) => b.rank - a.rank);
855
+ for (const entry of packageEntries) {
856
+ const archiveRegex = /<archive>([\s\S]*?)<\/archive>/g;
857
+ const archives = [];
858
+ let archiveMatch = archiveRegex.exec(entry.body);
859
+ while (archiveMatch) {
860
+ const archiveBody = archiveMatch[1];
861
+ const completeMatch = archiveBody.match(
862
+ /<complete>([\s\S]*?)<\/complete>/,
863
+ );
864
+ if (completeMatch) {
865
+ const completeBody = completeMatch[1];
866
+ const urlMatch = completeBody.match(/<url>([^<]+)<\/url>/);
867
+ const sizeMatch = completeBody.match(/<size>(\d+)<\/size>/);
868
+ const hostOsMatch = archiveBody.match(/<host-os>([^<]+)<\/host-os>/);
869
+ if (urlMatch) {
870
+ archives.push({
871
+ hostOs: hostOsMatch ? hostOsMatch[1] : "",
872
+ url: urlMatch[1],
873
+ sizeBytes: sizeMatch ? Number(sizeMatch[1]) : 0,
874
+ packagePath: entry.packagePath,
875
+ });
876
+ }
877
+ }
878
+ archiveMatch = archiveRegex.exec(entry.body);
879
+ }
880
+
881
+ const exactHost = archives.find((item) => item.hostOs === hostOs);
882
+ if (exactHost) return exactHost;
883
+
884
+ const noHost = archives.find((item) => !item.hostOs);
885
+ if (noHost) return noHost;
886
+ }
887
+
888
+ throw new Error(
889
+ `Unable to find Android cmdline-tools archive for host OS '${hostOs}'.`,
890
+ );
891
+ }
892
+
893
+ function buildAndroidRepositoryUrl(relativeOrAbsolutePath) {
894
+ const raw = String(relativeOrAbsolutePath || "");
895
+ if (!raw) return "";
896
+ if (/^https?:\/\//i.test(raw)) return raw;
897
+ return `https://dl.google.com/android/repository/${raw.replace(/^\/+/, "")}`;
898
+ }
899
+
900
+ function escapePowerShellLiteral(value) {
901
+ return String(value).replace(/'/g, "''");
902
+ }
903
+
904
+ function extractZipArchive(zipPath, destination) {
905
+ ensureDir(destination);
906
+ if (process.platform === "win32") {
907
+ if (
908
+ !commandCanRun("powershell", [
909
+ "-NoProfile",
910
+ "-Command",
911
+ "$PSVersionTable.PSVersion.ToString()",
912
+ ])
913
+ ) {
914
+ throw new Error(
915
+ "PowerShell is required to extract Android command-line tools zip on Windows.",
916
+ );
917
+ }
918
+ const escapedZip = escapePowerShellLiteral(path.resolve(zipPath));
919
+ const escapedDest = escapePowerShellLiteral(path.resolve(destination));
920
+ runCommand("powershell", [
921
+ "-NoProfile",
922
+ "-Command",
923
+ `Expand-Archive -Path '${escapedZip}' -DestinationPath '${escapedDest}' -Force`,
924
+ ]);
925
+ return;
926
+ }
927
+
928
+ if (commandCanRun("unzip", ["-v"])) {
929
+ runCommand("unzip", ["-q", zipPath, "-d", destination]);
930
+ return;
931
+ }
932
+
933
+ if (process.platform === "darwin" && commandCanRun("ditto", ["-h"])) {
934
+ runCommand("ditto", ["-x", "-k", zipPath, destination]);
935
+ return;
936
+ }
937
+
938
+ throw new Error(
939
+ "No zip extraction tool found. Install unzip (or use PowerShell on Windows).",
940
+ );
941
+ }
942
+
943
+ function findCommandLineToolsSourceDir(extractRoot) {
944
+ const sdkmanagerName =
945
+ process.platform === "win32" ? "sdkmanager.bat" : "sdkmanager";
946
+ const queue = [{ dir: extractRoot, depth: 0 }];
947
+ while (queue.length > 0) {
948
+ const current = queue.shift();
949
+ if (!current || current.depth > 5) continue;
950
+ const sdkmanagerPath = path.join(current.dir, "bin", sdkmanagerName);
951
+ if (exists(sdkmanagerPath)) {
952
+ return current.dir;
953
+ }
954
+ const entries = fs.readdirSync(current.dir, { withFileTypes: true });
955
+ for (const entry of entries) {
956
+ if (entry.isDirectory()) {
957
+ queue.push({
958
+ dir: path.join(current.dir, entry.name),
959
+ depth: current.depth + 1,
960
+ });
961
+ }
962
+ }
963
+ }
964
+ return null;
965
+ }
966
+
967
+ async function installAndroidCommandLineTools(sdkRoot) {
968
+ if (!sdkRoot) {
969
+ throw new Error(
970
+ "Android SDK root is missing, unable to install command-line tools.",
971
+ );
972
+ }
973
+ console.log(
974
+ "Android sdkmanager is unavailable. Try to auto-install Android Command-line Tools...",
975
+ );
976
+
977
+ const googleReachable = await canReachGoogleAndroidRepo();
978
+ if (!googleReachable) {
979
+ throw new Error(
980
+ "Unable to connect to Google Android repository (dl.google.com). Please manually install Android command-line tools and set ANDROID_SDK_ROOT.",
981
+ );
982
+ }
983
+
984
+ const repositoryXml = await fetchText(
985
+ "https://dl.google.com/android/repository/repository2-1.xml",
986
+ );
987
+ const archive = parseAndroidCommandLineToolsArchiveInfo(repositoryXml);
988
+ const downloadUrl = buildAndroidRepositoryUrl(archive.url);
989
+ if (!downloadUrl) {
990
+ throw new Error(
991
+ "Unable to resolve Android command-line tools download URL.",
992
+ );
993
+ }
994
+
995
+ if (archive.sizeBytes > 0) {
996
+ console.log("Estimated Android download size for command-line tools:");
997
+ console.log(`- ${archive.packagePath}: ${formatBytes(archive.sizeBytes)}`);
998
+ } else {
999
+ console.log(
1000
+ "Estimated Android download size for command-line tools: unknown",
1001
+ );
1002
+ }
1003
+
1004
+ const tempRoot = fs.mkdtempSync(
1005
+ path.join(os.tmpdir(), "quicktvui-cmdline-tools-"),
1006
+ );
1007
+ const zipPath = path.join(tempRoot, "commandlinetools.zip");
1008
+ const extractDir = path.join(tempRoot, "extract");
1009
+ ensureDir(extractDir);
1010
+
1011
+ try {
1012
+ console.log(`Downloading Android command-line tools: ${downloadUrl}`);
1013
+ await downloadToFile(downloadUrl, zipPath);
1014
+ console.log("Extracting Android command-line tools...");
1015
+ extractZipArchive(zipPath, extractDir);
1016
+
1017
+ const sourceDir = findCommandLineToolsSourceDir(extractDir);
1018
+ if (!sourceDir) {
1019
+ throw new Error(
1020
+ "Extracted command-line tools are invalid (sdkmanager not found).",
1021
+ );
1022
+ }
1023
+
1024
+ const cmdlineToolsDir = path.join(sdkRoot, "cmdline-tools");
1025
+ const latestDir = path.join(cmdlineToolsDir, "latest");
1026
+ ensureDir(cmdlineToolsDir);
1027
+ removeDirectoryIfExists(latestDir);
1028
+ copyDirectoryRecursive(sourceDir, latestDir);
1029
+ if (process.platform !== "win32") {
1030
+ const sdkmanagerScript = path.join(latestDir, "bin", "sdkmanager");
1031
+ const avdmanagerScript = path.join(latestDir, "bin", "avdmanager");
1032
+ if (exists(sdkmanagerScript)) fs.chmodSync(sdkmanagerScript, 0o755);
1033
+ if (exists(avdmanagerScript)) fs.chmodSync(avdmanagerScript, 0o755);
1034
+ }
1035
+ console.log(`Installed Android command-line tools: ${latestDir}`);
1036
+ } finally {
1037
+ removeDirectoryIfExists(tempRoot);
1038
+ }
1039
+ }
1040
+
1041
+ async function ensureAndroidCommandLineToolsAvailable(sdkRoot) {
1042
+ let sdkmanagerPath = resolveAndroidTool("sdkmanager", sdkRoot);
1043
+ let avdmanagerPath = resolveAndroidTool("avdmanager", sdkRoot);
1044
+ if (sdkmanagerPath && avdmanagerPath) {
1045
+ return { sdkmanagerPath, avdmanagerPath };
1046
+ }
1047
+
1048
+ await installAndroidCommandLineTools(sdkRoot);
1049
+ sdkmanagerPath = resolveAndroidTool("sdkmanager", sdkRoot);
1050
+ avdmanagerPath = resolveAndroidTool("avdmanager", sdkRoot);
1051
+ return { sdkmanagerPath, avdmanagerPath };
1052
+ }
1053
+
1054
+ function resolveAdbOverride(rawValue) {
1055
+ const value = String(rawValue || "").trim();
1056
+ if (!value) return null;
1057
+ const looksLikePath =
1058
+ value.startsWith(".") ||
1059
+ value.includes("/") ||
1060
+ value.includes("\\") ||
1061
+ path.isAbsolute(value);
1062
+ if (looksLikePath) {
1063
+ const candidate = path.resolve(value);
1064
+ return binaryExists(candidate, ["version"]) ? candidate : null;
1065
+ }
1066
+ return commandCanRun(value, ["version"]) ? value : null;
1067
+ }
1068
+
1069
+ function resolveAdbOverrideFromArgs(args) {
1070
+ const fromArg =
1071
+ typeof args["adb-path"] === "string" ? args["adb-path"].trim() : "";
1072
+ if (fromArg) {
1073
+ const resolved = resolveAdbOverride(fromArg);
1074
+ if (!resolved) {
1075
+ throw new Error(
1076
+ `Invalid --adb-path: ${fromArg}. Please provide a valid adb binary path or command.`,
1077
+ );
1078
+ }
1079
+ return { adbPath: resolved, source: "--adb-path" };
1080
+ }
1081
+
1082
+ const fromEnv =
1083
+ typeof process.env.QUICKTVUI_ADB_PATH === "string"
1084
+ ? process.env.QUICKTVUI_ADB_PATH.trim()
1085
+ : "";
1086
+ if (fromEnv) {
1087
+ const resolved = resolveAdbOverride(fromEnv);
1088
+ if (resolved) {
1089
+ return { adbPath: resolved, source: "QUICKTVUI_ADB_PATH" };
1090
+ }
1091
+ console.log(
1092
+ `Warning: QUICKTVUI_ADB_PATH is set but invalid: ${fromEnv}. Ignore and continue auto detection.`,
1093
+ );
1094
+ }
1095
+
1096
+ return null;
1097
+ }
1098
+
1099
+ async function resolveAdbPathForSetup(args, sdkRoot) {
1100
+ const override = resolveAdbOverrideFromArgs(args);
1101
+ if (override) {
1102
+ console.log(`Using adb from ${override.source}: ${override.adbPath}`);
1103
+ return override.adbPath;
1104
+ }
1105
+
1106
+ let adbPath = resolveAndroidTool("adb", sdkRoot);
1107
+ if (adbPath) {
1108
+ return adbPath;
1109
+ }
1110
+
1111
+ if (!sdkRoot) {
1112
+ return null;
1113
+ }
1114
+
1115
+ let sdkmanagerPath = resolveAndroidTool("sdkmanager", sdkRoot);
1116
+ if (!sdkmanagerPath) {
1117
+ const toolPaths = await ensureAndroidCommandLineToolsAvailable(sdkRoot);
1118
+ sdkmanagerPath = toolPaths.sdkmanagerPath;
1119
+ }
1120
+ if (!sdkmanagerPath) return null;
1121
+
1122
+ console.log(
1123
+ "adb is unavailable. Try to auto-install Android SDK Platform-Tools...",
1124
+ );
1125
+
1126
+ const googleReachable = await canReachGoogleAndroidRepo();
1127
+ if (!googleReachable) {
1128
+ throw new Error(
1129
+ "adb is missing and Google repository (dl.google.com) is unreachable. Please manually install Android SDK Platform-Tools, then rerun (or pass --adb-path / QUICKTVUI_ADB_PATH).",
1130
+ );
1131
+ }
1132
+
1133
+ try {
1134
+ const sizeMap = await fetchAndroidPackageSizeMap();
1135
+ printSdkPackageDownloadEstimate(
1136
+ sizeMap,
1137
+ ["platform-tools"],
1138
+ "Estimated Android download size for adb:",
1139
+ );
1140
+ } catch (error) {
1141
+ console.log(
1142
+ `Unable to fetch package size metadata: ${error.message}. Continue with sdkmanager download.`,
1143
+ );
1144
+ }
1145
+
1146
+ console.log("Installing required Android SDK package: platform-tools...");
1147
+ ensureAndroidSdkPackages(sdkmanagerPath, sdkRoot, ["platform-tools"]);
1148
+ adbPath = resolveAndroidTool("adb", sdkRoot);
1149
+ return adbPath;
1150
+ }
1151
+
1152
+ function parseAdbDevices(rawOutput) {
1153
+ const lines = rawOutput
1154
+ .split(/\r?\n/)
1155
+ .map((line) => line.trim())
1156
+ .filter(Boolean);
1157
+ const devices = [];
1158
+ const unauthorized = [];
1159
+ const offline = [];
1160
+
1161
+ for (const line of lines) {
1162
+ if (line.startsWith("List of devices attached")) continue;
1163
+ const columns = line.split(/\s+/);
1164
+ if (columns.length < 2) continue;
1165
+ const serial = columns[0];
1166
+ const state = columns[1];
1167
+ if (state === "device") {
1168
+ devices.push(serial);
1169
+ } else if (state === "unauthorized") {
1170
+ unauthorized.push(serial);
1171
+ } else if (state === "offline") {
1172
+ offline.push(serial);
1173
+ }
1174
+ }
1175
+
1176
+ return { devices, unauthorized, offline };
1177
+ }
1178
+
1179
+ function listConnectedDevices(adbPath) {
1180
+ const result = runCommandCapture(adbPath, ["devices"]);
1181
+ return parseAdbDevices(result.stdout);
1182
+ }
1183
+
1184
+ function listAvds(emulatorPath) {
1185
+ const result = runCommandCapture(emulatorPath, ["-list-avds"]);
1186
+ return result.stdout
1187
+ .split(/\r?\n/)
1188
+ .map((line) => line.trim())
1189
+ .filter(Boolean);
1190
+ }
1191
+
1192
+ function getPreferredSystemImageCandidates() {
1193
+ const apiLevel =
1194
+ process.env.QUICKTVUI_ANDROID_API || DEFAULT_ANDROID_API_LEVEL;
1195
+ const hostArch = os.arch();
1196
+ const abi = hostArch === "arm64" ? "arm64-v8a" : "x86_64";
1197
+ return [
1198
+ `system-images;${apiLevel};google_apis;${abi}`,
1199
+ `system-images;${apiLevel};google_apis_playstore;${abi}`,
1200
+ `system-images;${apiLevel};default;${abi}`,
1201
+ ];
1202
+ }
1203
+
1204
+ function ensureAndroidSdkPackages(sdkmanagerPath, sdkRoot, packages) {
1205
+ runCommand(sdkmanagerPath, ["--licenses"], {
1206
+ input: "y\n".repeat(80),
1207
+ stdio: ["pipe", "inherit", "inherit"],
1208
+ });
1209
+
1210
+ for (const packageName of packages) {
1211
+ runCommand(sdkmanagerPath, [`--sdk_root=${sdkRoot}`, packageName], {
1212
+ stdio: "inherit",
1213
+ });
1214
+ }
1215
+ }
1216
+
1217
+ function installSystemImagePackage(sdkmanagerPath, sdkRoot) {
1218
+ const candidates = getPreferredSystemImageCandidates();
1219
+ let lastError;
1220
+
1221
+ for (const candidate of candidates) {
1222
+ try {
1223
+ console.log(`Trying Android system image: ${candidate}`);
1224
+ runCommand(sdkmanagerPath, [`--sdk_root=${sdkRoot}`, candidate], {
1225
+ stdio: "inherit",
1226
+ });
1227
+ return candidate;
1228
+ } catch (error) {
1229
+ lastError = error;
1230
+ console.log(`- skipped: ${candidate}`);
1231
+ }
1232
+ }
1233
+
1234
+ throw new Error(
1235
+ `Unable to install Android system image automatically. ${
1236
+ lastError ? lastError.message : ""
1237
+ }`.trim(),
1238
+ );
1239
+ }
1240
+
1241
+ function createAvd(avdmanagerPath, avdName, imagePackage) {
1242
+ runCommand(
1243
+ avdmanagerPath,
1244
+ ["create", "avd", "-n", avdName, "-k", imagePackage, "-f"],
1245
+ {
1246
+ input: "no\n",
1247
+ stdio: ["pipe", "inherit", "inherit"],
1248
+ },
1249
+ );
1250
+ }
1251
+
1252
+ function startAvd(emulatorPath, avdName, args) {
1253
+ const emulatorArgs = [
1254
+ "-avd",
1255
+ avdName,
1256
+ "-netdelay",
1257
+ "none",
1258
+ "-netspeed",
1259
+ "full",
1260
+ ];
1261
+ if (args.headless) {
1262
+ emulatorArgs.push("-no-window", "-no-audio");
1263
+ }
1264
+ runCommandDetached(emulatorPath, emulatorArgs);
1265
+ }
1266
+
1267
+ async function waitForAdbDevice(adbPath, timeoutMs) {
1268
+ const deadline = Date.now() + timeoutMs;
1269
+ while (Date.now() < deadline) {
1270
+ try {
1271
+ const status = listConnectedDevices(adbPath);
1272
+ if (status.devices.length > 0) {
1273
+ return status.devices[0];
1274
+ }
1275
+ } catch (error) {
1276
+ // ignore transient adb boot race
1277
+ }
1278
+ await delay(2000);
1279
+ }
1280
+ throw new Error("Timed out waiting for Android device connection.");
1281
+ }
1282
+
1283
+ async function waitForBootComplete(adbPath, serial, timeoutMs) {
1284
+ const deadline = Date.now() + timeoutMs;
1285
+ while (Date.now() < deadline) {
1286
+ try {
1287
+ const result = runCommandCapture(adbPath, [
1288
+ "-s",
1289
+ serial,
1290
+ "shell",
1291
+ "getprop",
1292
+ "sys.boot_completed",
1293
+ ]);
1294
+ if ((result.stdout || "").trim() === "1") {
1295
+ return;
1296
+ }
1297
+ } catch (error) {
1298
+ // ignore transient shell failures during emulator boot
1299
+ }
1300
+ await delay(2500);
1301
+ }
1302
+ throw new Error(`Timed out waiting for device boot completion: ${serial}`);
1303
+ }
1304
+
1305
+ function getInstalledRuntimeInfo(adbPath, serial) {
1306
+ try {
1307
+ const result = runCommandCapture(adbPath, [
1308
+ "-s",
1309
+ serial,
1310
+ "shell",
1311
+ "dumpsys",
1312
+ "package",
1313
+ RUNTIME_PACKAGE_NAME,
1314
+ ]);
1315
+ const text = result.stdout || "";
1316
+ if (text.includes("Unable to find package")) {
1317
+ return { installed: false, versionName: null, versionCode: null };
1318
+ }
1319
+ const versionNameLine = text
1320
+ .split(/\r?\n/)
1321
+ .find((line) => line.includes("versionName="));
1322
+ const versionCodeLine = text
1323
+ .split(/\r?\n/)
1324
+ .find((line) => line.includes("versionCode="));
1325
+ const versionName = versionNameLine
1326
+ ? versionNameLine.split("=").slice(1).join("=").trim()
1327
+ : null;
1328
+ const versionCode = versionCodeLine
1329
+ ? versionCodeLine.split("=").slice(1).join("=").trim()
1330
+ : null;
1331
+ return {
1332
+ installed: true,
1333
+ versionName,
1334
+ versionCode,
1335
+ };
1336
+ } catch (error) {
1337
+ return { installed: false, versionName: null, versionCode: null };
1338
+ }
1339
+ }
1340
+
1341
+ async function waitForRuntimeRunning(adbPath, serial, timeoutMs) {
1342
+ const deadline = Date.now() + timeoutMs;
1343
+ while (Date.now() < deadline) {
1344
+ try {
1345
+ const result = runCommandCapture(adbPath, [
1346
+ "-s",
1347
+ serial,
1348
+ "shell",
1349
+ "pidof",
1350
+ RUNTIME_PACKAGE_NAME,
1351
+ ]);
1352
+ if ((result.stdout || "").trim()) {
1353
+ return true;
1354
+ }
1355
+ } catch (error) {
1356
+ // ignore during startup
1357
+ }
1358
+ await delay(1000);
1359
+ }
1360
+ return false;
1361
+ }
1362
+
1363
+ function setRuntimeDebugServerHost(adbPath, serial, hostIp) {
1364
+ runCommandCapture(adbPath, [
1365
+ "-s",
1366
+ serial,
1367
+ "shell",
1368
+ "am",
1369
+ "broadcast",
1370
+ "-a",
1371
+ RUNTIME_DEBUG_BROADCAST_ACTION,
1372
+ "--es",
1373
+ "ip",
1374
+ hostIp,
1375
+ ]);
1376
+ }
1377
+
1378
+ async function ensureRuntimeInstalledAndConfigured(adbPath, serial, args) {
1379
+ const forceRuntimeInstall = toBooleanFlag(
1380
+ args["force-runtime-install"],
1381
+ false,
1382
+ );
1383
+ const desiredRuntimeVersion =
1384
+ typeof args["runtime-version"] === "string" &&
1385
+ args["runtime-version"].trim()
1386
+ ? args["runtime-version"].trim()
1387
+ : "";
1388
+ const overrideRuntimeUrl =
1389
+ typeof args["runtime-url"] === "string" && args["runtime-url"].trim()
1390
+ ? args["runtime-url"].trim()
1391
+ : "";
1392
+ const hostIp =
1393
+ (typeof args["server-host"] === "string" && args["server-host"].trim()) ||
1394
+ getLocalIPv4Address();
1395
+
1396
+ if (!hostIp) {
1397
+ throw new Error(
1398
+ "Unable to detect local IPv4 address for runtime debug server host.",
1399
+ );
1400
+ }
1401
+
1402
+ let runtimeInfo = getInstalledRuntimeInfo(adbPath, serial);
1403
+ let installedVersion = runtimeInfo.versionName;
1404
+ const shouldInstallByVersion =
1405
+ Boolean(desiredRuntimeVersion) &&
1406
+ (!installedVersion || installedVersion !== desiredRuntimeVersion);
1407
+
1408
+ if (forceRuntimeInstall || !runtimeInfo.installed || shouldInstallByVersion) {
1409
+ let targetVersion = desiredRuntimeVersion;
1410
+ if (!targetVersion && !overrideRuntimeUrl) {
1411
+ const versions = await fetchRuntimeVersions();
1412
+ if (!versions || versions.length === 0) {
1413
+ throw new Error("No runtime versions found in runtime repository.");
1414
+ }
1415
+ targetVersion = versions[0];
1416
+ }
1417
+
1418
+ const apkUrl = overrideRuntimeUrl || buildRuntimeApkUrl(targetVersion);
1419
+ const apkName = `quicktvui-runtime-${targetVersion || Date.now()}.apk`;
1420
+ const apkPath = path.join(os.tmpdir(), apkName);
1421
+ console.log(`Downloading runtime APK: ${apkUrl}`);
1422
+ await downloadToFile(apkUrl, apkPath);
1423
+
1424
+ try {
1425
+ console.log("Installing runtime APK...");
1426
+ runCommand(adbPath, ["-s", serial, "install", "-r", apkPath], {
1427
+ stdio: "inherit",
1428
+ });
1429
+ } finally {
1430
+ if (exists(apkPath)) {
1431
+ fs.rmSync(apkPath, { force: true });
1432
+ }
1433
+ }
1434
+
1435
+ runtimeInfo = getInstalledRuntimeInfo(adbPath, serial);
1436
+ installedVersion = runtimeInfo.versionName;
1437
+ }
1438
+
1439
+ console.log(`Launching runtime app: ${RUNTIME_PACKAGE_NAME}`);
1440
+ runCommandCapture(adbPath, [
1441
+ "-s",
1442
+ serial,
1443
+ "shell",
1444
+ "am",
1445
+ "start",
1446
+ "-n",
1447
+ RUNTIME_LAUNCH_ACTIVITY,
1448
+ ]);
1449
+
1450
+ const isRunning = await waitForRuntimeRunning(adbPath, serial, 25000);
1451
+ if (!isRunning) {
1452
+ throw new Error("Runtime app did not enter running state in time.");
1453
+ }
1454
+
1455
+ setRuntimeDebugServerHost(adbPath, serial, hostIp);
1456
+ console.log(`Runtime debug server host configured: ${hostIp}`);
1457
+
1458
+ return {
1459
+ hostIp,
1460
+ runtimeVersion: installedVersion,
1461
+ };
1462
+ }
1463
+
1464
+ function resolveProjectAppPackage(projectRoot) {
1465
+ const packageJsonPath = path.join(projectRoot, "package.json");
1466
+ if (!exists(packageJsonPath)) {
1467
+ return "quicktvui-app";
1468
+ }
1469
+ const packageJson = readJsonFile(packageJsonPath);
1470
+ if (typeof packageJson.appName === "string" && packageJson.appName.trim()) {
1471
+ return packageJson.appName.trim();
1472
+ }
1473
+ if (typeof packageJson.name === "string" && packageJson.name.trim()) {
1474
+ return packageJson.name.replace(/^@[^/]+\//, "").trim();
1475
+ }
1476
+ return "quicktvui-app";
1477
+ }
1478
+
1479
+ function parseJsonObjectArg(rawValue, optionName) {
1480
+ if (typeof rawValue === "undefined") return {};
1481
+ if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
1482
+ return rawValue;
1483
+ }
1484
+ const text = String(rawValue).trim();
1485
+ if (!text) return {};
1486
+ let parsed;
1487
+ try {
1488
+ parsed = JSON.parse(text);
1489
+ } catch (error) {
1490
+ throw new Error(`${optionName} must be a valid JSON object string.`);
1491
+ }
1492
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
1493
+ throw new Error(`${optionName} must be a JSON object.`);
1494
+ }
1495
+ return parsed;
1496
+ }
1497
+
1498
+ function appendEsappParam(target, key, value) {
1499
+ if (typeof value === "undefined" || value === null) return;
1500
+ if (typeof value === "string" && !value.trim()) return;
1501
+ target[key] = value;
1502
+ }
1503
+
1504
+ function buildEsappActionStartUri(queryParams) {
1505
+ const search = new URLSearchParams();
1506
+ for (const [key, value] of Object.entries(queryParams || {})) {
1507
+ if (typeof value === "undefined" || value === null) continue;
1508
+ if (typeof value === "string" && !value.trim()) continue;
1509
+ if (typeof value === "object") {
1510
+ search.set(String(key), JSON.stringify(value));
1511
+ continue;
1512
+ }
1513
+ search.set(String(key), String(value));
1514
+ }
1515
+ const query = search.toString();
1516
+ return query ? `esapp://action/start?${query}` : "esapp://action/start";
1517
+ }
1518
+
1519
+ function ensureSupportedEsappUri(uri) {
1520
+ const normalized = String(uri || "")
1521
+ .trim()
1522
+ .toLowerCase();
1523
+ if (
1524
+ normalized.startsWith("esapp://") ||
1525
+ normalized.startsWith("quicktv://") ||
1526
+ normalized.startsWith("appcast://")
1527
+ ) {
1528
+ return;
1529
+ }
1530
+ throw new Error(
1531
+ `Unsupported URI scheme for runtime launch: ${uri}. Use esapp://, quicktv://, or appcast://.`,
1532
+ );
1533
+ }
1534
+
1535
+ function buildEsappLaunchUri(args, projectRoot, defaults = {}) {
1536
+ const positional =
1537
+ args._ && typeof args._[1] === "string" ? args._[1].trim() : "";
1538
+ const rawUri =
1539
+ (typeof args["esapp-uri"] === "string" && args["esapp-uri"].trim()) ||
1540
+ (positional.includes("://") ? positional : "");
1541
+ if (rawUri) {
1542
+ ensureSupportedEsappUri(rawUri);
1543
+ return rawUri;
1544
+ }
1545
+
1546
+ const query = {};
1547
+ const defaultPkg =
1548
+ (typeof defaults.pkg === "string" && defaults.pkg.trim()) ||
1549
+ resolveProjectAppPackage(projectRoot);
1550
+ const defaultFrom =
1551
+ (typeof defaults.from === "string" && defaults.from.trim()) || "cmd";
1552
+
1553
+ const pkg =
1554
+ (typeof args.pkg === "string" && args.pkg.trim()) ||
1555
+ (!positional.includes("://") ? positional : "") ||
1556
+ defaultPkg;
1557
+ if (!pkg) {
1558
+ throw new Error(
1559
+ "Missing target package for esapp launch. Use --pkg <package> or --esapp-uri <uri>.",
1560
+ );
1561
+ }
1562
+
1563
+ appendEsappParam(
1564
+ query,
1565
+ "from",
1566
+ (typeof args.from === "string" && args.from.trim()) || defaultFrom,
1567
+ );
1568
+ appendEsappParam(query, "pkg", pkg);
1569
+
1570
+ appendEsappParam(query, "ver", args.ver);
1571
+ appendEsappParam(query, "minVer", args["min-ver"]);
1572
+ appendEsappParam(query, "repository", args.repository);
1573
+ appendEsappParam(query, "uri", args.uri || defaults.uri);
1574
+ appendEsappParam(query, "flags", args.flags);
1575
+ appendEsappParam(query, "args", args.args);
1576
+ appendEsappParam(query, "exp", args.exp);
1577
+ appendEsappParam(query, "name", args.name);
1578
+ appendEsappParam(query, "icon", args.icon);
1579
+ appendEsappParam(query, "pageTag", args["page-tag"]);
1580
+ appendEsappParam(query, "pageLimit", args["page-limit"]);
1581
+ appendEsappParam(query, "bgColor", args["bg-color"]);
1582
+ appendEsappParam(query, "splash", args.splash);
1583
+
1584
+ if (typeof args["is-card"] !== "undefined") {
1585
+ query.isCard = toBooleanFlag(args["is-card"], false);
1586
+ }
1587
+ if (typeof args.transparent !== "undefined") {
1588
+ query.transparent = toBooleanFlag(args.transparent, false);
1589
+ }
1590
+ if (typeof args.enc !== "undefined") {
1591
+ query.enc = toBooleanFlag(args.enc, false);
1592
+ }
1593
+ if (typeof args["check-network"] !== "undefined") {
1594
+ query.checkNetwork = toBooleanFlag(args["check-network"], false);
1595
+ }
1596
+ if (typeof args["use-latest"] !== "undefined") {
1597
+ query.useLatest = toBooleanFlag(args["use-latest"], false);
1598
+ }
1599
+ if (typeof args["feature-single-activity"] !== "undefined") {
1600
+ query.feature_single_activity = toBooleanFlag(
1601
+ args["feature-single-activity"],
1602
+ false,
1603
+ );
1604
+ }
1605
+
1606
+ const extQuery = parseJsonObjectArg(args["esapp-query"], "--esapp-query");
1607
+ for (const [key, value] of Object.entries(extQuery)) {
1608
+ appendEsappParam(query, key, value);
1609
+ }
1610
+
1611
+ return buildEsappActionStartUri(query);
1612
+ }
1613
+
1614
+ function startEsappOnRuntimeByUri(adbPath, serial, runtimePackage, launchUri) {
1615
+ ensureSupportedEsappUri(launchUri);
1616
+ const result = runCommandCapture(adbPath, [
1617
+ "-s",
1618
+ serial,
1619
+ "shell",
1620
+ "am",
1621
+ "start",
1622
+ "-a",
1623
+ "android.intent.action.VIEW",
1624
+ "-p",
1625
+ runtimePackage,
1626
+ "-d",
1627
+ launchUri,
1628
+ ]);
1629
+ const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
1630
+ if (/(^|\s)error[:\s]/i.test(output) || /exception/i.test(output)) {
1631
+ throw new Error(output || "Unknown runtime start error.");
1632
+ }
1633
+ return output;
1634
+ }
1635
+
1636
+ async function clickRuntimeLoadButtonByDpad(adbPath, serial) {
1637
+ const rightCount = 6;
1638
+ for (let i = 0; i < rightCount; i += 1) {
1639
+ runCommandCapture(adbPath, [
1640
+ "-s",
1641
+ serial,
1642
+ "shell",
1643
+ "input",
1644
+ "keyevent",
1645
+ "22",
1646
+ ]);
1647
+ await delay(120);
1648
+ }
1649
+ runCommandCapture(adbPath, [
1650
+ "-s",
1651
+ serial,
1652
+ "shell",
1653
+ "input",
1654
+ "keyevent",
1655
+ "23",
1656
+ ]);
1657
+ }
1658
+
1659
+ async function autoLoadLocalBundleOnRuntime(
1660
+ adbPath,
1661
+ serial,
1662
+ projectRoot,
1663
+ hostIp,
1664
+ port,
1665
+ args = {},
1666
+ ) {
1667
+ const appPkg = resolveProjectAppPackage(projectRoot);
1668
+ const launchUri = buildEsappLaunchUri(args, projectRoot, {
1669
+ pkg: appPkg,
1670
+ from: "cmd",
1671
+ uri: `${hostIp}:${port}`,
1672
+ });
1673
+ console.log(`Auto loading bundle on runtime: ${launchUri}`);
1674
+ try {
1675
+ startEsappOnRuntimeByUri(adbPath, serial, RUNTIME_PACKAGE_NAME, launchUri);
1676
+ console.log("Triggered local bundle by runtime intent.");
1677
+ return;
1678
+ } catch (error) {
1679
+ console.log(
1680
+ `Runtime intent load failed, fallback to key events: ${error.message}`,
1681
+ );
1682
+ }
1683
+
1684
+ await clickRuntimeLoadButtonByDpad(adbPath, serial);
1685
+ console.log("Triggered runtime load button with DPAD key events.");
1686
+ }
1687
+
1688
+ function shouldInstallNodeForCurrentPlatform(requiredMajor, args) {
1689
+ const force = toBooleanFlag(args["force-node-install"], false);
1690
+ if (force) return true;
1691
+ const currentMajor = resolveNodeMajorVersion();
1692
+ if (!currentMajor) return true;
1693
+ return currentMajor < requiredMajor;
1694
+ }
1695
+
1696
+ function installNodeForMac(requiredMajor) {
1697
+ if (commandCanRun("brew", ["--version"])) {
1698
+ runCommand("brew", ["install", `node@${requiredMajor}`], {
1699
+ stdio: "inherit",
1700
+ });
1701
+ runCommand(
1702
+ "brew",
1703
+ ["link", `node@${requiredMajor}`, "--force", "--overwrite"],
1704
+ {
1705
+ stdio: "inherit",
1706
+ },
1707
+ );
1708
+ return;
1709
+ }
1710
+
1711
+ if (
1712
+ commandExistsViaShell(
1713
+ "/bin/zsh -lc 'command -v nvm >/dev/null 2>&1 && nvm --version >/dev/null 2>&1'",
1714
+ )
1715
+ ) {
1716
+ runCommand(
1717
+ "/bin/zsh",
1718
+ ["-lc", `nvm install ${requiredMajor} && nvm use ${requiredMajor}`],
1719
+ { stdio: "inherit" },
1720
+ );
1721
+ return;
1722
+ }
1723
+
1724
+ throw new Error(
1725
+ "Unable to auto-install Node.js on macOS. Install Homebrew or nvm first, then rerun setup-vue-env.",
1726
+ );
1727
+ }
1728
+
1729
+ function installNodeForWindows() {
1730
+ if (commandCanRun("winget", ["--version"])) {
1731
+ runCommand(
1732
+ "winget",
1733
+ [
1734
+ "install",
1735
+ "--id",
1736
+ "OpenJS.NodeJS.LTS",
1737
+ "-e",
1738
+ "--accept-source-agreements",
1739
+ "--accept-package-agreements",
1740
+ ],
1741
+ { stdio: "inherit" },
1742
+ );
1743
+ return;
1744
+ }
1745
+
1746
+ if (commandCanRun("choco", ["-v"])) {
1747
+ runCommand("choco", ["install", "nodejs-lts", "-y"], { stdio: "inherit" });
1748
+ return;
1749
+ }
1750
+
1751
+ throw new Error(
1752
+ "Unable to auto-install Node.js on Windows. Install winget or chocolatey first, then rerun setup-vue-env.",
1753
+ );
1754
+ }
1755
+
1756
+ async function runSetupVueEnv(args) {
1757
+ const projectRoot = args.project ? path.resolve(args.project) : process.cwd();
1758
+ const requiredNodeMajor = Number(
1759
+ args["node-major"] || DEFAULT_NODE_LTS_MAJOR,
1760
+ );
1761
+ const skipNodeInstall = toBooleanFlag(args["skip-node-install"], false);
1762
+ const skipYarnInstall = toBooleanFlag(args["skip-yarn-install"], false);
1763
+ const skipQuicktvuiCliInstall = toBooleanFlag(
1764
+ args["skip-quicktvui-cli-install"],
1765
+ false,
1766
+ );
1767
+ const skipProjectInstall = toBooleanFlag(args["skip-project-install"], false);
1768
+
1769
+ if (
1770
+ !skipNodeInstall &&
1771
+ shouldInstallNodeForCurrentPlatform(requiredNodeMajor, args)
1772
+ ) {
1773
+ console.log(`Installing Node.js LTS (major ${requiredNodeMajor})...`);
1774
+ if (process.platform === "darwin") {
1775
+ installNodeForMac(requiredNodeMajor);
1776
+ } else if (process.platform === "win32") {
1777
+ installNodeForWindows();
1778
+ } else {
1779
+ throw new Error(
1780
+ "Auto Node.js installation currently supports macOS and Windows only.",
1781
+ );
1782
+ }
1783
+ } else {
1784
+ console.log(
1785
+ `Node.js check passed (current=${process.versions.node}, required>=${requiredNodeMajor}).`,
1786
+ );
1787
+ }
1788
+
1789
+ if (!commandCanRun("npm", ["--version"])) {
1790
+ throw new Error("npm is unavailable after Node.js setup.");
1791
+ }
1792
+
1793
+ if (!skipYarnInstall && !commandCanRun("yarn", ["--version"])) {
1794
+ console.log("Installing yarn globally...");
1795
+ runCommand("npm", ["install", "-g", "yarn"], { stdio: "inherit" });
1796
+ }
1797
+
1798
+ if (!skipQuicktvuiCliInstall && !commandCanRun("qui", ["--help"])) {
1799
+ console.log("Installing @quicktvui/cli globally...");
1800
+ runCommand("npm", ["install", "-g", "@quicktvui/cli@latest"], {
1801
+ stdio: "inherit",
1802
+ });
1803
+ }
1804
+
1805
+ if (!skipProjectInstall) {
1806
+ if (!exists(path.join(projectRoot, "package.json"))) {
1807
+ throw new Error(`Missing package.json in project root: ${projectRoot}`);
1808
+ }
1809
+ console.log(`Installing project dependencies: ${projectRoot}`);
1810
+ installProjectDependencies(projectRoot, args);
1811
+ }
1812
+
1813
+ console.log(
1814
+ `Vue development environment is ready for project: ${projectRoot}`,
1815
+ );
1816
+ }
1817
+
1818
+ async function runSetupAllEnv(args) {
1819
+ console.log("Step 1/2: setup Vue development environment...");
1820
+ await runSetupVueEnv(args);
1821
+ console.log("Step 2/2: setup Android runtime environment...");
1822
+ await runSetupAndroidEnv(args);
1823
+ console.log("All development environments are ready.");
1824
+ }
1825
+
1826
+ function resolvePackageManagerCommand(projectRoot) {
1827
+ const yarnLock = path.join(projectRoot, "yarn.lock");
1828
+ const pnpmLock = path.join(projectRoot, "pnpm-lock.yaml");
1829
+ if (exists(yarnLock) && commandExists("yarn")) {
1830
+ return { command: "yarn", args: ["dev"] };
1831
+ }
1832
+ if (exists(pnpmLock) && commandExists("pnpm")) {
1833
+ return { command: "pnpm", args: ["dev"] };
1834
+ }
1835
+ if (commandExists("npm")) {
1836
+ return { command: "npm", args: ["run", "dev"] };
1837
+ }
1838
+ throw new Error("Neither yarn, pnpm, nor npm is available on this machine.");
1839
+ }
1840
+
1841
+ async function runSetupAndroidEnv(args) {
1842
+ const projectRoot = args.project ? path.resolve(args.project) : process.cwd();
1843
+ const skipRuntimeSetup = toBooleanFlag(args["skip-runtime-setup"], false);
1844
+ const autoEmulator = toBooleanFlag(args["auto-emulator"], true);
1845
+ const runtimeSetupMode =
1846
+ typeof args["runtime-setup-mode"] === "string"
1847
+ ? String(args["runtime-setup-mode"]).trim().toLowerCase()
1848
+ : "direct";
1849
+ const avdName =
1850
+ typeof args["avd-name"] === "string" && args["avd-name"].trim()
1851
+ ? args["avd-name"].trim()
1852
+ : DEFAULT_ANDROID_AVD_NAME;
1853
+
1854
+ const { sdkRoot, source } = resolveAndroidSdkRootForSetup();
1855
+ console.log(`Android SDK root: ${sdkRoot || "not found"} (${source})`);
1856
+
1857
+ const adbPath = await resolveAdbPathForSetup(args, sdkRoot);
1858
+ if (!adbPath) {
1859
+ throw new Error(
1860
+ "adb is unavailable. quicktvui-ai does not bundle adb by default. Install Android SDK Platform-Tools (or Android Studio), or pass --adb-path <path>.",
1861
+ );
1862
+ }
1863
+
1864
+ let deviceState = listConnectedDevices(adbPath);
1865
+ console.log(
1866
+ `ADB devices: connected=${deviceState.devices.length}, unauthorized=${deviceState.unauthorized.length}, offline=${deviceState.offline.length}`,
1867
+ );
1868
+ let useConnectedDevice = deviceState.devices.length > 0;
1869
+ let shouldSetupEmulator = false;
1870
+ let preferredRealDeviceSerial = null;
1871
+
1872
+ if (deviceState.devices.length > 0) {
1873
+ const connectedList = deviceState.devices.join(", ");
1874
+ useConnectedDevice = await askYesNo(
1875
+ `Detected connected Android device(s): ${connectedList}. Use this device for setup?`,
1876
+ true,
1877
+ args,
1878
+ );
1879
+ if (useConnectedDevice) {
1880
+ preferredRealDeviceSerial = deviceState.devices[0] || null;
1881
+ }
1882
+ }
1883
+
1884
+ if (!useConnectedDevice) {
1885
+ const connectRealByIp = await askYesNo(
1886
+ "Do you want to connect a real device by IP now (adb connect)?",
1887
+ true,
1888
+ args,
1889
+ );
1890
+ if (connectRealByIp) {
1891
+ const connectedEndpoint = await connectRealDeviceByIp(adbPath, args);
1892
+ if (connectedEndpoint) {
1893
+ await delay(600);
1894
+ deviceState = listConnectedDevices(adbPath);
1895
+ if (deviceState.devices.length > 0) {
1896
+ useConnectedDevice = true;
1897
+ preferredRealDeviceSerial =
1898
+ deviceState.devices.find(
1899
+ (serial) => serial === connectedEndpoint,
1900
+ ) || connectedEndpoint;
1901
+ console.log(
1902
+ `Detected connected device(s): ${deviceState.devices.join(", ")}`,
1903
+ );
1904
+ }
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ if (!useConnectedDevice) {
1910
+ if (autoEmulator) {
1911
+ shouldSetupEmulator = await askYesNo(
1912
+ "No usable real device found. Do you want to download and use official Google Android emulator now?",
1913
+ true,
1914
+ args,
1915
+ );
1916
+ }
1917
+
1918
+ if (!shouldSetupEmulator) {
1919
+ throw new Error(
1920
+ "No real device connected and emulator setup declined. Please manually install/connect device or emulator.",
1921
+ );
1922
+ }
1923
+ }
1924
+
1925
+ if (!useConnectedDevice && shouldSetupEmulator) {
1926
+ const emulatorPath = resolveAndroidTool("emulator", sdkRoot);
1927
+ if (!emulatorPath) {
1928
+ throw new Error(
1929
+ "No Android device is connected and emulator tool is unavailable. Install Android Studio (with SDK + Emulator) first.",
1930
+ );
1931
+ }
1932
+
1933
+ let avds = listAvds(emulatorPath);
1934
+ if (avds.length === 0) {
1935
+ let sdkmanagerPath = resolveAndroidTool("sdkmanager", sdkRoot);
1936
+ let avdmanagerPath = resolveAndroidTool("avdmanager", sdkRoot);
1937
+
1938
+ if (!sdkRoot || !sdkmanagerPath || !avdmanagerPath) {
1939
+ const toolPaths = await ensureAndroidCommandLineToolsAvailable(sdkRoot);
1940
+ sdkmanagerPath = toolPaths.sdkmanagerPath;
1941
+ avdmanagerPath = toolPaths.avdmanagerPath;
1942
+ }
1943
+
1944
+ if (!sdkmanagerPath || !avdmanagerPath) {
1945
+ throw new Error(
1946
+ "No AVD found and sdkmanager/avdmanager is still unavailable after auto-install. Please install Android command-line tools manually.",
1947
+ );
1948
+ }
1949
+
1950
+ const googleReachable = await canReachGoogleAndroidRepo();
1951
+ if (!googleReachable) {
1952
+ throw new Error(
1953
+ "Unable to connect to Google Android repository (dl.google.com). Please manually download/install Android emulator and SDK, then rerun setup-android-env.",
1954
+ );
1955
+ }
1956
+
1957
+ try {
1958
+ const sizeMap = await fetchAndroidPackageSizeMap();
1959
+ printEmulatorDownloadEstimate(
1960
+ sizeMap,
1961
+ getPreferredSystemImageCandidates(),
1962
+ );
1963
+ } catch (error) {
1964
+ console.log(
1965
+ `Unable to fetch package size metadata: ${error.message}. Continue with sdkmanager download.`,
1966
+ );
1967
+ }
1968
+
1969
+ console.log("Installing required Android SDK packages...");
1970
+ ensureAndroidSdkPackages(sdkmanagerPath, sdkRoot, [
1971
+ "platform-tools",
1972
+ "emulator",
1973
+ ]);
1974
+
1975
+ const imagePackage = installSystemImagePackage(sdkmanagerPath, sdkRoot);
1976
+ console.log(`Creating AVD: ${avdName}`);
1977
+ createAvd(avdmanagerPath, avdName, imagePackage);
1978
+ avds = listAvds(emulatorPath);
1979
+ }
1980
+
1981
+ const targetAvd = avds.includes(avdName) ? avdName : avds[0];
1982
+ console.log(`Starting Android emulator: ${targetAvd}`);
1983
+ startAvd(emulatorPath, targetAvd, args);
1984
+
1985
+ const serial = await waitForAdbDevice(adbPath, 180000);
1986
+ await waitForBootComplete(adbPath, serial, 180000);
1987
+ console.log(`Emulator ready: ${serial}`);
1988
+ }
1989
+
1990
+ deviceState = listConnectedDevices(adbPath);
1991
+ if (deviceState.devices.length === 0) {
1992
+ throw new Error(
1993
+ "No Android device is connected. Connect a TV/box via adb or enable --auto-emulator.",
1994
+ );
1995
+ }
1996
+
1997
+ const deviceSerial = pickPrimaryDeviceSerial(deviceState);
1998
+ const targetSerial = preferredRealDeviceSerial || deviceSerial;
1999
+ if (!targetSerial) {
2000
+ throw new Error("Unable to resolve target adb device serial.");
2001
+ }
2002
+
2003
+ let hostIp = getLocalIPv4Address();
2004
+ if (!skipRuntimeSetup) {
2005
+ if (runtimeSetupMode === "qui") {
2006
+ if (!commandCanRun("qui", ["--help"])) {
2007
+ throw new Error(
2008
+ "QuickTVUI CLI 'qui' is unavailable. Run: npm install -g @quicktvui/cli@latest",
2009
+ );
2010
+ }
2011
+ console.log("Running 'qui setup' to configure runtime APK...");
2012
+ runCommand("qui", ["setup"], { cwd: projectRoot });
2013
+ hostIp = getLocalIPv4Address();
2014
+ } else {
2015
+ const runtimeResult = await ensureRuntimeInstalledAndConfigured(
2016
+ adbPath,
2017
+ targetSerial,
2018
+ args,
2019
+ );
2020
+ hostIp = runtimeResult.hostIp;
2021
+ }
2022
+ } else {
2023
+ console.log("Skip runtime setup due to --skip-runtime-setup.");
2024
+ }
2025
+
2026
+ console.log(
2027
+ `Android environment is ready for project: ${projectRoot}, device=${targetSerial}`,
2028
+ );
2029
+ return {
2030
+ projectRoot,
2031
+ adbPath,
2032
+ deviceSerial: targetSerial,
2033
+ hostIp,
2034
+ };
2035
+ }
2036
+
2037
+ async function runRunDev(args) {
2038
+ const projectRoot = args.project ? path.resolve(args.project) : process.cwd();
2039
+ if (!exists(path.join(projectRoot, "package.json"))) {
2040
+ throw new Error(`Missing package.json in project root: ${projectRoot}`);
2041
+ }
2042
+
2043
+ let setupResult = null;
2044
+ if (!toBooleanFlag(args["skip-env-check"], false)) {
2045
+ setupResult = await runSetupAndroidEnv({
2046
+ ...args,
2047
+ project: projectRoot,
2048
+ "skip-runtime-setup": args["skip-runtime-setup"],
2049
+ });
2050
+ } else {
2051
+ console.log("Skip Android environment check due to --skip-env-check.");
2052
+ }
2053
+
2054
+ const command = resolvePackageManagerCommand(projectRoot);
2055
+ const autoLoadLocalBundle = toBooleanFlag(
2056
+ args["auto-load-local-bundle"],
2057
+ true,
2058
+ );
2059
+ const serverPort = Number(args.port || DEFAULT_DEV_SERVER_PORT);
2060
+
2061
+ console.log(
2062
+ `Starting dev server with '${command.command} ${command.args.join(" ")}'...`,
2063
+ );
2064
+ const child = spawn(command.command, command.args, {
2065
+ cwd: projectRoot,
2066
+ stdio: "inherit",
2067
+ shell: process.platform === "win32",
2068
+ });
2069
+
2070
+ if (autoLoadLocalBundle) {
2071
+ try {
2072
+ const adbPath =
2073
+ (setupResult && setupResult.adbPath) ||
2074
+ resolveAndroidTool("adb", resolveAndroidSdkRoot().sdkRoot);
2075
+ if (adbPath) {
2076
+ const serial =
2077
+ (setupResult && setupResult.deviceSerial) ||
2078
+ pickPrimaryDeviceSerial(listConnectedDevices(adbPath));
2079
+ const hostIp =
2080
+ (setupResult && setupResult.hostIp) ||
2081
+ (typeof args["server-host"] === "string"
2082
+ ? args["server-host"]
2083
+ : "") ||
2084
+ getLocalIPv4Address();
2085
+
2086
+ if (serial && hostIp) {
2087
+ console.log(`Waiting for local dev server on port ${serverPort}...`);
2088
+ await waitForPort("127.0.0.1", serverPort, 150000);
2089
+ await autoLoadLocalBundleOnRuntime(
2090
+ adbPath,
2091
+ serial,
2092
+ projectRoot,
2093
+ hostIp,
2094
+ serverPort,
2095
+ args,
2096
+ );
2097
+ } else {
2098
+ console.log(
2099
+ "Skip auto local-bundle trigger: missing adb device or local host IP.",
2100
+ );
2101
+ }
2102
+ } else {
2103
+ console.log("Skip auto local-bundle trigger: adb is unavailable.");
2104
+ }
2105
+ } catch (error) {
2106
+ console.log(`Auto local-bundle trigger failed: ${error.message}`);
2107
+ }
2108
+ }
2109
+
2110
+ await new Promise((resolve, reject) => {
2111
+ child.on("error", reject);
2112
+ child.on("exit", (code, signal) => {
2113
+ if (code === 0 || signal === "SIGINT" || signal === "SIGTERM") {
2114
+ resolve(true);
2115
+ } else {
2116
+ reject(
2117
+ new Error(`Dev server exited with code ${code}, signal ${signal}`),
2118
+ );
2119
+ }
2120
+ });
2121
+ });
2122
+ }
2123
+
2124
+ async function runRunEsapp(args) {
2125
+ const projectRoot = args.project ? path.resolve(args.project) : process.cwd();
2126
+ let setupResult = null;
2127
+ if (!toBooleanFlag(args["skip-env-check"], false)) {
2128
+ setupResult = await runSetupAndroidEnv({
2129
+ ...args,
2130
+ project: projectRoot,
2131
+ "skip-runtime-setup": args["skip-runtime-setup"],
2132
+ });
2133
+ } else {
2134
+ console.log("Skip Android environment check due to --skip-env-check.");
2135
+ }
2136
+
2137
+ const sdkState = resolveAndroidSdkRootForSetup();
2138
+ const adbPath =
2139
+ (setupResult && setupResult.adbPath) ||
2140
+ (await resolveAdbPathForSetup(args, sdkState.sdkRoot));
2141
+ if (!adbPath) {
2142
+ throw new Error("adb is unavailable for run-esapp.");
2143
+ }
2144
+
2145
+ let serial = typeof args.device === "string" ? args.device.trim() : "";
2146
+ if (!serial && setupResult && setupResult.deviceSerial) {
2147
+ serial = setupResult.deviceSerial;
2148
+ }
2149
+
2150
+ if (
2151
+ !serial &&
2152
+ typeof args["device-ip"] === "string" &&
2153
+ args["device-ip"].trim()
2154
+ ) {
2155
+ const endpoint = normalizeDeviceEndpoint(args["device-ip"]);
2156
+ console.log(`Trying to connect device: ${endpoint}`);
2157
+ const connectResult = tryAdbConnect(adbPath, endpoint);
2158
+ if (!connectResult.connected) {
2159
+ throw new Error(
2160
+ `Failed to connect device ${endpoint}: ${
2161
+ connectResult.output || "unknown adb connect error"
2162
+ }`,
2163
+ );
2164
+ }
2165
+ await delay(600);
2166
+ }
2167
+
2168
+ if (!serial) {
2169
+ const state = listConnectedDevices(adbPath);
2170
+ serial = pickPrimaryDeviceSerial(state);
2171
+ }
2172
+ if (!serial) {
2173
+ throw new Error(
2174
+ "No Android device available. Use --device/--device-ip or run setup-android-env first.",
2175
+ );
2176
+ }
2177
+
2178
+ const launchUri = buildEsappLaunchUri(args, projectRoot, {
2179
+ pkg: resolveProjectAppPackage(projectRoot),
2180
+ from: "cmd",
2181
+ });
2182
+ const runtimePackage =
2183
+ typeof args["runtime-package"] === "string" &&
2184
+ args["runtime-package"].trim()
2185
+ ? args["runtime-package"].trim()
2186
+ : RUNTIME_PACKAGE_NAME;
2187
+
2188
+ console.log("Launching ES app via protocol...");
2189
+ console.log(`- device: ${serial}`);
2190
+ console.log(`- runtime: ${runtimePackage}`);
2191
+ console.log(`- uri: ${launchUri}`);
2192
+ startEsappOnRuntimeByUri(adbPath, serial, runtimePackage, launchUri);
2193
+ console.log("ES app launch command sent.");
2194
+ }
2195
+
43
2196
  function resolveSkillsSource() {
44
2197
  try {
45
2198
  // Preferred: installed dependency
@@ -97,6 +2250,16 @@ function parseArgs(argv) {
97
2250
  return args;
98
2251
  }
99
2252
 
2253
+ function toBooleanFlag(value, defaultValue) {
2254
+ if (typeof value === "undefined") return defaultValue;
2255
+ if (typeof value === "boolean") return value;
2256
+
2257
+ const normalized = String(value).trim().toLowerCase();
2258
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
2259
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
2260
+ return defaultValue;
2261
+ }
2262
+
100
2263
  function printHelp() {
101
2264
  console.log(`quicktvui-ai v${PACKAGE_VERSION}
102
2265
 
@@ -108,14 +2271,59 @@ Commands:
108
2271
  doctor Check whether skills and local QuickTVUI rules are available
109
2272
  validate Strict check for required skill files (non-zero exit if missing)
110
2273
  update Reinstall skill assets to target directory
2274
+ create-project Create a QuickTVUI project (remote clone, local fallback)
2275
+ setup-vue-env Setup Vue development environment (Node/yarn/quicktvui cli)
2276
+ setup-android-env Configure Android device/emulator + runtime environment
2277
+ setup-all-env Setup both Vue and Android development environments
2278
+ run-dev Run project dev command (optionally checks Android env first)
2279
+ run-esapp Launch an ES app on runtime via esapp:// protocol
111
2280
  prompt Print an LLM-ready installation prompt block
112
2281
  help Show this message
113
2282
 
114
2283
  Options:
115
2284
  --dir <path> Custom skill installation directory
116
2285
  --project <path> Project root for @quicktvui/ai checks (default: current dir)
2286
+ --dest <path> Destination base directory for create-project
2287
+ --offline Skip git clone and use bundled template directly
2288
+ --skip-install Skip dependency install in create-project
117
2289
  --strict Non-zero exit in doctor when checks fail
118
2290
  --lang <code> Prompt language for 'prompt' command: zh | en
2291
+ --yes Non-interactive mode; accept default prompts
2292
+ --no-interactive Disable interactive prompts
2293
+ --node-major <n> Target Node.js LTS major for setup-vue-env (default: 20)
2294
+ --skip-node-install Skip Node.js auto-install stage in setup-vue-env
2295
+ --force-node-install Force Node.js auto-install even if current version is ok
2296
+ --skip-yarn-install Skip yarn global install in setup-vue-env
2297
+ --skip-quicktvui-cli-install Skip @quicktvui/cli global install in setup-vue-env
2298
+ --skip-project-install Skip project dependency install in setup-vue-env
2299
+ --auto-emulator <true|false> Auto create/start emulator when no adb device
2300
+ --adb-path <path> Custom adb binary path/command (or set QUICKTVUI_ADB_PATH)
2301
+ --device-ip <ip[:port]> Preferred real device endpoint for adb connect
2302
+ --avd-name <name> Custom AVD name for setup-android-env
2303
+ --headless Start emulator with -no-window -no-audio
2304
+ --runtime-setup-mode <direct|qui> Runtime setup mode in setup-android-env (default: direct)
2305
+ --runtime-version <version> Pin runtime version when direct mode is used
2306
+ --runtime-url <url> Use custom runtime apk url in direct mode
2307
+ --server-host <ip> Override local debug server host IP
2308
+ --force-runtime-install Force reinstall runtime apk in direct mode
2309
+ --skip-runtime-setup Skip runtime setup stage in setup-android-env/run-dev
2310
+ --auto-load-local-bundle <true|false> Try auto trigger runtime to load local bundle in run-dev
2311
+ --port <n> Dev server port used by run-dev auto load (default: 38989)
2312
+ --skip-env-check Skip setup-android-env stage in run-dev
2313
+ --runtime-package <pkg> Runtime package name for run-esapp (default: com.extscreen.runtime)
2314
+ --device <serial> Target adb device serial for run-esapp
2315
+ --esapp-uri <uri> Raw esapp:// / quicktv:// / appcast:// URI for run-esapp
2316
+ --esapp-query <json> Extra query params JSON merged into action/start URI
2317
+ --pkg <pkg> ES app package for run-esapp structured mode
2318
+ --ver <version> ES app version for run-esapp structured mode
2319
+ --min-ver <ver> ES app minVer for run-esapp structured mode
2320
+ --repository <url> Repository URL for run-esapp structured mode
2321
+ --uri <uri> ES app load URI (local/remote/assets/file) for run-esapp structured mode
2322
+ --from <from> Caller marker for run-esapp (default: cmd)
2323
+ --args <json> Startup args JSON string for run-esapp
2324
+ --exp <json> Startup exp JSON string for run-esapp
2325
+ --flags <n> Startup flags for run-esapp (supports old 100/200/300/400 mapping)
2326
+ --use-latest <true|false> Pass useLatest for run-esapp
119
2327
  `);
120
2328
  }
121
2329
 
@@ -281,6 +2489,68 @@ async function runUpdate(args) {
281
2489
  console.log(`- target: ${result.installDir}`);
282
2490
  }
283
2491
 
2492
+ function cloneTemplateProject(targetDir) {
2493
+ runCommand("git", ["clone", "--depth", "1", TEMPLATE_REPO_URL, targetDir]);
2494
+ removeDirectoryIfExists(path.join(targetDir, ".git"));
2495
+ }
2496
+
2497
+ function copyBundledTemplateProject(targetDir) {
2498
+ const templateSource = resolveBundledTemplateSource();
2499
+ copyDirectoryRecursive(templateSource, targetDir);
2500
+ removeDirectoryIfExists(path.join(targetDir, ".git"));
2501
+ }
2502
+
2503
+ async function runCreateProject(args) {
2504
+ const projectName = args._[1];
2505
+ if (!projectName) {
2506
+ throw new Error(
2507
+ "Missing project name. Usage: quicktvui-ai create-project <project-name>",
2508
+ );
2509
+ }
2510
+
2511
+ const destinationBase = args.dest ? path.resolve(args.dest) : process.cwd();
2512
+ ensureDir(destinationBase);
2513
+
2514
+ const targetDir = path.join(destinationBase, projectName);
2515
+ if (exists(targetDir) && !isDirectoryEmpty(targetDir)) {
2516
+ throw new Error(`Target directory is not empty: ${targetDir}`);
2517
+ }
2518
+ ensureDir(targetDir);
2519
+
2520
+ let usedSource = "bundled-template";
2521
+ let cloneError;
2522
+
2523
+ if (!args.offline) {
2524
+ try {
2525
+ cloneTemplateProject(targetDir);
2526
+ usedSource = "git-clone";
2527
+ } catch (error) {
2528
+ cloneError = error;
2529
+ removeDirectoryIfExists(targetDir);
2530
+ ensureDir(targetDir);
2531
+ }
2532
+ }
2533
+
2534
+ if (usedSource !== "git-clone") {
2535
+ copyBundledTemplateProject(targetDir);
2536
+ }
2537
+
2538
+ ensureProjectPackageRules(targetDir, projectName);
2539
+ installProjectDependencies(targetDir, args);
2540
+
2541
+ console.log(`Created QuickTVUI project: ${targetDir}`);
2542
+ console.log(`- source: ${usedSource}`);
2543
+ if (cloneError) {
2544
+ console.log(`- clone fallback reason: ${cloneError.message}`);
2545
+ }
2546
+ console.log(
2547
+ "- ensured: package.json name/version updated and @quicktvui/ai added to devDependencies",
2548
+ );
2549
+ console.log(
2550
+ "- next: if not configured, run 'npm install -g @quicktvui/cli@latest' and 'qui setup'",
2551
+ );
2552
+ }
2553
+
284
2554
  function buildPrompt(lang) {
285
2555
  const isZh = lang !== "en";
286
2556
  if (isZh) {
@@ -300,7 +2570,23 @@ quicktvui-ai doctor
300
2570
  npm install @quicktvui/ai --save-dev
301
2571
  \`\`\`
302
2572
 
303
- 安装完成后请提醒用户重启或 reload AI Agent,以便重新扫描 ~/.agents/skills/quicktvui。`;
2573
+ 安装完成后请提醒用户重启或 reload AI Agent,以便重新扫描 ~/.agents/skills/quicktvui
2574
+
2575
+ 创建项目时优先使用:
2576
+ \`\`\`bash
2577
+ quicktvui-ai create-project <project-name>
2578
+ \`\`\`
2579
+ 该命令在 git clone 失败时会自动回退到本地内置模板。
2580
+
2581
+ 项目创建后可执行:
2582
+ \`\`\`bash
2583
+ quicktvui-ai setup-vue-env --project <project-path>
2584
+ quicktvui-ai setup-android-env --project <project-path>
2585
+ quicktvui-ai run-dev --project <project-path>
2586
+ quicktvui-ai run-esapp --project <project-path> --pkg <es-package>
2587
+ # 或一键
2588
+ quicktvui-ai setup-all-env --project <project-path>
2589
+ \`\`\``;
304
2590
  }
305
2591
 
306
2592
  return `# Install QuickTVUI AI Skill Runtime
@@ -319,7 +2605,23 @@ If the user is inside a QuickTVUI project, also run:
319
2605
  npm install @quicktvui/ai --save-dev
320
2606
  \`\`\`
321
2607
 
322
- After installation, ask the user to restart or reload the AI Agent so it rescans ~/.agents/skills/quicktvui.`;
2608
+ After installation, ask the user to restart or reload the AI Agent so it rescans ~/.agents/skills/quicktvui.
2609
+
2610
+ When creating a project, prefer:
2611
+ \`\`\`bash
2612
+ quicktvui-ai create-project <project-name>
2613
+ \`\`\`
2614
+ This command auto-fallbacks to bundled local template when git clone fails.
2615
+
2616
+ After project creation, you can run:
2617
+ \`\`\`bash
2618
+ quicktvui-ai setup-vue-env --project <project-path>
2619
+ quicktvui-ai setup-android-env --project <project-path>
2620
+ quicktvui-ai run-dev --project <project-path>
2621
+ quicktvui-ai run-esapp --project <project-path> --pkg <es-package>
2622
+ # or one command
2623
+ quicktvui-ai setup-all-env --project <project-path>
2624
+ \`\`\``;
323
2625
  }
324
2626
 
325
2627
  async function runPrompt(args) {
@@ -340,6 +2642,18 @@ async function runCli(argv) {
340
2642
  return runValidate(args);
341
2643
  case "update":
342
2644
  return runUpdate(args);
2645
+ case "create-project":
2646
+ return runCreateProject(args);
2647
+ case "setup-vue-env":
2648
+ return runSetupVueEnv(args);
2649
+ case "setup-android-env":
2650
+ return runSetupAndroidEnv(args);
2651
+ case "setup-all-env":
2652
+ return runSetupAllEnv(args);
2653
+ case "run-dev":
2654
+ return runRunDev(args);
2655
+ case "run-esapp":
2656
+ return runRunEsapp(args);
343
2657
  case "prompt":
344
2658
  return runPrompt(args);
345
2659
  case "help":