@jskit-ai/jskit-cli 0.2.72 → 0.2.73

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.
@@ -0,0 +1,1316 @@
1
+ import path from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { readFile } from "node:fs/promises";
4
+ import {
5
+ createColorFormatter,
6
+ writeWrappedLines
7
+ } from "../shared/outputFormatting.js";
8
+ import {
9
+ buildMobileCommandOptionMeta,
10
+ listMobileCommandDefinitions,
11
+ resolveMobileCommandDefinition
12
+ } from "./mobileCommandCatalog.js";
13
+ import {
14
+ assertAndroidSdkConfigured,
15
+ assertCapacitorShellInstalled,
16
+ collectAndroidSdkComponentIssues,
17
+ collectAndroidNativeShellIdentityIssues,
18
+ ensureAndroidManifestDeepLinks,
19
+ ensureAndroidNativeShellIdentity,
20
+ renderManagedMobileFile,
21
+ resolveAndroidSdkDetails,
22
+ resolveInstalledMobileConfig
23
+ } from "./mobileShellSupport.js";
24
+ const CAPACITOR_RUNTIME_PACKAGE_ID = "@jskit-ai/mobile-capacitor";
25
+ const MOBILE_NOTES_RELATIVE_PATH = path.join(".jskit", "mobile-capacitor.md");
26
+
27
+ async function collectManagedMobileFileDriftIssues({
28
+ ctx,
29
+ appRoot,
30
+ issues = []
31
+ } = {}) {
32
+ const {
33
+ fileExists,
34
+ path: pathModule,
35
+ normalizeRelativePath
36
+ } = ctx;
37
+ const managedRelativePaths = [
38
+ "capacitor.config.json",
39
+ MOBILE_NOTES_RELATIVE_PATH
40
+ ];
41
+
42
+ for (const relativePath of managedRelativePaths) {
43
+ const absolutePath = pathModule.join(appRoot, relativePath);
44
+ if (!(await fileExists(absolutePath))) {
45
+ continue;
46
+ }
47
+
48
+ const currentContent = await readFile(absolutePath, "utf8");
49
+ let expectedContent = "";
50
+ try {
51
+ expectedContent = await renderManagedMobileFile({
52
+ appRoot,
53
+ relativeTargetPath: relativePath
54
+ });
55
+ } catch (error) {
56
+ issues.push(
57
+ `Could not validate ${normalizeRelativePath(appRoot, absolutePath)} against the installed mobile package: ${String(error?.message || error || "unknown error")}`
58
+ );
59
+ continue;
60
+ }
61
+ if (currentContent !== expectedContent) {
62
+ issues.push(
63
+ `${normalizeRelativePath(appRoot, absolutePath)} is stale and no longer matches config.mobile. Re-run jskit mobile sync android to refresh managed mobile-shell files.`
64
+ );
65
+ }
66
+ }
67
+ }
68
+
69
+ async function collectMissingInstalledDependencyNames(ctx, appRoot = "", packageJson = {}) {
70
+ const {
71
+ fileExists,
72
+ path: pathModule
73
+ } = ctx;
74
+ const sections = [
75
+ packageJson?.dependencies,
76
+ packageJson?.devDependencies,
77
+ packageJson?.optionalDependencies
78
+ ];
79
+ const missing = [];
80
+ const seen = new Set();
81
+
82
+ for (const section of sections) {
83
+ if (!section || typeof section !== "object" || Array.isArray(section)) {
84
+ continue;
85
+ }
86
+
87
+ for (const packageName of Object.keys(section).sort((left, right) => left.localeCompare(right))) {
88
+ const normalizedPackageName = String(packageName || "").trim();
89
+ if (!normalizedPackageName || seen.has(normalizedPackageName)) {
90
+ continue;
91
+ }
92
+ seen.add(normalizedPackageName);
93
+
94
+ const packageJsonPath = pathModule.join(
95
+ appRoot,
96
+ "node_modules",
97
+ ...normalizedPackageName.split("/"),
98
+ "package.json"
99
+ );
100
+ if (!(await fileExists(packageJsonPath))) {
101
+ missing.push(normalizedPackageName);
102
+ }
103
+ }
104
+ }
105
+
106
+ return missing;
107
+ }
108
+
109
+ function renderMobileHelp(stream, definition = null) {
110
+ const color = createColorFormatter(stream);
111
+ const lines = [];
112
+
113
+ if (!definition) {
114
+ lines.push(`Command: ${color.emphasis("mobile")}`);
115
+ lines.push("");
116
+ lines.push(color.heading("1) Minimal use"));
117
+ lines.push(" jskit mobile <subcommand>");
118
+ lines.push("");
119
+ lines.push(color.heading("2) Subcommands"));
120
+ for (const entry of listMobileCommandDefinitions()) {
121
+ lines.push(` - ${color.item(entry.name)}: ${entry.summary}`);
122
+ }
123
+ lines.push("");
124
+ lines.push(color.heading("3) Notes"));
125
+ lines.push(" - Mobile helpers are for the Stage 1 Android Capacitor shell flow.");
126
+ lines.push(" - Use jskit mobile <subcommand> help for subcommand-specific usage.");
127
+ writeWrappedLines({
128
+ stdout: stream,
129
+ lines
130
+ });
131
+ return;
132
+ }
133
+
134
+ lines.push(`Mobile subcommand: ${color.emphasis(definition.name)}`);
135
+ lines.push("");
136
+ lines.push(color.heading("1) Summary"));
137
+ lines.push(` ${definition.summary}`);
138
+ lines.push("");
139
+ lines.push(color.heading("2) Use"));
140
+ lines.push(` ${definition.usage}`);
141
+
142
+ if (definition.options.length > 0) {
143
+ lines.push("");
144
+ lines.push(color.heading("3) Options"));
145
+ for (const optionRow of definition.options) {
146
+ lines.push(` - ${optionRow.label}: ${optionRow.description}`);
147
+ }
148
+ }
149
+
150
+ if (definition.defaults.length > 0) {
151
+ lines.push("");
152
+ lines.push(color.heading(definition.options.length > 0 ? "4) Defaults" : "3) Defaults"));
153
+ for (const defaultLine of definition.defaults) {
154
+ lines.push(` - ${defaultLine}`);
155
+ }
156
+ }
157
+
158
+ writeWrappedLines({
159
+ stdout: stream,
160
+ lines
161
+ });
162
+ }
163
+
164
+ function isValidHttpOrHttpsUrl(value = "") {
165
+ const normalizedValue = String(value || "").trim();
166
+ if (!normalizedValue) {
167
+ return false;
168
+ }
169
+
170
+ try {
171
+ const parsed = new URL(normalizedValue);
172
+ const protocol = String(parsed.protocol || "").toLowerCase();
173
+ return protocol === "http:" || protocol === "https:";
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function normalizeInlineOptions(options = {}) {
180
+ return options?.inlineOptions && typeof options.inlineOptions === "object" ? options.inlineOptions : {};
181
+ }
182
+
183
+ function parsePortNumber(rawValue, {
184
+ createCliError,
185
+ optionLabel = "--port"
186
+ } = {}) {
187
+ const normalizedValue = String(rawValue || "").trim();
188
+ if (!normalizedValue) {
189
+ return 0;
190
+ }
191
+
192
+ const numericValue = Number(normalizedValue);
193
+ if (!Number.isInteger(numericValue) || numericValue < 1 || numericValue > 65535) {
194
+ throw createCliError(`${optionLabel} must be an integer between 1 and 65535.`);
195
+ }
196
+
197
+ return numericValue;
198
+ }
199
+
200
+ function parseAdbDeviceList(output = "") {
201
+ return String(output || "")
202
+ .split(/\r?\n/u)
203
+ .map((line) => String(line || "").trim())
204
+ .filter((line) => line && line !== "List of devices attached")
205
+ .map((line) => {
206
+ const match = /^(\S+)\s+(\S+)(?:\s+(.*))?$/u.exec(line);
207
+ if (!match) {
208
+ return null;
209
+ }
210
+ return Object.freeze({
211
+ serial: String(match[1] || "").trim(),
212
+ state: String(match[2] || "").trim(),
213
+ details: String(match[3] || "").trim()
214
+ });
215
+ })
216
+ .filter(Boolean);
217
+ }
218
+
219
+ function resolveAdbReversePort({
220
+ mobileConfig = null,
221
+ explicitPort = "",
222
+ createCliError
223
+ } = {}) {
224
+ const parsedExplicitPort = parsePortNumber(explicitPort, {
225
+ createCliError,
226
+ optionLabel: "--port"
227
+ });
228
+ if (parsedExplicitPort > 0) {
229
+ return parsedExplicitPort;
230
+ }
231
+
232
+ const apiBaseUrl = String(mobileConfig?.apiBaseUrl || "").trim();
233
+ if (!apiBaseUrl) {
234
+ throw createCliError("config.mobile.apiBaseUrl is required to infer the adb reverse port. Pass --port to override it.");
235
+ }
236
+
237
+ let parsedUrl = null;
238
+ try {
239
+ parsedUrl = new URL(apiBaseUrl);
240
+ } catch {
241
+ throw createCliError("config.mobile.apiBaseUrl must be a valid absolute URL to infer the adb reverse port.");
242
+ }
243
+
244
+ const hostname = String(parsedUrl.hostname || "").trim().toLowerCase();
245
+ const isLoopbackHost =
246
+ hostname === "127.0.0.1" ||
247
+ hostname === "localhost" ||
248
+ hostname === "::1" ||
249
+ hostname === "[::1]";
250
+ if (!isLoopbackHost) {
251
+ throw createCliError(
252
+ `config.mobile.apiBaseUrl points at "${apiBaseUrl}", which is not a loopback host. Pass --port explicitly if you still need adb reverse.`
253
+ );
254
+ }
255
+
256
+ const port = Number(parsedUrl.port || "");
257
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
258
+ throw createCliError(
259
+ `config.mobile.apiBaseUrl "${apiBaseUrl}" must include an explicit port so jskit mobile tunnel android can infer adb reverse.`
260
+ );
261
+ }
262
+
263
+ return port;
264
+ }
265
+
266
+ async function runCapturedBinary(binaryName, args = [], {
267
+ cwd = process.cwd(),
268
+ env = {},
269
+ createCliError,
270
+ notFoundMessage = ""
271
+ } = {}) {
272
+ const spawnedEnv = {
273
+ ...process.env,
274
+ ...env
275
+ };
276
+
277
+ return await new Promise((resolve, reject) => {
278
+ const child = spawn(binaryName, Array.isArray(args) ? args : [], {
279
+ cwd,
280
+ env: spawnedEnv,
281
+ stdio: ["ignore", "pipe", "pipe"]
282
+ });
283
+
284
+ let stdout = "";
285
+ let stderr = "";
286
+
287
+ child.stdout?.on("data", (chunk) => {
288
+ stdout += String(chunk || "");
289
+ });
290
+ child.stderr?.on("data", (chunk) => {
291
+ stderr += String(chunk || "");
292
+ });
293
+
294
+ child.on("error", (error) => {
295
+ if (error?.code === "ENOENT") {
296
+ reject(createCliError(notFoundMessage || `Could not find "${binaryName}" on PATH.`));
297
+ return;
298
+ }
299
+ reject(error);
300
+ });
301
+
302
+ child.on("exit", (code) => {
303
+ if (code === 0) {
304
+ resolve(Object.freeze({
305
+ stdout,
306
+ stderr
307
+ }));
308
+ return;
309
+ }
310
+ const renderedArgs = Array.isArray(args) ? args.join(" ") : "";
311
+ const errorMessage = stderr.trim() || stdout.trim() || `${binaryName} ${renderedArgs}`.trim();
312
+ reject(createCliError(`${binaryName}${renderedArgs ? ` ${renderedArgs}` : ""} failed: ${errorMessage}`));
313
+ });
314
+ });
315
+ }
316
+
317
+ async function listVisibleAndroidDevices({
318
+ ctx,
319
+ appRoot
320
+ } = {}) {
321
+ const result = await runCapturedBinary("adb", ["devices", "-l"], {
322
+ cwd: appRoot,
323
+ createCliError: ctx.createCliError,
324
+ notFoundMessage: 'Could not find "adb" on PATH. Install Android platform-tools first.'
325
+ });
326
+ return parseAdbDeviceList(result.stdout);
327
+ }
328
+
329
+ async function resolveAndroidDeviceTarget({
330
+ ctx,
331
+ appRoot,
332
+ explicitTarget = "",
333
+ commandLabel = "this mobile command"
334
+ } = {}) {
335
+ const devices = await listVisibleAndroidDevices({
336
+ ctx,
337
+ appRoot
338
+ });
339
+ if (devices.length < 1) {
340
+ throw ctx.createCliError(`No Android devices are visible to adb. Run jskit mobile devices android before ${commandLabel}.`);
341
+ }
342
+
343
+ const normalizedExplicitTarget = String(explicitTarget || "").trim();
344
+ const selectedDevice = normalizedExplicitTarget
345
+ ? devices.find((device) => device.serial === normalizedExplicitTarget) || null
346
+ : devices[0];
347
+
348
+ if (!selectedDevice) {
349
+ throw ctx.createCliError(`Android device "${normalizedExplicitTarget}" is not visible to adb. Run jskit mobile devices android first.`);
350
+ }
351
+ if (selectedDevice.state !== "device") {
352
+ throw ctx.createCliError(`Android device "${selectedDevice.serial}" is currently "${selectedDevice.state}", not ready for ${commandLabel}.`);
353
+ }
354
+
355
+ return selectedDevice;
356
+ }
357
+
358
+ async function resolveInstalledMobileConfigForCommand({
359
+ appRoot,
360
+ createCliError
361
+ } = {}) {
362
+ try {
363
+ return await resolveInstalledMobileConfig(appRoot);
364
+ } catch (error) {
365
+ const message = String(error?.message || error || "unknown error");
366
+ throw createCliError(`config.mobile is invalid: ${message}`);
367
+ }
368
+ }
369
+
370
+ async function runLocalBinary(binaryName, args = [], {
371
+ appRoot,
372
+ cwd = appRoot,
373
+ env = {},
374
+ stderr,
375
+ stdout,
376
+ pathModule,
377
+ createCliError,
378
+ dryRun = false
379
+ } = {}) {
380
+ if (dryRun === true) {
381
+ const renderedArgs = Array.isArray(args) ? args.join(" ") : "";
382
+ stdout?.write(`[dry-run] ${binaryName}${renderedArgs ? ` ${renderedArgs}` : ""}\n`);
383
+ return;
384
+ }
385
+
386
+ const localBinDirectory = pathModule.join(appRoot, "node_modules", ".bin");
387
+ const inheritedPath = String(process.env.PATH || "");
388
+ const spawnedEnv = {
389
+ ...process.env,
390
+ ...env,
391
+ PATH: `${localBinDirectory}${pathModule.delimiter}${inheritedPath}`
392
+ };
393
+
394
+ await new Promise((resolve, reject) => {
395
+ const child = spawn(binaryName, Array.isArray(args) ? args : [], {
396
+ cwd,
397
+ env: spawnedEnv,
398
+ stdio: "inherit"
399
+ });
400
+
401
+ child.on("error", (error) => {
402
+ if (error?.code === "ENOENT") {
403
+ reject(
404
+ createCliError(
405
+ `Could not find local "${binaryName}" in node_modules/.bin. Re-run jskit mobile add capacitor after npm install succeeds.`
406
+ )
407
+ );
408
+ return;
409
+ }
410
+ reject(error);
411
+ });
412
+ child.on("exit", (code) => {
413
+ if (code === 0) {
414
+ resolve();
415
+ return;
416
+ }
417
+ reject(createCliError(`${binaryName} ${args.join(" ")} failed with exit code ${code}.`));
418
+ });
419
+ }).catch((error) => {
420
+ stderr.write(`${binaryName} failed: ${error.message}\n`);
421
+ throw error;
422
+ });
423
+ }
424
+
425
+ async function runMobileAppInstall({
426
+ ctx,
427
+ appRoot,
428
+ stdout,
429
+ stderr,
430
+ dryRun = false,
431
+ devlinks = false
432
+ } = {}) {
433
+ const {
434
+ path: pathModule,
435
+ loadAppPackageJson
436
+ } = ctx;
437
+ const { packageJson } = await loadAppPackageJson(appRoot);
438
+ const packageScripts = packageJson?.scripts && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
439
+
440
+ await runLocalBinary("npm", ["install"], {
441
+ appRoot,
442
+ stderr,
443
+ stdout,
444
+ pathModule,
445
+ createCliError: ctx.createCliError,
446
+ dryRun
447
+ });
448
+
449
+ if (devlinks === true && Object.prototype.hasOwnProperty.call(packageScripts, "devlinks")) {
450
+ await runLocalBinary("npm", ["run", "--if-present", "devlinks"], {
451
+ appRoot,
452
+ stderr,
453
+ stdout,
454
+ pathModule,
455
+ createCliError: ctx.createCliError,
456
+ dryRun
457
+ });
458
+ }
459
+ }
460
+
461
+ async function refreshManagedMobileFiles({
462
+ ctx,
463
+ commandAdd,
464
+ appRoot,
465
+ options = {},
466
+ stdout,
467
+ stderr
468
+ } = {}) {
469
+ const {
470
+ path: pathModule
471
+ } = ctx;
472
+ const packageJsonPath = pathModule.join(appRoot, "package.json");
473
+ const packageJsonBefore = await readFile(packageJsonPath, "utf8");
474
+ let capturedStdout = "";
475
+ await commandAdd({
476
+ positional: ["package", CAPACITOR_RUNTIME_PACKAGE_ID],
477
+ options: {
478
+ ...options,
479
+ forceReapplyTarget: true,
480
+ runNpmInstall: false,
481
+ inlineOptions: {}
482
+ },
483
+ cwd: appRoot,
484
+ io: {
485
+ stdout: {
486
+ write(chunk) {
487
+ capturedStdout += String(chunk || "");
488
+ }
489
+ },
490
+ stderr
491
+ }
492
+ });
493
+ const packageJsonAfter = await readFile(packageJsonPath, "utf8");
494
+ const parsedPackageJsonAfter = JSON.parse(packageJsonAfter);
495
+ const missingInstalledDependencies = await collectMissingInstalledDependencyNames(ctx, appRoot, parsedPackageJsonAfter);
496
+
497
+ if (!/Touched files \(0\):/u.test(capturedStdout)) {
498
+ stdout.write(capturedStdout);
499
+ }
500
+
501
+ if (
502
+ options?.dryRun !== true &&
503
+ (packageJsonAfter !== packageJsonBefore || missingInstalledDependencies.length > 0)
504
+ ) {
505
+ await runMobileAppInstall({
506
+ ctx,
507
+ appRoot,
508
+ stdout,
509
+ stderr,
510
+ dryRun: false,
511
+ devlinks: options?.devlinks === true
512
+ });
513
+ }
514
+ }
515
+
516
+ async function runMobileAddCapacitorCommand({
517
+ commandAdd,
518
+ appRoot,
519
+ options = {},
520
+ stdout,
521
+ stderr
522
+ }) {
523
+ return await commandAdd({
524
+ positional: ["package", CAPACITOR_RUNTIME_PACKAGE_ID],
525
+ options: {
526
+ ...options,
527
+ runNpmInstall: true,
528
+ inlineOptions: {}
529
+ },
530
+ cwd: appRoot,
531
+ io: {
532
+ stdout,
533
+ stderr
534
+ }
535
+ });
536
+ }
537
+
538
+ async function runMobileSyncAndroidCommand({
539
+ ctx,
540
+ commandAdd,
541
+ appRoot,
542
+ options = {},
543
+ stdout,
544
+ stderr
545
+ }) {
546
+ const {
547
+ path: pathModule
548
+ } = ctx;
549
+
550
+ await refreshManagedMobileFiles({
551
+ ctx,
552
+ commandAdd,
553
+ appRoot,
554
+ options,
555
+ stdout,
556
+ stderr
557
+ });
558
+
559
+ await assertCapacitorShellInstalled({
560
+ ctx,
561
+ appRoot
562
+ });
563
+ await ensureAndroidNativeShellIdentity({
564
+ ctx,
565
+ appRoot,
566
+ dryRun: options?.dryRun === true,
567
+ stdout
568
+ });
569
+
570
+ await runLocalBinary("npm", ["run", "build"], {
571
+ appRoot,
572
+ stderr,
573
+ stdout,
574
+ pathModule,
575
+ createCliError: ctx.createCliError,
576
+ dryRun: options?.dryRun === true
577
+ });
578
+ await runLocalBinary("cap", ["sync", "android"], {
579
+ appRoot,
580
+ stderr,
581
+ stdout,
582
+ pathModule,
583
+ createCliError: ctx.createCliError,
584
+ dryRun: options?.dryRun === true
585
+ });
586
+ await ensureAndroidManifestDeepLinks({
587
+ ctx,
588
+ appRoot,
589
+ dryRun: options?.dryRun === true,
590
+ stdout
591
+ });
592
+
593
+ if (options?.dryRun === true) {
594
+ return 0;
595
+ }
596
+
597
+ stdout.write("[mobile] Built dist/ and synced the Android shell.\n");
598
+ return 0;
599
+ }
600
+
601
+ async function runMobileRunAndroidCommand({
602
+ ctx,
603
+ commandAdd,
604
+ appRoot,
605
+ options = {},
606
+ stdout,
607
+ stderr
608
+ }) {
609
+ const {
610
+ path: pathModule
611
+ } = ctx;
612
+ const inlineOptions = normalizeInlineOptions(options);
613
+ const target = String(inlineOptions.target || "").trim();
614
+ const mobileConfig = await resolveInstalledMobileConfigForCommand({
615
+ appRoot,
616
+ createCliError: ctx.createCliError
617
+ });
618
+ if (options?.dryRun !== true) {
619
+ await assertAndroidSdkConfigured({
620
+ ctx,
621
+ appRoot
622
+ });
623
+ }
624
+
625
+ if (mobileConfig.assetMode === "bundled") {
626
+ await runMobileSyncAndroidCommand({
627
+ ctx,
628
+ commandAdd,
629
+ appRoot,
630
+ options,
631
+ stdout,
632
+ stderr
633
+ });
634
+ } else {
635
+ await refreshManagedMobileFiles({
636
+ ctx,
637
+ commandAdd,
638
+ appRoot,
639
+ options,
640
+ stdout,
641
+ stderr
642
+ });
643
+
644
+ await assertCapacitorShellInstalled({
645
+ ctx,
646
+ appRoot
647
+ });
648
+ await ensureAndroidNativeShellIdentity({
649
+ ctx,
650
+ appRoot,
651
+ dryRun: options?.dryRun === true,
652
+ stdout
653
+ });
654
+ await runLocalBinary("cap", ["sync", "android"], {
655
+ appRoot,
656
+ stderr,
657
+ stdout,
658
+ pathModule,
659
+ createCliError: ctx.createCliError,
660
+ dryRun: options?.dryRun === true
661
+ });
662
+ await ensureAndroidManifestDeepLinks({
663
+ ctx,
664
+ appRoot,
665
+ dryRun: options?.dryRun === true,
666
+ stdout
667
+ });
668
+
669
+ if (options?.dryRun !== true) {
670
+ stdout.write("[mobile] Synced the Android shell against the configured dev server.\n");
671
+ }
672
+ }
673
+
674
+ await runCapRunAndroidCommand({
675
+ ctx,
676
+ appRoot,
677
+ pathModule,
678
+ target,
679
+ stdout,
680
+ stderr,
681
+ dryRun: options?.dryRun === true
682
+ });
683
+
684
+ if (options?.dryRun === true) {
685
+ return 0;
686
+ }
687
+
688
+ stdout.write("[mobile] Ran the Android shell via Capacitor.\n");
689
+ return 0;
690
+ }
691
+
692
+ async function runCapRunAndroidCommand({
693
+ ctx,
694
+ appRoot,
695
+ pathModule,
696
+ target = "",
697
+ stdout,
698
+ stderr,
699
+ dryRun = false
700
+ } = {}) {
701
+ const capRunArgs = ["run", "android"];
702
+ if (target) {
703
+ capRunArgs.push("--target", target);
704
+ }
705
+
706
+ await runLocalBinary("cap", capRunArgs, {
707
+ appRoot,
708
+ stderr,
709
+ stdout,
710
+ pathModule,
711
+ createCliError: ctx.createCliError,
712
+ dryRun
713
+ });
714
+ }
715
+
716
+ async function runMobileBuildAndroidCommand({
717
+ ctx,
718
+ commandAdd,
719
+ appRoot,
720
+ options = {},
721
+ stdout,
722
+ stderr
723
+ }) {
724
+ const {
725
+ path: pathModule,
726
+ createCliError
727
+ } = ctx;
728
+ const mobileConfig = await resolveInstalledMobileConfigForCommand({
729
+ appRoot,
730
+ createCliError
731
+ });
732
+ if (options?.dryRun !== true) {
733
+ await assertAndroidSdkConfigured({
734
+ ctx,
735
+ appRoot
736
+ });
737
+ }
738
+
739
+ if (mobileConfig.assetMode !== "bundled") {
740
+ throw createCliError(
741
+ 'jskit mobile build android requires config.mobile.assetMode="bundled" so the release shell does not depend on a live dev server.'
742
+ );
743
+ }
744
+
745
+ await runMobileSyncAndroidCommand({
746
+ ctx,
747
+ commandAdd,
748
+ appRoot,
749
+ options,
750
+ stdout,
751
+ stderr
752
+ });
753
+
754
+ const gradleCommand = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
755
+ await runLocalBinary(gradleCommand, ["bundleRelease"], {
756
+ appRoot,
757
+ cwd: path.join(appRoot, "android"),
758
+ stderr,
759
+ stdout,
760
+ pathModule,
761
+ createCliError,
762
+ dryRun: options?.dryRun === true
763
+ });
764
+
765
+ if (options?.dryRun === true) {
766
+ return 0;
767
+ }
768
+
769
+ stdout.write("[mobile] Built the Android release AAB with Gradle.\n");
770
+ return 0;
771
+ }
772
+
773
+ async function runMobileDoctorCommand({
774
+ ctx,
775
+ appRoot,
776
+ stdout
777
+ }) {
778
+ const {
779
+ fileExists,
780
+ createCliError,
781
+ path: pathModule,
782
+ normalizeRelativePath
783
+ } = ctx;
784
+ const issues = [];
785
+ let mobileConfig = null;
786
+ try {
787
+ mobileConfig = await resolveInstalledMobileConfigForCommand({
788
+ appRoot,
789
+ createCliError
790
+ });
791
+ } catch (error) {
792
+ issues.push(String(error?.message || error || "config.mobile is invalid."));
793
+ }
794
+ const sdkDetails = await resolveAndroidSdkDetails({
795
+ appRoot
796
+ });
797
+ const capacitorConfigPath = pathModule.join(appRoot, "capacitor.config.json");
798
+ const androidDirectoryPath = pathModule.join(appRoot, "android");
799
+ const manifestPath = pathModule.join(appRoot, "android", "app", "src", "main", "AndroidManifest.xml");
800
+
801
+ if (mobileConfig) {
802
+ if (mobileConfig.enabled !== true) {
803
+ issues.push("config.mobile.enabled must be true.");
804
+ }
805
+ if (mobileConfig.strategy !== "capacitor") {
806
+ issues.push('config.mobile.strategy must be "capacitor".');
807
+ }
808
+ if (!mobileConfig.apiBaseUrl) {
809
+ issues.push("config.mobile.apiBaseUrl must be set to the real JSKIT server origin.");
810
+ } else if (mobileConfig.apiBaseUrl === "https://api.example.com") {
811
+ issues.push("config.mobile.apiBaseUrl is still using the example placeholder.");
812
+ }
813
+ if (mobileConfig.assetMode === "dev_server") {
814
+ if (!mobileConfig.devServerUrl) {
815
+ issues.push('config.mobile.devServerUrl must be set when config.mobile.assetMode="dev_server".');
816
+ } else if (!isValidHttpOrHttpsUrl(mobileConfig.devServerUrl)) {
817
+ issues.push("config.mobile.devServerUrl must be a valid absolute http/https URL.");
818
+ }
819
+ }
820
+ if (String(mobileConfig.appId || "").startsWith("com.example.")) {
821
+ issues.push("config.mobile.appId is still using the example placeholder namespace.");
822
+ }
823
+ if (String(mobileConfig.android.packageName || "").startsWith("com.example.")) {
824
+ issues.push("config.mobile.android.packageName is still using the example placeholder namespace.");
825
+ }
826
+ }
827
+ if (!(await fileExists(capacitorConfigPath))) {
828
+ issues.push(`Missing ${normalizeRelativePath(appRoot, capacitorConfigPath)}.`);
829
+ }
830
+ if (!(await fileExists(androidDirectoryPath))) {
831
+ issues.push(`Missing ${normalizeRelativePath(appRoot, androidDirectoryPath)}.`);
832
+ }
833
+ if (!(await fileExists(manifestPath))) {
834
+ issues.push(`Missing ${normalizeRelativePath(appRoot, manifestPath)}.`);
835
+ } else if (mobileConfig) {
836
+ const manifestSource = await readFile(manifestPath, "utf8");
837
+ const customScheme = String(mobileConfig?.auth?.customScheme || "").trim().toLowerCase();
838
+ if (customScheme && !manifestSource.includes(`android:scheme="${customScheme}"`)) {
839
+ issues.push(
840
+ `${normalizeRelativePath(appRoot, manifestPath)} is missing the managed deep-link filter for scheme "${customScheme}".`
841
+ );
842
+ }
843
+ }
844
+ if (!sdkDetails.sdkRoot) {
845
+ issues.push("Android SDK location is not configured. Set ANDROID_HOME or ANDROID_SDK_ROOT, or add android/local.properties.");
846
+ } else if (!(await fileExists(sdkDetails.sdkRoot))) {
847
+ issues.push(`Configured Android SDK path does not exist: ${sdkDetails.sdkRoot} (${sdkDetails.source}).`);
848
+ } else {
849
+ issues.push(...await collectAndroidSdkComponentIssues({
850
+ appRoot,
851
+ sdkRoot: sdkDetails.sdkRoot
852
+ }));
853
+ }
854
+ if (mobileConfig) {
855
+ await collectManagedMobileFileDriftIssues({
856
+ ctx,
857
+ appRoot,
858
+ issues
859
+ });
860
+ issues.push(...await collectAndroidNativeShellIdentityIssues({
861
+ ctx,
862
+ appRoot
863
+ }));
864
+ }
865
+
866
+ if (issues.length > 0) {
867
+ stdout.write("Mobile doctor found issues:\n");
868
+ for (const issue of issues) {
869
+ stdout.write(`- ${issue}\n`);
870
+ }
871
+ return 1;
872
+ }
873
+
874
+ stdout.write("Mobile doctor: Android Capacitor shell looks healthy.\n");
875
+ return 0;
876
+ }
877
+
878
+ async function runMobileDevicesAndroidCommand({
879
+ ctx,
880
+ appRoot,
881
+ stdout
882
+ }) {
883
+ const devices = await listVisibleAndroidDevices({
884
+ ctx,
885
+ appRoot
886
+ });
887
+
888
+ if (devices.length < 1) {
889
+ stdout.write("No Android devices visible to adb.\n");
890
+ return 0;
891
+ }
892
+
893
+ stdout.write("Android devices:\n");
894
+ for (const device of devices) {
895
+ const detailSuffix = device.details ? ` ${device.details}` : "";
896
+ stdout.write(`- ${device.serial} ${device.state}${detailSuffix}\n`);
897
+ }
898
+ return 0;
899
+ }
900
+
901
+ async function runMobileTunnelAndroidCommand({
902
+ ctx,
903
+ appRoot,
904
+ options = {},
905
+ stdout
906
+ }) {
907
+ const inlineOptions = normalizeInlineOptions(options);
908
+ const target = String(inlineOptions.target || "").trim();
909
+ if (!target) {
910
+ throw ctx.createCliError("jskit mobile tunnel android requires --target <device-id>.");
911
+ }
912
+
913
+ const mobileConfig = await resolveInstalledMobileConfigForCommand({
914
+ appRoot,
915
+ createCliError: ctx.createCliError
916
+ });
917
+ const port = resolveAdbReversePort({
918
+ mobileConfig,
919
+ explicitPort: inlineOptions.port,
920
+ createCliError: ctx.createCliError
921
+ });
922
+
923
+ const matchingDevice = await resolveAndroidDeviceTarget({
924
+ ctx,
925
+ appRoot,
926
+ explicitTarget: target,
927
+ commandLabel: "adb reverse"
928
+ });
929
+
930
+ await runCapturedBinary("adb", ["-s", matchingDevice.serial, "reverse", `tcp:${port}`, `tcp:${port}`], {
931
+ cwd: appRoot,
932
+ createCliError: ctx.createCliError,
933
+ notFoundMessage: 'Could not find "adb" on PATH. Install Android platform-tools first.'
934
+ });
935
+ const reverseListResult = await runCapturedBinary("adb", ["-s", matchingDevice.serial, "reverse", "--list"], {
936
+ cwd: appRoot,
937
+ createCliError: ctx.createCliError,
938
+ notFoundMessage: 'Could not find "adb" on PATH. Install Android platform-tools first.'
939
+ });
940
+
941
+ stdout.write(`Android reverse tunnel ready for ${matchingDevice.serial}: tcp:${port} -> tcp:${port}\n`);
942
+ const reverseLines = String(reverseListResult.stdout || "")
943
+ .split(/\r?\n/u)
944
+ .map((line) => String(line || "").trim())
945
+ .filter(Boolean);
946
+ if (reverseLines.length > 0) {
947
+ stdout.write("adb reverse --list:\n");
948
+ for (const line of reverseLines) {
949
+ stdout.write(`- ${line}\n`);
950
+ }
951
+ }
952
+ return 0;
953
+ }
954
+
955
+ async function runMobileRestartAndroidCommand({
956
+ ctx,
957
+ appRoot,
958
+ options = {},
959
+ stdout
960
+ }) {
961
+ const inlineOptions = normalizeInlineOptions(options);
962
+ const target = String(inlineOptions.target || "").trim();
963
+ if (!target) {
964
+ throw ctx.createCliError("jskit mobile restart android requires --target <device-id>.");
965
+ }
966
+
967
+ const mobileConfig = await resolveInstalledMobileConfigForCommand({
968
+ appRoot,
969
+ createCliError: ctx.createCliError
970
+ });
971
+ const packageName =
972
+ String(mobileConfig?.android?.packageName || "").trim() ||
973
+ String(mobileConfig?.appId || "").trim();
974
+ if (!packageName) {
975
+ throw ctx.createCliError("config.mobile.android.packageName or config.mobile.appId is required to restart the Android shell.");
976
+ }
977
+
978
+ const matchingDevice = await resolveAndroidDeviceTarget({
979
+ ctx,
980
+ appRoot,
981
+ explicitTarget: target,
982
+ commandLabel: "restart"
983
+ });
984
+
985
+ await runCapturedBinary("adb", ["-s", matchingDevice.serial, "shell", "pm", "clear", packageName], {
986
+ cwd: appRoot,
987
+ createCliError: ctx.createCliError,
988
+ notFoundMessage: 'Could not find "adb" on PATH. Install Android platform-tools first.'
989
+ });
990
+ await runCapturedBinary("adb", ["-s", matchingDevice.serial, "shell", "am", "force-stop", packageName], {
991
+ cwd: appRoot,
992
+ createCliError: ctx.createCliError,
993
+ notFoundMessage: 'Could not find "adb" on PATH. Install Android platform-tools first.'
994
+ });
995
+ await runCapturedBinary("adb", ["-s", matchingDevice.serial, "shell", "am", "start", "-W", "-n", `${packageName}/.MainActivity`], {
996
+ cwd: appRoot,
997
+ createCliError: ctx.createCliError,
998
+ notFoundMessage: 'Could not find "adb" on PATH. Install Android platform-tools first.'
999
+ });
1000
+
1001
+ stdout.write(`Android app restarted on ${matchingDevice.serial}: ${packageName}\n`);
1002
+ return 0;
1003
+ }
1004
+
1005
+ async function runMobileDevAndroidCommand({
1006
+ ctx,
1007
+ commandAdd,
1008
+ appRoot,
1009
+ options = {},
1010
+ stdout,
1011
+ stderr
1012
+ }) {
1013
+ const inlineOptions = normalizeInlineOptions(options);
1014
+ const selectedDevice = await resolveAndroidDeviceTarget({
1015
+ ctx,
1016
+ appRoot,
1017
+ explicitTarget: inlineOptions.target,
1018
+ commandLabel: "the local Android dev flow"
1019
+ });
1020
+
1021
+ stdout.write(`[mobile] Using Android device: ${selectedDevice.serial}\n`);
1022
+ stdout.write("[mobile] Building and syncing the Android shell:\n");
1023
+ stdout.write("[mobile] npx jskit mobile sync android\n");
1024
+ await runMobileSyncAndroidCommand({
1025
+ ctx,
1026
+ commandAdd,
1027
+ appRoot,
1028
+ options,
1029
+ stdout,
1030
+ stderr
1031
+ });
1032
+
1033
+ stdout.write(`[mobile] Installing and launching the app on ${selectedDevice.serial}:\n`);
1034
+ stdout.write(`[mobile] npx jskit mobile run android --target ${selectedDevice.serial}\n`);
1035
+ await runCapRunAndroidCommand({
1036
+ ctx,
1037
+ appRoot,
1038
+ pathModule: ctx.path,
1039
+ target: selectedDevice.serial,
1040
+ stdout,
1041
+ stderr,
1042
+ dryRun: false
1043
+ });
1044
+
1045
+ stdout.write(`[mobile] Creating the adb reverse tunnel on ${selectedDevice.serial}:\n`);
1046
+ stdout.write(`[mobile] npx jskit mobile tunnel android --target ${selectedDevice.serial}\n`);
1047
+ await runMobileTunnelAndroidCommand({
1048
+ ctx,
1049
+ appRoot,
1050
+ options: {
1051
+ inlineOptions: {
1052
+ target: selectedDevice.serial
1053
+ }
1054
+ },
1055
+ stdout,
1056
+ stderr
1057
+ });
1058
+
1059
+ return 0;
1060
+ }
1061
+
1062
+ function createMobileCommands(ctx = {}, { commandAdd } = {}) {
1063
+ const {
1064
+ createCliError,
1065
+ resolveAppRootFromCwd
1066
+ } = ctx;
1067
+
1068
+ if (typeof commandAdd !== "function") {
1069
+ throw new TypeError("createMobileCommands requires commandAdd().");
1070
+ }
1071
+
1072
+ async function commandMobile({ positional = [], options = {}, cwd = "", stdout, stderr }) {
1073
+ const firstToken = String(positional[0] || "").trim();
1074
+ const secondToken = String(positional[1] || "").trim();
1075
+ const remainingPositionals = positional.slice(2);
1076
+
1077
+ if (!firstToken) {
1078
+ renderMobileHelp(stdout);
1079
+ return 0;
1080
+ }
1081
+
1082
+ if (firstToken === "help") {
1083
+ renderMobileHelp(stdout, resolveMobileCommandDefinition(secondToken));
1084
+ return 0;
1085
+ }
1086
+
1087
+ const definition = resolveMobileCommandDefinition(firstToken);
1088
+ if (!definition) {
1089
+ throw createCliError(`Unknown mobile subcommand: ${firstToken}.`, {
1090
+ renderUsage: () => renderMobileHelp(stderr)
1091
+ });
1092
+ }
1093
+
1094
+ if (secondToken === "help") {
1095
+ renderMobileHelp(stdout, definition);
1096
+ return 0;
1097
+ }
1098
+
1099
+ const optionMeta = buildMobileCommandOptionMeta(definition.name);
1100
+ const supportedOptionNames = new Set(Object.keys(optionMeta));
1101
+ const inlineOptionNames = Object.keys(options?.inlineOptions && typeof options.inlineOptions === "object" ? options.inlineOptions : {});
1102
+ const unknownInlineOptionNames = inlineOptionNames.filter((optionName) => !supportedOptionNames.has(optionName));
1103
+ if (unknownInlineOptionNames.length > 0) {
1104
+ throw createCliError(
1105
+ `Unknown option${unknownInlineOptionNames.length === 1 ? "" : "s"} for jskit mobile ${definition.name}: ${unknownInlineOptionNames.map((optionName) => `--${optionName}`).join(", ")}.`,
1106
+ {
1107
+ renderUsage: () => renderMobileHelp(stderr, definition)
1108
+ }
1109
+ );
1110
+ }
1111
+ if (options?.dryRun === true && !supportedOptionNames.has("dry-run")) {
1112
+ throw createCliError(`Unknown option for jskit mobile ${definition.name}: --dry-run.`, {
1113
+ renderUsage: () => renderMobileHelp(stderr, definition)
1114
+ });
1115
+ }
1116
+
1117
+ const appRoot = await resolveAppRootFromCwd(cwd);
1118
+
1119
+ if (definition.name === "devices") {
1120
+ if (secondToken !== "android") {
1121
+ throw createCliError(`jskit mobile devices currently supports only "android".`, {
1122
+ renderUsage: () => renderMobileHelp(stderr, definition)
1123
+ });
1124
+ }
1125
+ if (remainingPositionals.length > 0) {
1126
+ throw createCliError(`Unexpected positional arguments for jskit mobile devices: ${remainingPositionals.join(" ")}`, {
1127
+ renderUsage: () => renderMobileHelp(stderr, definition)
1128
+ });
1129
+ }
1130
+
1131
+ return runMobileDevicesAndroidCommand({
1132
+ ctx,
1133
+ appRoot,
1134
+ stdout,
1135
+ stderr
1136
+ });
1137
+ }
1138
+
1139
+ if (definition.name === "dev") {
1140
+ if (secondToken !== "android") {
1141
+ throw createCliError(`jskit mobile dev currently supports only "android".`, {
1142
+ renderUsage: () => renderMobileHelp(stderr, definition)
1143
+ });
1144
+ }
1145
+ if (remainingPositionals.length > 0) {
1146
+ throw createCliError(`Unexpected positional arguments for jskit mobile dev: ${remainingPositionals.join(" ")}`, {
1147
+ renderUsage: () => renderMobileHelp(stderr, definition)
1148
+ });
1149
+ }
1150
+
1151
+ return runMobileDevAndroidCommand({
1152
+ ctx,
1153
+ commandAdd,
1154
+ appRoot,
1155
+ options,
1156
+ stdout,
1157
+ stderr
1158
+ });
1159
+ }
1160
+
1161
+ if (definition.name === "tunnel") {
1162
+ if (secondToken !== "android") {
1163
+ throw createCliError(`jskit mobile tunnel currently supports only "android".`, {
1164
+ renderUsage: () => renderMobileHelp(stderr, definition)
1165
+ });
1166
+ }
1167
+ if (remainingPositionals.length > 0) {
1168
+ throw createCliError(`Unexpected positional arguments for jskit mobile tunnel: ${remainingPositionals.join(" ")}`, {
1169
+ renderUsage: () => renderMobileHelp(stderr, definition)
1170
+ });
1171
+ }
1172
+
1173
+ return runMobileTunnelAndroidCommand({
1174
+ ctx,
1175
+ appRoot,
1176
+ options,
1177
+ stdout,
1178
+ stderr
1179
+ });
1180
+ }
1181
+
1182
+ if (definition.name === "restart") {
1183
+ if (secondToken !== "android") {
1184
+ throw createCliError(`jskit mobile restart currently supports only "android".`, {
1185
+ renderUsage: () => renderMobileHelp(stderr, definition)
1186
+ });
1187
+ }
1188
+ if (remainingPositionals.length > 0) {
1189
+ throw createCliError(`Unexpected positional arguments for jskit mobile restart: ${remainingPositionals.join(" ")}`, {
1190
+ renderUsage: () => renderMobileHelp(stderr, definition)
1191
+ });
1192
+ }
1193
+
1194
+ return runMobileRestartAndroidCommand({
1195
+ ctx,
1196
+ appRoot,
1197
+ options,
1198
+ stdout,
1199
+ stderr
1200
+ });
1201
+ }
1202
+
1203
+ if (definition.name === "add") {
1204
+ if (secondToken !== "capacitor") {
1205
+ throw createCliError(`jskit mobile add currently supports only "capacitor".`, {
1206
+ renderUsage: () => renderMobileHelp(stderr, definition)
1207
+ });
1208
+ }
1209
+ if (remainingPositionals.length > 0) {
1210
+ throw createCliError(`Unexpected positional arguments for jskit mobile add: ${remainingPositionals.join(" ")}`, {
1211
+ renderUsage: () => renderMobileHelp(stderr, definition)
1212
+ });
1213
+ }
1214
+
1215
+ return runMobileAddCapacitorCommand({
1216
+ ctx,
1217
+ commandAdd,
1218
+ appRoot,
1219
+ options,
1220
+ stdout,
1221
+ stderr
1222
+ });
1223
+ }
1224
+
1225
+ if (definition.name === "sync") {
1226
+ if (secondToken !== "android") {
1227
+ throw createCliError(`jskit mobile sync currently supports only "android".`, {
1228
+ renderUsage: () => renderMobileHelp(stderr, definition)
1229
+ });
1230
+ }
1231
+ if (remainingPositionals.length > 0) {
1232
+ throw createCliError(`Unexpected positional arguments for jskit mobile sync: ${remainingPositionals.join(" ")}`, {
1233
+ renderUsage: () => renderMobileHelp(stderr, definition)
1234
+ });
1235
+ }
1236
+
1237
+ return runMobileSyncAndroidCommand({
1238
+ ctx,
1239
+ commandAdd,
1240
+ appRoot,
1241
+ options,
1242
+ stdout,
1243
+ stderr
1244
+ });
1245
+ }
1246
+
1247
+ if (definition.name === "run") {
1248
+ if (secondToken !== "android") {
1249
+ throw createCliError(`jskit mobile run currently supports only "android".`, {
1250
+ renderUsage: () => renderMobileHelp(stderr, definition)
1251
+ });
1252
+ }
1253
+ if (remainingPositionals.length > 0) {
1254
+ throw createCliError(`Unexpected positional arguments for jskit mobile run: ${remainingPositionals.join(" ")}`, {
1255
+ renderUsage: () => renderMobileHelp(stderr, definition)
1256
+ });
1257
+ }
1258
+
1259
+ return runMobileRunAndroidCommand({
1260
+ ctx,
1261
+ commandAdd,
1262
+ appRoot,
1263
+ options,
1264
+ stdout,
1265
+ stderr
1266
+ });
1267
+ }
1268
+
1269
+ if (definition.name === "build") {
1270
+ if (secondToken !== "android") {
1271
+ throw createCliError(`jskit mobile build currently supports only "android".`, {
1272
+ renderUsage: () => renderMobileHelp(stderr, definition)
1273
+ });
1274
+ }
1275
+ if (remainingPositionals.length > 0) {
1276
+ throw createCliError(`Unexpected positional arguments for jskit mobile build: ${remainingPositionals.join(" ")}`, {
1277
+ renderUsage: () => renderMobileHelp(stderr, definition)
1278
+ });
1279
+ }
1280
+
1281
+ return runMobileBuildAndroidCommand({
1282
+ ctx,
1283
+ commandAdd,
1284
+ appRoot,
1285
+ options,
1286
+ stdout,
1287
+ stderr
1288
+ });
1289
+ }
1290
+
1291
+ if (definition.name === "doctor") {
1292
+ if (secondToken) {
1293
+ throw createCliError(`Unexpected positional arguments for jskit mobile doctor: ${[secondToken, ...remainingPositionals].join(" ")}`, {
1294
+ renderUsage: () => renderMobileHelp(stderr, definition)
1295
+ });
1296
+ }
1297
+
1298
+ return runMobileDoctorCommand({
1299
+ ctx,
1300
+ appRoot,
1301
+ stdout,
1302
+ stderr
1303
+ });
1304
+ }
1305
+
1306
+ throw createCliError(`Unhandled mobile subcommand: ${definition.name}.`, {
1307
+ renderUsage: () => renderMobileHelp(stderr, definition)
1308
+ });
1309
+ }
1310
+
1311
+ return {
1312
+ commandMobile
1313
+ };
1314
+ }
1315
+
1316
+ export { createMobileCommands };