@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,929 @@
1
+ import path from "node:path";
2
+ import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
3
+ import {
4
+ loadAppConfigFromAppRoot,
5
+ resolveMobileConfig
6
+ } from "@jskit-ai/kernel/server/support";
7
+ import { ensureArray, ensureObject } from "../shared/collectionUtils.js";
8
+ import { loadPackageRegistry } from "../cliRuntime/packageRegistries.js";
9
+ import { resolvePackageTemplateRoot } from "../cliRuntime/packageTemplateResolution.js";
10
+ import {
11
+ interpolateFileMutationRecord,
12
+ renderTemplateFile,
13
+ resolveTemplateContextReplacementsForMutation
14
+ } from "../cliRuntime/mutations/templateContext.js";
15
+
16
+ const CAPACITOR_CONFIG_FILE = "capacitor.config.json";
17
+ const CAPACITOR_RUNTIME_PACKAGE_ID = "@jskit-ai/mobile-capacitor";
18
+ const PUBLIC_CONFIG_RELATIVE_PATH = path.join("config", "public.js");
19
+ const ANDROID_DIRECTORY_NAME = "android";
20
+ const ANDROID_MANIFEST_RELATIVE_PATH = path.join(
21
+ ANDROID_DIRECTORY_NAME,
22
+ "app",
23
+ "src",
24
+ "main",
25
+ "AndroidManifest.xml"
26
+ );
27
+ const ANDROID_VARIABLES_RELATIVE_PATH = path.join(
28
+ ANDROID_DIRECTORY_NAME,
29
+ "variables.gradle"
30
+ );
31
+ const ANDROID_APP_BUILD_GRADLE_RELATIVE_PATH = path.join(
32
+ ANDROID_DIRECTORY_NAME,
33
+ "app",
34
+ "build.gradle"
35
+ );
36
+ const ANDROID_STRINGS_RELATIVE_PATH = path.join(
37
+ ANDROID_DIRECTORY_NAME,
38
+ "app",
39
+ "src",
40
+ "main",
41
+ "res",
42
+ "values",
43
+ "strings.xml"
44
+ );
45
+ const ANDROID_MAIN_JAVA_ROOT_RELATIVE_PATH = path.join(
46
+ ANDROID_DIRECTORY_NAME,
47
+ "app",
48
+ "src",
49
+ "main",
50
+ "java"
51
+ );
52
+ const ANDROID_MAIN_KOTLIN_ROOT_RELATIVE_PATH = path.join(
53
+ ANDROID_DIRECTORY_NAME,
54
+ "app",
55
+ "src",
56
+ "main",
57
+ "kotlin"
58
+ );
59
+ const MANAGED_DEEP_LINK_START_MARKER = "<!-- jskit-mobile-capacitor:deep-links:start -->";
60
+ const MANAGED_DEEP_LINK_END_MARKER = "<!-- jskit-mobile-capacitor:deep-links:end -->";
61
+ const MANAGED_MOBILE_CONFIG_START_MARKER = "// jskit-mobile-capacitor:config:start";
62
+ const MANAGED_MOBILE_CONFIG_END_MARKER = "// jskit-mobile-capacitor:config:end";
63
+
64
+ function normalizeRelativePosixPath(pathValue = "") {
65
+ return String(pathValue || "")
66
+ .trim()
67
+ .replace(/\\/gu, "/")
68
+ .replace(/^\/+|\/+$/gu, "")
69
+ .replace(/\/{2,}/gu, "/");
70
+ }
71
+
72
+ function escapeRegExp(value = "") {
73
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
74
+ }
75
+
76
+ async function pathExists(targetPath = "") {
77
+ try {
78
+ await access(targetPath);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function humanizeAppName(value = "") {
86
+ const normalized = String(value || "").trim();
87
+ if (!normalized) {
88
+ return "Example App";
89
+ }
90
+
91
+ const words = normalized
92
+ .replace(/^@/u, "")
93
+ .replace(/[/._-]+/gu, " ")
94
+ .split(/\s+/u)
95
+ .map((entry) => entry.trim())
96
+ .filter(Boolean);
97
+ if (words.length < 1) {
98
+ return "Example App";
99
+ }
100
+ return words
101
+ .map((entry) => entry.charAt(0).toUpperCase() + entry.slice(1))
102
+ .join(" ");
103
+ }
104
+
105
+ function slugifyForIdentifier(value = "") {
106
+ const normalizedValue = String(value || "").trim().toLowerCase();
107
+ if (!normalizedValue) {
108
+ return "exampleapp";
109
+ }
110
+
111
+ const packageLeaf = normalizedValue.split("/").filter(Boolean).pop() || normalizedValue;
112
+ const rawParts = packageLeaf
113
+ .replace(/[^a-z0-9]+/gu, " ")
114
+ .split(/\s+/u)
115
+ .map((entry) => entry.trim())
116
+ .filter(Boolean);
117
+ const genericSuffixParts = new Set(["app", "mobile", "web", "site", "client"]);
118
+ const filteredParts = rawParts.filter((entry) => !genericSuffixParts.has(entry));
119
+ const candidateParts = filteredParts.length > 0 ? filteredParts : rawParts;
120
+
121
+ return candidateParts
122
+ .join("")
123
+ .replace(/^([0-9]+)/u, "")
124
+ || "exampleapp";
125
+ }
126
+
127
+ function buildManagedMobileConfigStub({ packageJson = {} } = {}) {
128
+ const packageName = String(packageJson?.name || "").trim();
129
+ const packageVersion = String(packageJson?.version || "").trim() || "0.1.0";
130
+ const scheme = slugifyForIdentifier(packageName);
131
+ const appId = `ai.jskit.${scheme}`;
132
+ const appName = humanizeAppName(packageName);
133
+
134
+ return [
135
+ `${MANAGED_MOBILE_CONFIG_START_MARKER}`,
136
+ "config.mobile = {",
137
+ " enabled: true,",
138
+ ' strategy: "capacitor",',
139
+ ` appId: ${JSON.stringify(appId)},`,
140
+ ` appName: ${JSON.stringify(appName)},`,
141
+ ' assetMode: "bundled",',
142
+ ' devServerUrl: "",',
143
+ ' apiBaseUrl: "http://127.0.0.1:3000",',
144
+ " auth: {",
145
+ ' callbackPath: "/auth/login",',
146
+ ` customScheme: ${JSON.stringify(scheme)},`,
147
+ " appLinkDomains: []",
148
+ " },",
149
+ " android: {",
150
+ ` packageName: ${JSON.stringify(appId)},`,
151
+ " minSdk: 26,",
152
+ " targetSdk: 35,",
153
+ " versionCode: 1,",
154
+ ` versionName: ${JSON.stringify(packageVersion)}`,
155
+ " }",
156
+ "};",
157
+ `${MANAGED_MOBILE_CONFIG_END_MARKER}`
158
+ ].join("\n");
159
+ }
160
+
161
+ function parseAndroidSdkDirFromLocalProperties(source = "") {
162
+ const lines = String(source || "").split(/\r?\n/u);
163
+ for (const line of lines) {
164
+ const match = /^\s*sdk\.dir\s*=\s*(.+?)\s*$/u.exec(line);
165
+ if (!match) {
166
+ continue;
167
+ }
168
+ return match[1].replace(/\\:/gu, ":").replace(/\\\\/gu, "\\").trim();
169
+ }
170
+ return "";
171
+ }
172
+
173
+ function buildAndroidNativeConfig(mobileConfig = {}) {
174
+ const packageName = String(mobileConfig?.android?.packageName || "").trim();
175
+ const appName = String(mobileConfig?.appName || "").trim();
176
+ const customScheme = String(mobileConfig?.auth?.customScheme || "").trim().toLowerCase();
177
+ const minSdk = String(mobileConfig?.android?.minSdk || "").trim();
178
+ const targetSdk = String(mobileConfig?.android?.targetSdk || "").trim();
179
+ const versionCode = String(mobileConfig?.android?.versionCode || "").trim();
180
+ const versionName = String(mobileConfig?.android?.versionName || "").trim();
181
+
182
+ if (!packageName) {
183
+ throw new Error("config.mobile.android.packageName is required before refreshing the Android shell.");
184
+ }
185
+ if (!appName) {
186
+ throw new Error("config.mobile.appName is required before refreshing the Android shell.");
187
+ }
188
+ if (!customScheme) {
189
+ throw new Error("config.mobile.auth.customScheme is required before refreshing the Android shell.");
190
+ }
191
+ if (!minSdk || !targetSdk || !versionCode || !versionName) {
192
+ throw new Error("config.mobile.android min/target SDK and version fields are required before refreshing the Android shell.");
193
+ }
194
+
195
+ return Object.freeze({
196
+ packageName,
197
+ appName,
198
+ customScheme,
199
+ minSdk,
200
+ compileSdk: targetSdk,
201
+ targetSdk,
202
+ versionCode,
203
+ versionName
204
+ });
205
+ }
206
+
207
+ function replaceRequiredPattern(source = "", pattern, replacement, label = "pattern") {
208
+ const normalizedSource = String(source || "");
209
+ if (!pattern.test(normalizedSource)) {
210
+ throw new Error(`Could not locate ${label} while refreshing the Android shell.`);
211
+ }
212
+ return normalizedSource.replace(pattern, replacement);
213
+ }
214
+
215
+ function escapeXmlText(value = "") {
216
+ return String(value || "")
217
+ .replace(/&/gu, "&amp;")
218
+ .replace(/</gu, "&lt;")
219
+ .replace(/>/gu, "&gt;")
220
+ .replace(/"/gu, "&quot;")
221
+ .replace(/'/gu, "&apos;");
222
+ }
223
+
224
+ function replaceXmlStringValue(source = "", stringName = "", value = "") {
225
+ const escapedValue = escapeXmlText(value);
226
+ return replaceRequiredPattern(
227
+ source,
228
+ new RegExp(`(<string\\s+name="${escapeRegExp(stringName)}">)([\\s\\S]*?)(</string>)`, "u"),
229
+ `$1${escapedValue}$3`,
230
+ `strings.xml value ${stringName}`
231
+ );
232
+ }
233
+
234
+ function renderAndroidVariablesGradleSource(source = "", nativeConfig = {}) {
235
+ let nextSource = String(source || "");
236
+ nextSource = replaceRequiredPattern(
237
+ nextSource,
238
+ /(minSdkVersion\s*=\s*)(\d+)/u,
239
+ `$1${nativeConfig.minSdk}`,
240
+ "variables.gradle minSdkVersion"
241
+ );
242
+ nextSource = replaceRequiredPattern(
243
+ nextSource,
244
+ /(compileSdkVersion\s*=\s*)(\d+)/u,
245
+ `$1${nativeConfig.compileSdk}`,
246
+ "variables.gradle compileSdkVersion"
247
+ );
248
+ nextSource = replaceRequiredPattern(
249
+ nextSource,
250
+ /(targetSdkVersion\s*=\s*)(\d+)/u,
251
+ `$1${nativeConfig.targetSdk}`,
252
+ "variables.gradle targetSdkVersion"
253
+ );
254
+ return nextSource;
255
+ }
256
+
257
+ function renderAndroidAppBuildGradleSource(source = "", nativeConfig = {}) {
258
+ let nextSource = String(source || "");
259
+ nextSource = replaceRequiredPattern(
260
+ nextSource,
261
+ /(namespace\s+)(["'])([^"']+)(["'])/u,
262
+ `$1"${nativeConfig.packageName}"`,
263
+ "app/build.gradle namespace"
264
+ );
265
+ nextSource = replaceRequiredPattern(
266
+ nextSource,
267
+ /(applicationId\s+)(["'])([^"']+)(["'])/u,
268
+ `$1"${nativeConfig.packageName}"`,
269
+ "app/build.gradle applicationId"
270
+ );
271
+ nextSource = replaceRequiredPattern(
272
+ nextSource,
273
+ /(versionCode\s+)(\d+)/u,
274
+ `$1${nativeConfig.versionCode}`,
275
+ "app/build.gradle versionCode"
276
+ );
277
+ nextSource = replaceRequiredPattern(
278
+ nextSource,
279
+ /(versionName\s+)(["'])([^"']+)(["'])/u,
280
+ `$1"${nativeConfig.versionName}"`,
281
+ "app/build.gradle versionName"
282
+ );
283
+ return nextSource;
284
+ }
285
+
286
+ function renderAndroidStringsSource(source = "", nativeConfig = {}) {
287
+ let nextSource = String(source || "");
288
+ nextSource = replaceXmlStringValue(nextSource, "app_name", nativeConfig.appName);
289
+ nextSource = replaceXmlStringValue(nextSource, "title_activity_main", nativeConfig.appName);
290
+ nextSource = replaceXmlStringValue(nextSource, "package_name", nativeConfig.packageName);
291
+ nextSource = replaceXmlStringValue(nextSource, "custom_url_scheme", nativeConfig.customScheme);
292
+ return nextSource;
293
+ }
294
+
295
+ function renderAndroidMainActivitySource(source = "", packageName = "", extension = ".java") {
296
+ const normalizedExtension = String(extension || "").trim().toLowerCase();
297
+ if (normalizedExtension === ".kt") {
298
+ return replaceRequiredPattern(
299
+ source,
300
+ /^[ \t]*package[ \t]+[A-Za-z0-9_.]+[ \t]*$/mu,
301
+ `package ${packageName}`,
302
+ "MainActivity package declaration"
303
+ );
304
+ }
305
+
306
+ return replaceRequiredPattern(
307
+ source,
308
+ /^[ \t]*package[ \t]+[A-Za-z0-9_.]+[ \t]*;[ \t]*$/mu,
309
+ `package ${packageName};`,
310
+ "MainActivity package declaration"
311
+ );
312
+ }
313
+
314
+ async function listFilesRecursively(rootDirectoryPath = "") {
315
+ const collected = [];
316
+ if (!(await pathExists(rootDirectoryPath))) {
317
+ return collected;
318
+ }
319
+
320
+ const entries = await readdir(rootDirectoryPath, { withFileTypes: true });
321
+ for (const entry of entries) {
322
+ const absolutePath = path.join(rootDirectoryPath, entry.name);
323
+ if (entry.isDirectory()) {
324
+ collected.push(...await listFilesRecursively(absolutePath));
325
+ continue;
326
+ }
327
+ if (entry.isFile()) {
328
+ collected.push(absolutePath);
329
+ }
330
+ }
331
+
332
+ return collected;
333
+ }
334
+
335
+ async function resolveAndroidMainActivityEntry(appRoot = "") {
336
+ const candidateRoots = [
337
+ path.join(appRoot, ANDROID_MAIN_JAVA_ROOT_RELATIVE_PATH),
338
+ path.join(appRoot, ANDROID_MAIN_KOTLIN_ROOT_RELATIVE_PATH)
339
+ ];
340
+ const candidates = [];
341
+
342
+ for (const rootDirectoryPath of candidateRoots) {
343
+ const files = await listFilesRecursively(rootDirectoryPath);
344
+ for (const absolutePath of files) {
345
+ if (path.basename(absolutePath) !== "MainActivity.java" && path.basename(absolutePath) !== "MainActivity.kt") {
346
+ continue;
347
+ }
348
+ candidates.push({
349
+ absolutePath,
350
+ sourceRoot: rootDirectoryPath,
351
+ extension: path.extname(absolutePath)
352
+ });
353
+ }
354
+ }
355
+
356
+ if (candidates.length < 1) {
357
+ return null;
358
+ }
359
+ if (candidates.length > 1) {
360
+ throw new Error("Found multiple MainActivity source files in the Android shell.");
361
+ }
362
+ return candidates[0];
363
+ }
364
+
365
+ async function resolveInstalledMobileConfig(appRoot = "") {
366
+ const mergedAppConfig = await loadAppConfigFromAppRoot({
367
+ appRoot
368
+ });
369
+ return resolveMobileConfig({
370
+ mobile: mergedAppConfig.mobile
371
+ });
372
+ }
373
+
374
+ async function resolveAndroidSdkDetails({ appRoot = "" } = {}) {
375
+ const envSdkRoot = String(process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || "").trim();
376
+ if (envSdkRoot) {
377
+ return Object.freeze({
378
+ source: process.env.ANDROID_HOME ? "ANDROID_HOME" : "ANDROID_SDK_ROOT",
379
+ sdkRoot: envSdkRoot
380
+ });
381
+ }
382
+
383
+ const localPropertiesPath = path.join(appRoot, ANDROID_DIRECTORY_NAME, "local.properties");
384
+ if (!(await pathExists(localPropertiesPath))) {
385
+ return Object.freeze({
386
+ source: "",
387
+ sdkRoot: ""
388
+ });
389
+ }
390
+
391
+ const localPropertiesSource = await readFile(localPropertiesPath, "utf8");
392
+ const sdkRoot = parseAndroidSdkDirFromLocalProperties(localPropertiesSource);
393
+ return Object.freeze({
394
+ source: sdkRoot ? "android/local.properties" : "",
395
+ sdkRoot
396
+ });
397
+ }
398
+
399
+ async function collectAndroidSdkComponentIssues({ appRoot = "", sdkRoot = "" } = {}) {
400
+ const issues = [];
401
+ const normalizedSdkRoot = String(sdkRoot || "").trim();
402
+ if (!normalizedSdkRoot) {
403
+ return issues;
404
+ }
405
+
406
+ const variablesGradlePath = path.join(appRoot, ANDROID_VARIABLES_RELATIVE_PATH);
407
+ if (!(await pathExists(variablesGradlePath))) {
408
+ return issues;
409
+ }
410
+
411
+ const variablesGradleSource = await readFile(variablesGradlePath, "utf8");
412
+ const compileSdkMatch = /compileSdkVersion\s*=\s*(\d+)/u.exec(variablesGradleSource);
413
+ const compileSdkVersion = String(compileSdkMatch?.[1] || "").trim();
414
+ if (compileSdkVersion) {
415
+ const platformDirectoryPath = path.join(
416
+ normalizedSdkRoot,
417
+ "platforms",
418
+ `android-${compileSdkVersion}`
419
+ );
420
+ if (!(await pathExists(platformDirectoryPath))) {
421
+ issues.push(
422
+ `Android SDK platform android-${compileSdkVersion} is missing under ${normalizedSdkRoot}.`
423
+ );
424
+ }
425
+ }
426
+
427
+ const buildToolsRoot = path.join(normalizedSdkRoot, "build-tools");
428
+ if (!(await pathExists(buildToolsRoot))) {
429
+ issues.push(`Android SDK build-tools directory is missing under ${normalizedSdkRoot}.`);
430
+ return issues;
431
+ }
432
+
433
+ const buildToolsEntries = await readdir(buildToolsRoot, { withFileTypes: true });
434
+ const hasBuildToolsVersion = buildToolsEntries.some((entry) => entry.isDirectory());
435
+ if (!hasBuildToolsVersion) {
436
+ issues.push(`Android SDK build-tools has no installed versions under ${buildToolsRoot}.`);
437
+ }
438
+
439
+ const licensesRoot = path.join(normalizedSdkRoot, "licenses");
440
+ if (!(await pathExists(licensesRoot))) {
441
+ issues.push(`Android SDK licenses directory is missing under ${normalizedSdkRoot}. Run sdkmanager --licenses after installing the required components.`);
442
+ return issues;
443
+ }
444
+
445
+ const licenseEntries = await readdir(licensesRoot, { withFileTypes: true });
446
+ const hasLicenseFiles = licenseEntries.some((entry) => entry.isFile());
447
+ if (!hasLicenseFiles) {
448
+ issues.push(`Android SDK licenses are not accepted under ${licensesRoot}. Run sdkmanager --licenses before building the Android shell.`);
449
+ }
450
+
451
+ return issues;
452
+ }
453
+
454
+ async function assertAndroidSdkConfigured({ ctx, appRoot } = {}) {
455
+ const { createCliError } = ctx;
456
+ const sdkDetails = await resolveAndroidSdkDetails({
457
+ appRoot
458
+ });
459
+ if (!sdkDetails.sdkRoot) {
460
+ throw createCliError(
461
+ `Android SDK location is not configured. Set ANDROID_HOME or ANDROID_SDK_ROOT, or create android/local.properties with sdk.dir=... before running the Android shell.`
462
+ );
463
+ }
464
+ if (!(await pathExists(sdkDetails.sdkRoot))) {
465
+ throw createCliError(
466
+ `Configured Android SDK path does not exist: ${sdkDetails.sdkRoot} (${sdkDetails.source}).`
467
+ );
468
+ }
469
+ const componentIssues = await collectAndroidSdkComponentIssues({
470
+ appRoot,
471
+ sdkRoot: sdkDetails.sdkRoot
472
+ });
473
+ if (componentIssues.length > 0) {
474
+ throw createCliError(componentIssues.join(" "));
475
+ }
476
+
477
+ return sdkDetails;
478
+ }
479
+
480
+ async function ensureMobileConfigStub({
481
+ ctx,
482
+ appRoot,
483
+ packageJson = {},
484
+ dryRun = false,
485
+ stdout
486
+ } = {}) {
487
+ const {
488
+ normalizeRelativePath
489
+ } = ctx;
490
+ const publicConfigPath = path.join(appRoot, PUBLIC_CONFIG_RELATIVE_PATH);
491
+ const currentSource = await readFile(publicConfigPath, "utf8");
492
+ if (/\bconfig\.mobile\b|\bmobile\s*:/u.test(currentSource)) {
493
+ return false;
494
+ }
495
+
496
+ const stubSource = buildManagedMobileConfigStub({
497
+ packageJson
498
+ });
499
+ const nextSource = `${String(currentSource || "").replace(/\s*$/u, "")}\n\n${stubSource}\n`;
500
+
501
+ if (dryRun === true) {
502
+ stdout?.write(`[dry-run] append managed mobile config to ${normalizeRelativePath(appRoot, publicConfigPath)}\n`);
503
+ return true;
504
+ }
505
+
506
+ await writeFile(publicConfigPath, nextSource, "utf8");
507
+ stdout?.write(`[mobile] Added managed mobile config stub to ${normalizeRelativePath(appRoot, publicConfigPath)}.\n`);
508
+ return true;
509
+ }
510
+
511
+ function buildManagedDeepLinkIntentFilterBlock(mobileConfig = {}) {
512
+ const customScheme = String(mobileConfig?.auth?.customScheme || "").trim().toLowerCase();
513
+ if (!customScheme) {
514
+ throw new Error("config.mobile.auth.customScheme is required before wiring Android deep links.");
515
+ }
516
+
517
+ return [
518
+ ` ${MANAGED_DEEP_LINK_START_MARKER}`,
519
+ ' <intent-filter>',
520
+ ' <action android:name="android.intent.action.VIEW" />',
521
+ ' <category android:name="android.intent.category.DEFAULT" />',
522
+ ' <category android:name="android.intent.category.BROWSABLE" />',
523
+ ` <data android:scheme="${customScheme}" />`,
524
+ " </intent-filter>",
525
+ ` ${MANAGED_DEEP_LINK_END_MARKER}`
526
+ ].join("\n");
527
+ }
528
+
529
+ function shouldAllowAndroidCleartextTraffic(mobileConfig = {}) {
530
+ const assetMode = String(mobileConfig?.assetMode || "").trim().toLowerCase();
531
+ const devServerUrl = String(mobileConfig?.devServerUrl || "").trim();
532
+ const apiBaseUrl = String(mobileConfig?.apiBaseUrl || "").trim();
533
+
534
+ if (assetMode === "dev_server" && devServerUrl) {
535
+ try {
536
+ if (String(new URL(devServerUrl).protocol || "").toLowerCase() === "http:") {
537
+ return true;
538
+ }
539
+ } catch {}
540
+ }
541
+
542
+ if (apiBaseUrl) {
543
+ try {
544
+ if (String(new URL(apiBaseUrl).protocol || "").toLowerCase() === "http:") {
545
+ return true;
546
+ }
547
+ } catch {}
548
+ }
549
+
550
+ return false;
551
+ }
552
+
553
+ function renderAndroidManifestApplicationTrafficPolicy(manifestSource = "", mobileConfig = {}) {
554
+ const normalizedSource = String(manifestSource || "");
555
+ if (!normalizedSource) {
556
+ throw new Error("AndroidManifest.xml is empty.");
557
+ }
558
+
559
+ const applicationPattern = /(<application\b)([\s\S]*?)(>)/u;
560
+ const match = applicationPattern.exec(normalizedSource);
561
+ if (!match) {
562
+ throw new Error("Could not locate <application> in AndroidManifest.xml.");
563
+ }
564
+
565
+ const allowCleartext = shouldAllowAndroidCleartextTraffic(mobileConfig);
566
+ let applicationAttributes = String(match[2] || "");
567
+ applicationAttributes = applicationAttributes.replace(/\s+android:usesCleartextTraffic="(?:true|false)"/gu, "");
568
+ if (allowCleartext) {
569
+ applicationAttributes = `${applicationAttributes} android:usesCleartextTraffic="true"`;
570
+ }
571
+
572
+ return normalizedSource.replace(applicationPattern, `$1${applicationAttributes}$3`);
573
+ }
574
+
575
+ function renderManagedAndroidManifest(manifestSource = "", mobileConfig = {}) {
576
+ const manifestWithTrafficPolicy = renderAndroidManifestApplicationTrafficPolicy(manifestSource, mobileConfig);
577
+ const managedBlock = buildManagedDeepLinkIntentFilterBlock(mobileConfig);
578
+ return injectManagedDeepLinkBlock(manifestWithTrafficPolicy, managedBlock);
579
+ }
580
+
581
+ function injectManagedDeepLinkBlock(manifestSource = "", managedBlock = "") {
582
+ const normalizedSource = String(manifestSource || "");
583
+ const normalizedManagedBlock = String(managedBlock || "").trim();
584
+ if (!normalizedSource) {
585
+ throw new Error("AndroidManifest.xml is empty.");
586
+ }
587
+ if (!normalizedManagedBlock) {
588
+ throw new Error("Managed deep-link block is empty.");
589
+ }
590
+
591
+ const managedBlockPattern = new RegExp(
592
+ `\\n?\\s*${escapeRegExp(MANAGED_DEEP_LINK_START_MARKER)}[\\s\\S]*?${escapeRegExp(MANAGED_DEEP_LINK_END_MARKER)}\\n?`,
593
+ "u"
594
+ );
595
+ const sourceWithoutManagedBlock = normalizedSource.replace(managedBlockPattern, "\n");
596
+ const mainActivityPattern = /(<activity\b[^>]*android:name="\.MainActivity"[\s\S]*?>)([\s\S]*?)(\n\s*<\/activity>)/u;
597
+ const match = mainActivityPattern.exec(sourceWithoutManagedBlock);
598
+ if (!match) {
599
+ throw new Error("Could not locate MainActivity in AndroidManifest.xml.");
600
+ }
601
+
602
+ const [, activityOpen, activityBody, activityClose] = match;
603
+ const normalizedBody = String(activityBody || "").replace(/\s+$/u, "");
604
+ const nextActivityBody = normalizedBody
605
+ ? `${normalizedBody}\n\n${normalizedManagedBlock}`
606
+ : `\n${normalizedManagedBlock}`;
607
+
608
+ return sourceWithoutManagedBlock.replace(
609
+ mainActivityPattern,
610
+ `${activityOpen}${nextActivityBody}${activityClose}`
611
+ );
612
+ }
613
+
614
+ async function assertCapacitorShellInstalled({ ctx, appRoot }) {
615
+ const missingPaths = await collectCapacitorShellInstallIssues({
616
+ ctx,
617
+ appRoot
618
+ });
619
+
620
+ if (missingPaths.length > 0) {
621
+ throw ctx.createCliError(
622
+ `Capacitor Android shell is not installed for this app. Missing: ${missingPaths.join(", ")}. Run jskit mobile add capacitor first.`
623
+ );
624
+ }
625
+ }
626
+
627
+ async function collectCapacitorShellInstallIssues({ ctx, appRoot } = {}) {
628
+ const {
629
+ fileExists,
630
+ path: pathModule,
631
+ normalizeRelativePath
632
+ } = ctx;
633
+ const missingPaths = [];
634
+
635
+ const capacitorConfigPath = pathModule.join(appRoot, CAPACITOR_CONFIG_FILE);
636
+ if (!(await fileExists(capacitorConfigPath))) {
637
+ missingPaths.push(normalizeRelativePath(appRoot, capacitorConfigPath));
638
+ }
639
+
640
+ const androidDirectoryPath = pathModule.join(appRoot, ANDROID_DIRECTORY_NAME);
641
+ if (!(await fileExists(androidDirectoryPath))) {
642
+ missingPaths.push(normalizeRelativePath(appRoot, androidDirectoryPath));
643
+ }
644
+
645
+ const requiredAndroidPaths = [
646
+ ANDROID_MANIFEST_RELATIVE_PATH,
647
+ ANDROID_APP_BUILD_GRADLE_RELATIVE_PATH,
648
+ ANDROID_VARIABLES_RELATIVE_PATH,
649
+ ANDROID_STRINGS_RELATIVE_PATH
650
+ ];
651
+ for (const relativePath of requiredAndroidPaths) {
652
+ const absolutePath = pathModule.join(appRoot, relativePath);
653
+ if (!(await fileExists(absolutePath))) {
654
+ missingPaths.push(normalizeRelativePath(appRoot, absolutePath));
655
+ }
656
+ }
657
+
658
+ const mainActivityEntry = await resolveAndroidMainActivityEntry(appRoot);
659
+ if (!mainActivityEntry) {
660
+ missingPaths.push("Android MainActivity source file");
661
+ }
662
+
663
+ return missingPaths;
664
+ }
665
+
666
+ async function ensureAndroidManifestDeepLinks({
667
+ ctx,
668
+ appRoot,
669
+ dryRun = false,
670
+ stdout
671
+ } = {}) {
672
+ const {
673
+ fileExists,
674
+ path: pathModule,
675
+ normalizeRelativePath,
676
+ createCliError
677
+ } = ctx;
678
+ const manifestPath = pathModule.join(appRoot, ANDROID_MANIFEST_RELATIVE_PATH);
679
+ if (!(await fileExists(manifestPath))) {
680
+ throw createCliError(
681
+ `Capacitor Android shell is missing ${normalizeRelativePath(appRoot, manifestPath)}. Run jskit mobile add capacitor first.`
682
+ );
683
+ }
684
+
685
+ const mobileConfig = await resolveInstalledMobileConfig(appRoot);
686
+ const currentManifestSource = await readFile(manifestPath, "utf8");
687
+ const nextManifestSource = renderManagedAndroidManifest(currentManifestSource, mobileConfig);
688
+
689
+ if (nextManifestSource === currentManifestSource) {
690
+ return false;
691
+ }
692
+
693
+ if (dryRun === true) {
694
+ stdout?.write(`[dry-run] refresh ${normalizeRelativePath(appRoot, manifestPath)}\n`);
695
+ return true;
696
+ }
697
+
698
+ await writeFile(manifestPath, nextManifestSource, "utf8");
699
+ stdout?.write(`[mobile] Refreshed ${normalizeRelativePath(appRoot, manifestPath)}.\n`);
700
+ return true;
701
+ }
702
+
703
+ async function collectAndroidNativeShellIdentityIssues({ ctx, appRoot } = {}) {
704
+ const {
705
+ fileExists,
706
+ path: pathModule,
707
+ normalizeRelativePath
708
+ } = ctx;
709
+ const issues = [];
710
+ const mobileConfig = await resolveInstalledMobileConfig(appRoot);
711
+ const nativeConfig = buildAndroidNativeConfig(mobileConfig);
712
+ const buildGradlePath = pathModule.join(appRoot, ANDROID_APP_BUILD_GRADLE_RELATIVE_PATH);
713
+ const variablesGradlePath = pathModule.join(appRoot, ANDROID_VARIABLES_RELATIVE_PATH);
714
+ const stringsPath = pathModule.join(appRoot, ANDROID_STRINGS_RELATIVE_PATH);
715
+
716
+ const compareRenderedFile = async (absolutePath, renderer) => {
717
+ if (!(await fileExists(absolutePath))) {
718
+ issues.push(`Missing ${normalizeRelativePath(appRoot, absolutePath)}.`);
719
+ return;
720
+ }
721
+ const currentSource = await readFile(absolutePath, "utf8");
722
+ const expectedSource = renderer(currentSource, nativeConfig);
723
+ if (currentSource !== expectedSource) {
724
+ issues.push(
725
+ `${normalizeRelativePath(appRoot, absolutePath)} is stale and no longer matches config.mobile. Re-run jskit mobile sync android to refresh the Android shell.`
726
+ );
727
+ }
728
+ };
729
+
730
+ await compareRenderedFile(buildGradlePath, renderAndroidAppBuildGradleSource);
731
+ await compareRenderedFile(variablesGradlePath, renderAndroidVariablesGradleSource);
732
+ await compareRenderedFile(stringsPath, renderAndroidStringsSource);
733
+ await compareRenderedFile(
734
+ pathModule.join(appRoot, ANDROID_MANIFEST_RELATIVE_PATH),
735
+ (currentSource) => renderManagedAndroidManifest(currentSource, mobileConfig)
736
+ );
737
+
738
+ const mainActivityEntry = await resolveAndroidMainActivityEntry(appRoot);
739
+ if (!mainActivityEntry) {
740
+ issues.push("Missing Android MainActivity source file.");
741
+ return issues;
742
+ }
743
+
744
+ const expectedMainActivityPath = path.join(
745
+ mainActivityEntry.sourceRoot,
746
+ ...nativeConfig.packageName.split("."),
747
+ `MainActivity${mainActivityEntry.extension}`
748
+ );
749
+ const currentMainActivitySource = await readFile(mainActivityEntry.absolutePath, "utf8");
750
+ const expectedMainActivitySource = renderAndroidMainActivitySource(
751
+ currentMainActivitySource,
752
+ nativeConfig.packageName,
753
+ mainActivityEntry.extension
754
+ );
755
+ if (
756
+ mainActivityEntry.absolutePath !== expectedMainActivityPath ||
757
+ currentMainActivitySource !== expectedMainActivitySource
758
+ ) {
759
+ issues.push(
760
+ `${normalizeRelativePath(appRoot, mainActivityEntry.absolutePath)} is stale and no longer matches config.mobile. Re-run jskit mobile sync android to refresh the Android shell.`
761
+ );
762
+ }
763
+
764
+ return issues;
765
+ }
766
+
767
+ async function ensureAndroidNativeShellIdentity({
768
+ ctx,
769
+ appRoot,
770
+ dryRun = false,
771
+ stdout
772
+ } = {}) {
773
+ const {
774
+ fileExists,
775
+ path: pathModule,
776
+ normalizeRelativePath,
777
+ createCliError
778
+ } = ctx;
779
+ const mobileConfig = await resolveInstalledMobileConfig(appRoot);
780
+ const nativeConfig = buildAndroidNativeConfig(mobileConfig);
781
+ let touched = false;
782
+ const refreshRenderedFile = async (relativePath, renderer) => {
783
+ const absolutePath = pathModule.join(appRoot, relativePath);
784
+ if (!(await fileExists(absolutePath))) {
785
+ throw createCliError(
786
+ `Capacitor Android shell is missing ${normalizeRelativePath(appRoot, absolutePath)}. Run jskit mobile add capacitor first.`
787
+ );
788
+ }
789
+ const currentSource = await readFile(absolutePath, "utf8");
790
+ const nextSource = renderer(currentSource, nativeConfig);
791
+ if (nextSource === currentSource) {
792
+ return;
793
+ }
794
+ touched = true;
795
+ if (dryRun === true) {
796
+ stdout?.write(`[dry-run] refresh ${normalizeRelativePath(appRoot, absolutePath)}\n`);
797
+ return;
798
+ }
799
+ await writeFile(absolutePath, nextSource, "utf8");
800
+ stdout?.write(`[mobile] Refreshed ${normalizeRelativePath(appRoot, absolutePath)}.\n`);
801
+ };
802
+
803
+ await refreshRenderedFile(ANDROID_APP_BUILD_GRADLE_RELATIVE_PATH, renderAndroidAppBuildGradleSource);
804
+ await refreshRenderedFile(ANDROID_VARIABLES_RELATIVE_PATH, renderAndroidVariablesGradleSource);
805
+ await refreshRenderedFile(ANDROID_STRINGS_RELATIVE_PATH, renderAndroidStringsSource);
806
+ await refreshRenderedFile(ANDROID_MANIFEST_RELATIVE_PATH, (currentSource) => renderManagedAndroidManifest(currentSource, mobileConfig));
807
+
808
+ const mainActivityEntry = await resolveAndroidMainActivityEntry(appRoot);
809
+ if (!mainActivityEntry) {
810
+ throw createCliError("Capacitor Android shell is missing MainActivity.java or MainActivity.kt. Run jskit mobile add capacitor first.");
811
+ }
812
+
813
+ const currentMainActivitySource = await readFile(mainActivityEntry.absolutePath, "utf8");
814
+ const nextMainActivitySource = renderAndroidMainActivitySource(
815
+ currentMainActivitySource,
816
+ nativeConfig.packageName,
817
+ mainActivityEntry.extension
818
+ );
819
+ const nextMainActivityPath = path.join(
820
+ mainActivityEntry.sourceRoot,
821
+ ...nativeConfig.packageName.split("."),
822
+ `MainActivity${mainActivityEntry.extension}`
823
+ );
824
+ if (
825
+ nextMainActivitySource !== currentMainActivitySource ||
826
+ nextMainActivityPath !== mainActivityEntry.absolutePath
827
+ ) {
828
+ touched = true;
829
+ if (dryRun === true) {
830
+ const currentRelativePath = normalizeRelativePath(appRoot, mainActivityEntry.absolutePath);
831
+ const nextRelativePath = normalizeRelativePath(appRoot, nextMainActivityPath);
832
+ if (currentRelativePath === nextRelativePath) {
833
+ stdout?.write(`[dry-run] refresh ${currentRelativePath}\n`);
834
+ } else {
835
+ stdout?.write(`[dry-run] move ${currentRelativePath} -> ${nextRelativePath}\n`);
836
+ }
837
+ } else {
838
+ await mkdir(path.dirname(nextMainActivityPath), { recursive: true });
839
+ await writeFile(nextMainActivityPath, nextMainActivitySource, "utf8");
840
+ if (nextMainActivityPath !== mainActivityEntry.absolutePath) {
841
+ await unlink(mainActivityEntry.absolutePath);
842
+ }
843
+ const currentRelativePath = normalizeRelativePath(appRoot, mainActivityEntry.absolutePath);
844
+ const nextRelativePath = normalizeRelativePath(appRoot, nextMainActivityPath);
845
+ if (currentRelativePath === nextRelativePath) {
846
+ stdout?.write(`[mobile] Refreshed ${nextRelativePath}.\n`);
847
+ } else {
848
+ stdout?.write(`[mobile] Moved ${currentRelativePath} -> ${nextRelativePath}.\n`);
849
+ }
850
+ }
851
+ }
852
+
853
+ return touched;
854
+ }
855
+
856
+ async function renderManagedMobileFile({
857
+ appRoot,
858
+ relativeTargetPath,
859
+ packageId = CAPACITOR_RUNTIME_PACKAGE_ID
860
+ } = {}) {
861
+ const normalizedTargetPath = normalizeRelativePosixPath(relativeTargetPath);
862
+ if (!normalizedTargetPath) {
863
+ throw new Error("relativeTargetPath is required to render a managed mobile file.");
864
+ }
865
+
866
+ const packageRegistry = await loadPackageRegistry();
867
+ const packageEntry = packageRegistry.get(packageId);
868
+ if (!packageEntry) {
869
+ throw new Error(`Could not resolve package ${packageId} from the JSKIT package registry.`);
870
+ }
871
+ const templateRoot = await resolvePackageTemplateRoot({
872
+ packageEntry,
873
+ appRoot
874
+ });
875
+ const packageEntryForMutations =
876
+ templateRoot === packageEntry.rootDir
877
+ ? packageEntry
878
+ : {
879
+ ...packageEntry,
880
+ rootDir: templateRoot
881
+ };
882
+
883
+ const mutation = ensureArray(ensureObject(packageEntryForMutations.descriptor).mutations?.files)
884
+ .map((entry) => interpolateFileMutationRecord(ensureObject(entry), {}, packageEntryForMutations.packageId))
885
+ .find((entry) => normalizeRelativePosixPath(entry.to) === normalizedTargetPath);
886
+ if (!mutation) {
887
+ throw new Error(`Package ${packageId} does not manage ${normalizedTargetPath}.`);
888
+ }
889
+
890
+ const sourcePath = path.join(packageEntryForMutations.rootDir, mutation.from);
891
+ const targetPath = path.join(appRoot, mutation.to);
892
+ const templateContextReplacements = await resolveTemplateContextReplacementsForMutation({
893
+ packageEntry: packageEntryForMutations,
894
+ mutation,
895
+ options: {},
896
+ appRoot,
897
+ sourcePath,
898
+ targetPaths: [targetPath],
899
+ mutationContext: "files mutation"
900
+ });
901
+
902
+ return renderTemplateFile(
903
+ sourcePath,
904
+ {},
905
+ packageEntryForMutations.packageId,
906
+ `${mutation.id || mutation.to || mutation.from}.source`,
907
+ templateContextReplacements
908
+ );
909
+ }
910
+
911
+ export {
912
+ CAPACITOR_CONFIG_FILE,
913
+ ANDROID_DIRECTORY_NAME,
914
+ ANDROID_MANIFEST_RELATIVE_PATH,
915
+ buildManagedMobileConfigStub,
916
+ resolveInstalledMobileConfig,
917
+ resolveAndroidSdkDetails,
918
+ collectAndroidSdkComponentIssues,
919
+ assertAndroidSdkConfigured,
920
+ collectCapacitorShellInstallIssues,
921
+ ensureMobileConfigStub,
922
+ buildManagedDeepLinkIntentFilterBlock,
923
+ injectManagedDeepLinkBlock,
924
+ assertCapacitorShellInstalled,
925
+ ensureAndroidManifestDeepLinks,
926
+ collectAndroidNativeShellIdentityIssues,
927
+ ensureAndroidNativeShellIdentity,
928
+ renderManagedMobileFile
929
+ };