catalyst-core 1.1.0 → 1.2.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/bin/catalyst.js +54 -6
  2. package/changelog.md +27 -1
  3. package/dist/errors/registry.js +306 -18
  4. package/dist/errors-index.json +273 -70
  5. package/dist/native/androidProject/app/build.gradle.kts +53 -79
  6. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
  11. package/dist/native/androidSetup.js +76 -170
  12. package/dist/native/androidToolchain.js +642 -0
  13. package/dist/native/appIcon.js +100 -0
  14. package/dist/native/buildAndroid/assets.js +29 -5
  15. package/dist/native/buildAndroid/build.js +110 -223
  16. package/dist/native/buildAndroid/config.js +57 -65
  17. package/dist/native/buildAndroid/devices.js +176 -0
  18. package/dist/native/buildAndroid/index.js +271 -46
  19. package/dist/native/buildAndroid/output.js +43 -0
  20. package/dist/native/buildAndroid/signing.js +456 -0
  21. package/dist/native/buildAppAndroid.js +27 -7
  22. package/dist/native/buildAppIos.js +139 -20
  23. package/dist/native/buildIos/assets.js +76 -4
  24. package/dist/native/buildIos/build.js +31 -181
  25. package/dist/native/buildIos/config.js +126 -11
  26. package/dist/native/buildIos/index.js +354 -34
  27. package/dist/native/buildIos/localhostCert.js +87 -0
  28. package/dist/native/buildIos/output.js +28 -0
  29. package/dist/native/buildIos/signing.js +596 -0
  30. package/dist/native/credentials.js +309 -0
  31. package/dist/native/exportCredentials.js +51 -0
  32. package/dist/native/iosToolchain.js +583 -0
  33. package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
  34. package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
  35. package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
  36. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
  37. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
  38. package/dist/native/localState.js +148 -0
  39. package/dist/native/nativeConfig.js +621 -0
  40. package/dist/native/releaseOutput.js +53 -0
  41. package/dist/native/renameAndroidProject.js +154 -369
  42. package/dist/native/setupEmulatorIos.js +58 -275
  43. package/dist/native/setupServer.js +520 -124
  44. package/dist/native/utils.js +418 -115
  45. package/dist/vite/resolveDevServerConfig.js +21 -0
  46. package/mcp_v2/knowledge-base.json +13 -11
  47. package/mcp_v2/test/config.test.ts +40 -0
  48. package/mcp_v2/tools/build.js +80 -104
  49. package/mcp_v2/tools/config.js +181 -174
  50. package/package.json +1 -1
  51. package/test/cli/nativeFlags.test.ts +97 -0
  52. package/test/native/androidDevices.test.ts +230 -0
  53. package/test/native/androidElfAlignment.test.ts +148 -0
  54. package/test/native/androidSigning.test.ts +646 -0
  55. package/test/native/androidToolchain.test.ts +766 -0
  56. package/test/native/appIcon.test.ts +124 -0
  57. package/test/native/credentials.test.ts +241 -0
  58. package/test/native/interaction.test.ts +179 -0
  59. package/test/native/iosConfigPhase.test.ts +611 -0
  60. package/test/native/iosSigning.test.ts +1060 -0
  61. package/test/native/iosToolchain.test.ts +821 -0
  62. package/test/native/localState.test.ts +80 -0
  63. package/test/native/nativeConfig.test.ts +602 -0
  64. package/test/native/setupServer.test.ts +529 -0
  65. package/test/native/utils.test.ts +102 -127
  66. package/test/vite/resolveDevServerConfig.test.js +25 -0
  67. package/dist/native/androidProject/keystore/Untitled +0 -0
  68. package/dist/native/androidProject/keystore/release-key.jks +0 -0
  69. package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
@@ -3,19 +3,26 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.ANDROID_SDK_HINT = void 0;
6
+ exports.NATIVE_ERRORS = exports.ANDROID_SDK_HINT = void 0;
7
+ exports.askConsent = askConsent;
7
8
  exports.commandExists = commandExists;
9
+ exports.confirm = confirm;
8
10
  exports.detectAndroidSdk = detectAndroidSdk;
9
11
  exports.ensureLocalProperties = ensureLocalProperties;
10
12
  exports.expandHome = expandHome;
13
+ exports.isInteractive = isInteractive;
11
14
  exports.listAvds = listAvds;
15
+ exports.nativeError = nativeError;
16
+ exports.needsAnswerError = needsAnswerError;
17
+ exports.parseNativeArgs = parseNativeArgs;
12
18
  exports.promptUser = promptUser;
13
19
  exports.redactError = redactError;
14
20
  exports.redactSecrets = redactSecrets;
15
21
  exports.runCommand = runCommand;
22
+ exports.runFile = runFile;
23
+ exports.runInheritedCommand = runInheritedCommand;
16
24
  exports.runInteractiveCommand = runInteractiveCommand;
17
- exports.runSdkManagerCommand = runSdkManagerCommand;
18
- exports.validateAndCompleteConfig = validateAndCompleteConfig;
25
+ exports.select = select;
19
26
  var _child_process = require("child_process");
20
27
  var _readline = _interopRequireDefault(require("readline"));
21
28
  var _fs = _interopRequireDefault(require("fs"));
@@ -105,6 +112,44 @@ function runCommand(command, {
105
112
  throw error;
106
113
  }
107
114
  }
115
+
116
+ /**
117
+ * runCommand for a program and its arguments, with no shell in between. For
118
+ * tools whose path comes from the environment or config (an SDK's adb or
119
+ * emulator): a path holding quotes or `$(...)` is just a file name here.
120
+ *
121
+ * @param {string} file - the program; must be a non-empty path with no NUL or newline
122
+ * @param {string[]} args
123
+ * @param {object} [options]
124
+ * @param {number} [options.timeout] - ms before the program is killed
125
+ */
126
+ function runFile(file, args = [], {
127
+ timeout
128
+ } = {}) {
129
+ assertToolPath(file);
130
+ const display = [file, ...args].join(" ");
131
+ try {
132
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - argv array, no shell; the path is validated above.
133
+ return (0, _child_process.execFileSync)(file, args, {
134
+ encoding: "utf8",
135
+ stdio: ["ignore", "pipe", "pipe"],
136
+ timeout
137
+ });
138
+ } catch (error) {
139
+ if (timeout && isTimeout(error)) throw toolTimeoutError(_path.default.basename(file), timeout);
140
+ redactError(error, display);
141
+ console.error(`Error executing command: ${secretScrubber(display)(display)}`);
142
+ console.error(`Error message: ${error.message}`);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /** A tool path from config or the environment: a non-empty string with no NUL or line break. */
148
+ function assertToolPath(file) {
149
+ if (typeof file !== "string" || !file.trim() || /[\0\r\n]/.test(file)) {
150
+ throw new Error(`Not a valid tool path: ${JSON.stringify(file)}`);
151
+ }
152
+ }
108
153
  function commandExists(command) {
109
154
  try {
110
155
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - Utility checks known local tool names during native setup.
@@ -199,8 +244,11 @@ function detectAndroidSdk(env = process.env) {
199
244
  function listAvds(sdkPath) {
200
245
  if (!sdkPath) return [];
201
246
  try {
202
- // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process, javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - sdkPath is a local SDK directory from config or auto-detection, never remote input.
203
- const out = (0, _child_process.execSync)(`"${_path.default.join(expandHome(sdkPath), "emulator", "emulator")}" -list-avds`, {
247
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - sdkPath is a local SDK directory from config or auto-detection, never remote input.
248
+ const emulator = _path.default.join(expandHome(sdkPath), "emulator", "emulator");
249
+ assertToolPath(emulator);
250
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - argv array, no shell.
251
+ const out = (0, _child_process.execFileSync)(emulator, ["-list-avds"], {
204
252
  encoding: "utf8",
205
253
  stdio: ["ignore", "pipe", "ignore"],
206
254
  timeout: 15000
@@ -220,7 +268,7 @@ function expandHome(dir) {
220
268
  }
221
269
 
222
270
  /** The hint for a missing or wrong SDK path, naming the one field to set. */
223
- const ANDROID_SDK_HINT = exports.ANDROID_SDK_HINT = 'Install Android Studio, or set WEBVIEW_CONFIG.android.sdkPath in config/config.json (e.g. "~/Library/Android/sdk").';
271
+ const ANDROID_SDK_HINT = exports.ANDROID_SDK_HINT = 'Run catalyst setupEmulator:android to install it, or set WEBVIEW_CONFIG.android.sdkPath in config/config.json (e.g. "~/Library/Android/sdk").';
224
272
 
225
273
  /**
226
274
  * Write the SDK location gradle reads. `setupEmulator:android` used to be the
@@ -250,6 +298,8 @@ function ensureLocalProperties(projectDir, sdkPath) {
250
298
  * @param {string[]} args
251
299
  * @param {object} [promptResponses] - text to answer prompts with
252
300
  * @param {object} [options]
301
+ * @param {NodeJS.ProcessEnv} [options.env] - the child's environment (defaults to this process's)
302
+ * @param {string} [options.cwd] - the child's working directory
253
303
  * @param {(line: string) => void} [options.onLine] - called per output line.
254
304
  * Without it the output is captured silently. Gradle and xcodebuild emit
255
305
  * hundreds of lines that nobody reads on success and that are redundant on
@@ -258,14 +308,24 @@ function ensureLocalProperties(projectDir, sdkPath) {
258
308
  * than letting the raw stream scroll.
259
309
  */
260
310
  async function runInteractiveCommand(command, args, promptResponses = {}, {
261
- onLine
311
+ onLine,
312
+ env,
313
+ cwd
262
314
  } = {}) {
263
315
  return new Promise((resolve, reject) => {
264
316
  // Named `child`, not `process`: the previous name shadowed the global
265
317
  // `process` for the whole function body.
318
+ // `env` is how secrets reach a child (ORG_GRADLE_PROJECT_*): never argv,
319
+ // where ps and the failure echo would show them.
266
320
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - Native build commands are fixed internal commands passed with argv arrays.
267
321
  const child = (0, _child_process.spawn)(command, args, {
268
- stdio: ["pipe", "pipe", "pipe"]
322
+ stdio: ["pipe", "pipe", "pipe"],
323
+ ...(env ? {
324
+ env
325
+ } : {}),
326
+ ...(cwd ? {
327
+ cwd
328
+ } : {})
269
329
  });
270
330
  let promptBuffer = "";
271
331
  let outputBuffer = "";
@@ -352,128 +412,371 @@ function handlePrompts(child, buffer, promptResponses) {
352
412
  }
353
413
  return buffer;
354
414
  }
355
- async function runSdkManagerCommand(sdkManagerPath, args) {
356
- const promptResponses = {
357
- "(y/N)": "y",
358
- "Accept? (y/N):": "y"
359
- };
360
- return runInteractiveCommand(sdkManagerPath, args, promptResponses);
415
+
416
+ /**
417
+ * Run a command on the user's own terminal: inherited stdio, so they see its
418
+ * progress and can answer it (a sudo password, a licence). The spinner is
419
+ * stood down for the duration. Only for steps a person has agreed to.
420
+ *
421
+ * @param {string} command
422
+ * @param {string[]} args
423
+ * @param {object} [options]
424
+ * @param {NodeJS.ProcessEnv} [options.env]
425
+ * @param {string} [options.cwd]
426
+ */
427
+ async function runInheritedCommand(command, args, {
428
+ env,
429
+ cwd
430
+ } = {}) {
431
+ return withSpinnersPaused(() => new Promise((resolve, reject) => {
432
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - Fixed setup commands with argv arrays, run after the user agreed.
433
+ const child = (0, _child_process.spawn)(command, args, {
434
+ stdio: "inherit",
435
+ ...(env ? {
436
+ env
437
+ } : {}),
438
+ ...(cwd ? {
439
+ cwd
440
+ } : {})
441
+ });
442
+ child.on("error", error => reject(error.code === "ENOENT" ? new Error(`Command not found: ${command}`, {
443
+ cause: error
444
+ }) : error));
445
+ child.on("close", code => {
446
+ if (code === 0) resolve();else reject(Object.assign(new Error(`${command} exited with code ${code}`), {
447
+ code
448
+ }));
449
+ });
450
+ }));
361
451
  }
362
- async function validateAndCompleteConfig(platform, configPath) {
363
- // Read existing config
364
- let config;
365
- try {
366
- const configContent = _fs.default.readFileSync(configPath, "utf8");
367
- config = JSON.parse(configContent);
368
- } catch (error) {
369
- console.error(`Error reading config file: ${error.message}`);
370
- config = {};
371
- }
372
452
 
373
- // Initialize WEBVIEW_CONFIG if it doesn't exist
374
- if (!config.WEBVIEW_CONFIG) {
375
- config.WEBVIEW_CONFIG = {};
453
+ // ---------------------------------------------------------------------------
454
+ // Coded errors shared by the Android and iOS builds.
455
+ // ---------------------------------------------------------------------------
456
+
457
+ // Messages mirror errors/registry.js ERROR_DEFINITIONS (see errors/<CATEGORY>/*.md).
458
+ // This CJS subtree can't import the ESM error registry (see
459
+ // buildErrorFormat.js), so the strings are copied here; a test keeps them equal.
460
+ const NATIVE_ERRORS = exports.NATIVE_ERRORS = {
461
+ "ANDROID-005": {
462
+ message: "No upload key for the release build",
463
+ hint: "Run catalyst buildApp:android --release once in a terminal to create credentials/android/upload.jks, then back it up with catalyst credentials:export android. In CI, set the CATALYST_ANDROID_CREDENTIALS secret to the output of catalyst credentials:export android."
464
+ },
465
+ "ANDROID-006": {
466
+ message: "Upload key settings are incomplete or wrong",
467
+ hint: "Check credentials/android/keystore.properties against credentials/android/upload.jks. In CI, run catalyst credentials:export android again and update the CATALYST_ANDROID_CREDENTIALS secret."
468
+ },
469
+ "ANDROID-007": {
470
+ message: "No JDK 17 to 23 found",
471
+ hint: "Install JDK 17 or 21 (macOS: brew install --cask zulu@17; Linux: your distribution's openjdk-17-jdk package) and set JAVA_HOME, or run catalyst buildApp:android in a terminal, or with --yes, to install it."
472
+ },
473
+ "ANDROID-008": {
474
+ message: "Android SDK setup was declined",
475
+ hint: "Run the command again and answer yes, or pass --yes to allow the downloads and --accept-licenses to accept the Android SDK licences."
476
+ },
477
+ "ANDROID-009": {
478
+ message: "Could not install the Android command-line tools",
479
+ hint: "Check your network or proxy and retry, or download the command-line tools from https://developer.android.com/studio#command-tools and unzip them so that <sdk>/cmdline-tools/latest/bin/sdkmanager exists."
480
+ },
481
+ "ANDROID-010": {
482
+ message: "App icon is not usable",
483
+ hint: "Replace public/icon.png with a square PNG of at least 1024x1024 pixels, or add per-density icons in public/android/appIcons."
484
+ },
485
+ "ANDROID-011": {
486
+ message: "No Android phone connected",
487
+ hint: "Connect the phone, turn on USB debugging in Developer options, accept the Allow USB debugging prompt on the phone and check that adb devices lists it. Or drop --device to use the emulator."
488
+ },
489
+ "IOS-002": {
490
+ message: "Full Xcode is not installed or selected",
491
+ hint: "Install Xcode from the Mac App Store, then run sudo xcode-select -s /Applications/Xcode.app/Contents/Developer."
492
+ },
493
+ "IOS-003": {
494
+ message: "Xcode setup is not finished",
495
+ hint: "Run sudo xcodebuild -license accept and sudo xcodebuild -runFirstLaunch, then retry."
496
+ },
497
+ "IOS-004": {
498
+ message: "No iOS simulator runtime installed",
499
+ hint: "Run catalyst setupEmulator:ios in a terminal or with --yes, or run xcodebuild -downloadPlatform iOS yourself."
500
+ },
501
+ "IOS-005": {
502
+ message: "App icon is not usable",
503
+ hint: "Replace public/icon.png with a square PNG of at least 1024x1024 pixels, or add the sized icons in public/ios/appIcons."
504
+ },
505
+ "IOS-006": {
506
+ message: "App icon has transparency",
507
+ hint: "Export public/icon.png without an alpha channel (flatten it onto a solid background), then rebuild."
508
+ },
509
+ "IOS-010": {
510
+ message: "iOS code signing is not set up",
511
+ hint: "For cloud signing, put an App Store Connect API key (its AuthKey_*.p8 file, with an ascIssuerId line in signing.properties) in credentials/ios/ and set WEBVIEW_CONFIG.ios.developmentTeam; for local signing, put a distribution .p12 and its .mobileprovision there instead. In CI, set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios."
512
+ },
513
+ "IOS-011": {
514
+ message: "App Store Connect API key settings are incomplete or wrong",
515
+ hint: "Put the AuthKey_*.p8 file in credentials/ios/, and an ascIssuerId line (plus ascKeyId when the file is renamed) in credentials/ios/signing.properties. In CI, run catalyst credentials:export ios again and paste the value into CATALYST_IOS_CREDENTIALS."
516
+ },
517
+ "IOS-012": {
518
+ message: "Manual signing inputs are incomplete or wrong",
519
+ hint: "Keep one distribution .p12 and the .mobileprovision for WEBVIEW_CONFIG.appId in credentials/ios/, with a p12Password line in credentials/ios/signing.properties, and check that they belong together. In CI, run catalyst credentials:export ios again and paste the value into CATALYST_IOS_CREDENTIALS."
520
+ },
521
+ "IOS-013": {
522
+ message: "Unknown iOS export method",
523
+ hint: "Set WEBVIEW_CONFIG.ios.exportMethod to app-store-connect (the default), release-testing, enterprise or debugging."
524
+ },
525
+ "IOS-014": {
526
+ message: "No connected iPhone found",
527
+ hint: "Connect the iPhone with a cable, unlock it, accept Trust This Computer and turn on Developer Mode (Settings > Privacy & Security), then retry; or leave out --device to use the simulator."
528
+ },
529
+ "IOS-015": {
530
+ message: "No address the iPhone can reach this Mac on",
531
+ hint: "Join the Mac and the iPhone to the same Wi-Fi network and check the Local Hostname in System Settings > General > Sharing, then retry."
532
+ },
533
+ "NATIVE-001": {
534
+ message: "Release build points at a local or private host",
535
+ hint: "Set WEBVIEW_CONFIG.url to your production origin (e.g. https://shop.acme.com) or pass --url, or set WEBVIEW_CONFIG.allowPrivateHost to true for an internal app on a private network."
536
+ },
537
+ "NATIVE-002": {
538
+ message: "Invalid build number",
539
+ hint: "Unset CATALYST_BUILD_NUMBER to use the automatic build number, or set it to a whole number from 1 to 2100000000 that is higher than the last one you uploaded."
540
+ },
541
+ "NATIVE-003": {
542
+ message: "Release build uses a placeholder app id",
543
+ hint: "Set WEBVIEW_CONFIG.appId in config/config.json to your own reverse-DNS id, e.g. com.acme.shop."
544
+ },
545
+ "NATIVE-004": {
546
+ message: "Invalid production URL",
547
+ hint: "Use an origin such as https://shop.acme.com, without a path or a port other than 443."
548
+ },
549
+ "NATIVE-005": {
550
+ message: "A step needs your answer and there is no terminal to ask on",
551
+ hint: "Pass the flag named in the message, or run the command in an interactive terminal."
552
+ },
553
+ "NATIVE-006": {
554
+ message: "The dev server did not answer",
555
+ hint: "Run npm start on its own to see why the server does not start, then run the build again."
556
+ },
557
+ "NATIVE-007": {
558
+ message: "The credentials secret could not be unpacked",
559
+ hint: "Run catalyst credentials:export <android|ios> where credentials/<platform>/ exists and paste the whole value into the secret again."
560
+ },
561
+ "NATIVE-008": {
562
+ message: "No credentials to export",
563
+ hint: "Run the command from the app root after a release build has created credentials/<platform>/."
564
+ },
565
+ "NATIVE-009": {
566
+ message: "Credentials are too large for one secret",
567
+ hint: "Remove files the build does not need from credentials/<platform>/ (only signing keys, profiles and their settings belong there), then export again."
568
+ },
569
+ "NATIVE-010": {
570
+ message: "Invalid app id",
571
+ hint: "Set WEBVIEW_CONFIG.appId in config/config.json to a reverse-DNS id such as com.acme.shop: letters and digits only, each part starting with a letter."
572
+ },
573
+ "NATIVE-011": {
574
+ message: "A release with this version and build number already exists",
575
+ hint: "Wait a minute and run the release again, or set CATALYST_BUILD_NUMBER to a higher number. To rebuild the same build on purpose, delete that folder first."
376
576
  }
577
+ };
578
+ const ERROR_DOC_BASE = "https://github.com/tata1mg/catalyst-core/blob/main/errors";
377
579
 
378
- // Check if WEBVIEW_CONFIG already has all required fields
379
- const webviewConfig = config.WEBVIEW_CONFIG;
380
- const commonFields = {
381
- port: "Port the app is served on"
580
+ /**
581
+ * A coded error the build diagnostic prints with its code, hint and doc link.
582
+ *
583
+ * @param {string} code
584
+ * @param {string} [detail]
585
+ * @param {{ hint?: string }} [options]
586
+ * @returns {Error & { code: string, hint?: string, docUrl: string }}
587
+ */
588
+ function nativeError(code, detail, {
589
+ hint
590
+ } = {}) {
591
+ const def = NATIVE_ERRORS[code] || {
592
+ message: "Native build failed"
382
593
  };
594
+ /** @type {any} */
595
+ const error = new Error(detail ? `${def.message}: ${detail}` : def.message);
596
+ error.code = code;
597
+ error.hint = hint || def.hint;
598
+ error.docUrl = `${ERROR_DOC_BASE}/${code.replace(/-\d{3}$/, "")}/${code}.md`;
599
+ return error;
600
+ }
383
601
 
384
- // cachePattern is optional -- the iOS build emits an empty pattern list
385
- // when it is absent and Android never required it -- so asking for it on
386
- // every first setup only stalled people on a question they could not answer.
387
- const platformConfigs = {
388
- android: {
389
- buildType: "Android build type (debug/release)",
390
- sdkPath: "Android SDK path",
391
- emulatorName: "Android emulator (AVD) name"
392
- },
393
- ios: {
394
- buildType: "iOS build type (debug/release)",
395
- appBundleId: "iOS bundle ID (e.g. com.example.app)",
396
- simulatorName: "iOS simulator name (e.g. iPhone 16 Pro)"
397
- }
398
- };
602
+ // ---------------------------------------------------------------------------
603
+ // Flags and questions. Agents and CI never get a prompt: without a terminal,
604
+ // a question that needs an answer fails at once with NATIVE-005 naming the
605
+ // flag that answers it.
606
+ // ---------------------------------------------------------------------------
399
607
 
400
- // Answers we can work out are filled in rather than asked; the rest are
401
- // asked with the best guess offered as the default.
402
- const sdkGuess = platform === "android" ? webviewConfig.android?.sdkPath || detectAndroidSdk() : null;
403
- const defaults = {
404
- port: config.NODE_SERVER_PORT ? String(config.NODE_SERVER_PORT) : undefined,
405
- buildType: "debug",
406
- sdkPath: sdkGuess || undefined,
407
- emulatorName: platform === "android" ? listAvds(sdkGuess)[0] : undefined
608
+ /**
609
+ * The native flags the CLI forwards (bin/catalyst.js keeps them out of
610
+ * command dispatch; this reads them back in the platform script):
611
+ * `--release` (signed store build), `--device` (the connected phone instead
612
+ * of the emulator/simulator), `--url <u>` / `--url=<u>` (the release's
613
+ * production URL, over WEBVIEW_CONFIG.url), `--yes` (confirms installs and
614
+ * downloads, never licences or key creation) and `--accept-licenses`.
615
+ *
616
+ * @param {string[]} [argv]
617
+ * @returns {{ release: boolean, device: boolean, url: string | undefined, yes: boolean, acceptLicenses: boolean }}
618
+ */
619
+ function parseNativeArgs(argv = process.argv.slice(2)) {
620
+ const result = {
621
+ release: false,
622
+ device: false,
623
+ url: undefined,
624
+ yes: false,
625
+ acceptLicenses: false
408
626
  };
409
- const filled = [];
410
- if (!platformConfigs[platform]) {
411
- throw new Error('Invalid platform. Must be either "android" or "ios"');
627
+ for (let i = 0; i < argv.length; i++) {
628
+ const arg = argv[i];
629
+ if (arg === "--release") result.release = true;else if (arg === "--device") result.device = true;else if (arg === "--yes") result.yes = true;else if (arg === "--accept-licenses") result.acceptLicenses = true;else if (arg === "--url" || arg.startsWith("--url=")) {
630
+ const value = arg === "--url" ? argv[++i] : arg.slice("--url=".length);
631
+ if (!value || value.startsWith("--")) {
632
+ throw nativeError("NATIVE-004", "--url needs a value, e.g. --url https://shop.acme.com");
633
+ }
634
+ result.url = value;
635
+ }
412
636
  }
637
+ return result;
638
+ }
413
639
 
414
- // Check if all required fields are present
415
- let hasAllFields = true;
640
+ /**
641
+ * True when a person can answer a question here: stdin and stdout are both
642
+ * terminals and `CI` is not set.
643
+ *
644
+ * @param {object} [options]
645
+ * @param {{ isTTY?: boolean }} [options.stdin]
646
+ * @param {{ isTTY?: boolean }} [options.stdout]
647
+ * @param {Record<string, string | undefined>} [options.env]
648
+ */
649
+ function isInteractive({
650
+ stdin = process.stdin,
651
+ stdout = process.stdout,
652
+ env = process.env
653
+ } = {}) {
654
+ const ci = env.CI !== undefined && env.CI !== "" && env.CI !== "0" && env.CI.toLowerCase() !== "false";
655
+ return Boolean(stdin?.isTTY && stdout?.isTTY && !ci);
656
+ }
416
657
 
417
- // Check common fields
418
- for (const key of Object.keys(commonFields)) {
419
- if (!webviewConfig[key]) {
420
- hasAllFields = false;
421
- break;
422
- }
423
- }
658
+ /**
659
+ * The error for a question that needs an answer when there is no terminal.
660
+ *
661
+ * @param {string} question - what would have been asked
662
+ * @param {object} [options]
663
+ * @param {string | null} [options.flag] - the flag that answers it; null when only a person can (key creation)
664
+ * @param {string} [options.hint] - replaces the generated hint
665
+ */
666
+ function needsAnswerError(question, {
667
+ flag = null,
668
+ hint
669
+ } = {}) {
670
+ const label = String(question).replace(/[?:\s]+$/, "");
671
+ return nativeError("NATIVE-005", flag ? `${label} (pass ${flag})` : label, {
672
+ hint: hint || (flag ? `Pass ${flag} to answer yes without a terminal, or run the command in an interactive terminal.` : "Run the command once in an interactive terminal. No flag answers this, not even --yes.")
673
+ });
674
+ }
424
675
 
425
- // Check platform-specific fields
426
- if (!webviewConfig[platform]) {
427
- webviewConfig[platform] = {};
428
- hasAllFields = false;
429
- } else {
430
- for (const key of Object.keys(platformConfigs[platform])) {
431
- if (!webviewConfig[platform][key]) {
432
- hasAllFields = false;
433
- break;
434
- }
435
- }
436
- }
676
+ /**
677
+ * A yes/no question. Enter takes the default. Without a terminal it returns
678
+ * false, so callers decide (usually needsAnswerError naming the flag).
679
+ */
680
+ async function confirm(question, {
681
+ defaultYes = true,
682
+ prompt = promptUser,
683
+ isTTY
684
+ } = {}) {
685
+ if (!(isTTY ?? isInteractive())) return false;
686
+ const answer = await prompt(`${question.replace(/[?\s]+$/, "")}? ${defaultYes ? "[Y/n]" : "[y/N]"}`, {
687
+ defaultValue: defaultYes ? "y" : "n"
688
+ });
689
+ return /^y(es)?$/i.test(String(answer || "").trim());
690
+ }
437
691
 
438
- // If all fields are present, return the platform-specific config
439
- if (hasAllFields) {
440
- return {
441
- port: webviewConfig.port,
442
- [platform]: webviewConfig[platform]
443
- };
444
- }
692
+ // What each kind of consent accepts in place of a typed yes.
693
+ const CONSENT_FLAGS = {
694
+ install: "--yes",
695
+ licence: "--accept-licenses",
696
+ key: null
697
+ };
445
698
 
446
- // Fields with a sure answer (detected or conventional) are filled without
447
- // asking; only genuinely unknown ones reach the prompt.
448
- const autoFill = new Set(["port", "buildType", "sdkPath"]);
449
- const fill = async (target, key, prompt) => {
450
- if (target[key]) return;
451
- const guess = defaults[key];
452
- target[key] = guess && autoFill.has(key) ? guess : await promptUser(prompt, {
453
- defaultValue: guess
699
+ /**
700
+ * Ask for consent to a step, following the rules for every native command:
701
+ * - "install": an install or download. `--yes` answers it.
702
+ * - "licence": accepting a licence. Only `--accept-licenses` answers it.
703
+ * - "key": creating a signing key. Only a person at a terminal answers it.
704
+ *
705
+ * Returns true when allowed, false when the person said no. Without a
706
+ * terminal and without the answering flag it throws NATIVE-005.
707
+ *
708
+ * @param {"install" | "licence" | "key"} kind
709
+ * @param {string} question
710
+ * @param {object} [options]
711
+ * @param {{ yes?: boolean, acceptLicenses?: boolean }} [options.flags] - from parseNativeArgs
712
+ * @param {boolean} [options.defaultYes] - Enter's answer; defaults to true, false for "key"
713
+ * @param {boolean} [options.isTTY] - defaults to isInteractive()
714
+ * @param {typeof promptUser} [options.prompt]
715
+ * @returns {Promise<boolean>}
716
+ */
717
+ async function askConsent(kind, question, {
718
+ flags = {},
719
+ defaultYes,
720
+ isTTY,
721
+ prompt = promptUser
722
+ } = {}) {
723
+ if (!(kind in CONSENT_FLAGS)) throw new Error(`Unknown consent kind "${kind}"`);
724
+ if (kind === "install" && flags.yes) return true;
725
+ if (kind === "licence" && flags.acceptLicenses) return true;
726
+ const interactive = isTTY ?? isInteractive();
727
+ if (!interactive) throw needsAnswerError(question, {
728
+ flag: CONSENT_FLAGS[kind]
729
+ });
730
+ return confirm(question, {
731
+ defaultYes: defaultYes ?? kind !== "key",
732
+ prompt,
733
+ isTTY: true
734
+ });
735
+ }
736
+
737
+ /**
738
+ * Pick one of several choices on the terminal (a numbered list; Enter takes
739
+ * the default). Without a terminal it returns the default, or throws
740
+ * NATIVE-005 naming `flag` when there is none.
741
+ *
742
+ * @template T
743
+ * @param {string} question
744
+ * @param {{ label: string, value: T, hint?: string }[]} choices
745
+ * @param {object} [options]
746
+ * @param {T} [options.defaultValue]
747
+ * @param {string} [options.flag] - the flag that makes this choice without a terminal
748
+ * @param {boolean} [options.isTTY]
749
+ * @param {typeof promptUser} [options.prompt]
750
+ * @param {(text: string) => void} [options.write]
751
+ * @returns {Promise<T>}
752
+ */
753
+ async function select(question, choices, {
754
+ defaultValue,
755
+ flag,
756
+ isTTY,
757
+ prompt = promptUser,
758
+ write = text => process.stdout.write(text)
759
+ } = {}) {
760
+ if (!choices.length) throw new Error("select() needs at least one choice");
761
+ const defaultIndex = Math.max(0, choices.findIndex(choice => choice.value === defaultValue));
762
+ if (!(isTTY ?? isInteractive())) {
763
+ if (defaultValue !== undefined) return defaultValue;
764
+ throw needsAnswerError(question, {
765
+ flag: flag || null
454
766
  });
455
- filled.push(key);
456
- };
457
- for (const [key, prompt] of Object.entries(commonFields)) await fill(webviewConfig, key, prompt);
458
- for (const [key, prompt] of Object.entries(platformConfigs[platform])) {
459
- await fill(webviewConfig[platform], key, prompt);
460
767
  }
461
-
462
- // Save updated config back to file
463
- try {
464
- _fs.default.writeFileSync(configPath, JSON.stringify(config, null, 2));
465
- if (filled.length) {
466
- // Under a live spinner a plain log lands on the spinner's own row.
467
- await withSpinnersPaused(async () => console.log(`${GUTTER}${t.accent("›")} Saved ${filled.join(", ")} to ${t.accent("config/config.json")}`));
768
+ return withSpinnersPaused(async () => {
769
+ write(`${GUTTER}${t.warn("?")} ${String(question).replace(/[?:\s]+$/, "")}\n`);
770
+ choices.forEach((choice, index) => {
771
+ const hint = choice.hint ? t.dim(` ${choice.hint}`) : "";
772
+ write(`${GUTTER} ${index + 1}. ${choice.label}${hint}\n`);
773
+ });
774
+ for (;;) {
775
+ const answer = await prompt(`Choose 1-${choices.length}`, {
776
+ defaultValue: String(defaultIndex + 1)
777
+ });
778
+ const index = Number(String(answer).trim()) - 1;
779
+ if (Number.isInteger(index) && index >= 0 && index < choices.length) return choices[index].value;
468
780
  }
469
- } catch (error) {
470
- console.error(`Error saving config file: ${error.message}`);
471
- throw error;
472
- }
473
-
474
- // Return only the platform-specific config
475
- return {
476
- port: webviewConfig.port,
477
- [platform]: webviewConfig[platform]
478
- };
781
+ });
479
782
  }
@@ -33,6 +33,22 @@ export function resolveDevFsAllow(devFs, frameworkPaths) {
33
33
  return [...frameworkPaths, ...extra]
34
34
  }
35
35
 
36
+ /**
37
+ * `server.allowedHosts` plus the comma-separated hosts in
38
+ * CATALYST_DEV_ALLOWED_HOSTS. `catalyst buildApp:ios` sets it to the Mac's
39
+ * `<name>.local` when a physical iPhone loads the dev server by that name:
40
+ * Vite answers 403 to any Host header that is not localhost or an IP.
41
+ */
42
+ export function withExtraAllowedHosts(allowedHosts, extra) {
43
+ const hosts = String(extra || "")
44
+ .split(",")
45
+ .map((host) => host.trim())
46
+ .filter(Boolean)
47
+ if (allowedHosts === true || hosts.length === 0) return allowedHosts
48
+ const base = Array.isArray(allowedHosts) ? allowedHosts : []
49
+ return [...base, ...hosts.filter((host) => !base.includes(host))]
50
+ }
51
+
36
52
  /**
37
53
  * Vite `server` block from `buildConfig.devServer`. Passthrough keys are spread
38
54
  * first so the framework-owned `hmr` and `fs` always win.
@@ -47,8 +63,13 @@ export function buildDevServer(devServer = {}, { frameworkPaths, isProduction =
47
63
  // module) opt out. isProduction is captured at module load, so it cannot
48
64
  // be flipped by setting NODE_ENV just before createServer().
49
65
  const hmrDisabled = isProduction || process.env.CATALYST_NO_HMR === "true"
66
+ const allowedHosts = withExtraAllowedHosts(
67
+ serverOverrides.allowedHosts,
68
+ process.env.CATALYST_DEV_ALLOWED_HOSTS
69
+ )
50
70
  return {
51
71
  ...serverOverrides,
72
+ ...(allowedHosts === undefined ? {} : { allowedHosts }),
52
73
  hmr: hmrDisabled ? false : (hmr ?? true),
53
74
  fs: { allow: resolveDevFsAllow(fs, frameworkPaths) },
54
75
  }