catalyst-core 1.0.1 → 1.0.2-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 (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. package/dist/scripts/cleanCache.js +0 -30
@@ -10,29 +10,25 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
10
10
  const catalystCorePath = _path.default.dirname(require.resolve("catalyst-core/package.json"));
11
11
  const pwd = _path.default.join(catalystCorePath, "dist/native");
12
12
  const configPath = `${process.env.PWD}/config/config.json`;
13
+
14
+ // Sentence case, verb first: these read as a sequence of actions, not as
15
+ // headings.
13
16
  const steps = {
14
- java: "Check Java Environment",
15
- config: "Initialize Configuration",
16
- androidTools: "Validate Android Tools",
17
- emulator: "Configure Android Emulator",
18
- properties: "Update Local Properties",
19
- saveConfig: "Save Configuration",
20
- setupServer: "Setup Server"
21
- };
22
- const progressPaddingConfig = {
23
- titlePaddingTop: 2,
24
- titlePaddingBottom: 1,
25
- stepPaddingLeft: 4,
26
- stepSpacing: 1,
27
- errorPaddingLeft: 6,
28
- bottomMargin: 2
17
+ java: "Check Java environment",
18
+ config: "Initialize configuration",
19
+ androidTools: "Validate Android tools",
20
+ emulator: "Configure Android emulator",
21
+ properties: "Update local.properties",
22
+ saveConfig: "Save configuration",
23
+ setupServer: "Set up server"
29
24
  };
30
- const progress = new _terminalProgress.default(steps, "Catalyst Universal Android Setup", progressPaddingConfig);
25
+ const progress = new _terminalProgress.default(steps, "catalyst setupEmulator", {
26
+ subject: "android"
27
+ });
31
28
  async function checkJavaInstallation() {
32
29
  try {
33
30
  (0, _utils.runCommand)("java -version");
34
31
  (0, _utils.runCommand)("javac -version");
35
- progress.log("Java is installed and configured", "success");
36
32
  return true;
37
33
  } catch (error) {
38
34
  progress.log("Java installation not found or not properly configured", "error");
@@ -87,23 +83,23 @@ async function installJava() {
87
83
  // Verify Java configuration
88
84
  try {
89
85
  const javaVersion = (0, _utils.runCommand)("java -version");
90
- progress.log("Java installed and configured successfully", "success");
86
+ progress.log("Installed Java 17 (Zulu)", "info");
91
87
  progress.log(`Java Version: ${javaVersion}`, "info");
92
88
  progress.log(`IMPORTANT: Please run the following commands to complete setup:`, "warning");
93
- progress.log(`1. source ${shellProfile}`, "info");
94
- progress.log(`2. echo $JAVA_HOME`, "info");
89
+ progress.log(`1. source ${shellProfile}`, "note");
90
+ progress.log(`2. echo $JAVA_HOME`, "note");
95
91
  return true;
96
92
  } catch (error) {
97
93
  throw new Error(`Java installation verified but environment not configured. Please run:\n1. source ${shellProfile}\n2. echo $JAVA_HOME`);
98
94
  }
99
95
  } catch (error) {
100
96
  progress.log(error.message, "error");
101
- progress.log("Manual Setup Instructions:", "info");
102
- progress.log("1. Run: brew reinstall --cask zulu@17", "info");
103
- progress.log("2. Add to ~/.zshrc:", "info");
104
- progress.log(" export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home", "info");
105
- progress.log(" export PATH=$JAVA_HOME/bin:$PATH", "info");
106
- progress.log("3. Run: source ~/.zshrc", "info");
97
+ progress.log("Manual Setup Instructions:", "note");
98
+ progress.log("1. Run: brew reinstall --cask zulu@17", "note");
99
+ progress.log("2. Add to ~/.zshrc:", "note");
100
+ progress.log(" export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home", "note");
101
+ progress.log(" export PATH=$JAVA_HOME/bin:$PATH", "note");
102
+ progress.log("3. Run: source ~/.zshrc", "note");
107
103
  throw error;
108
104
  }
109
105
  }
@@ -149,18 +145,19 @@ async function saveConfig(newConfig) {
149
145
  }
150
146
  };
151
147
  _fs.default.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
152
- progress.log("Configuration saved successfully", "success");
153
148
  } catch (error) {
154
149
  progress.log("Failed to save configuration: " + error.message, "error");
155
150
  process.exit(1);
156
151
  }
157
152
  }
158
153
  function validateAndroidTools(androidConfig) {
159
- const ANDROID_SDK = androidConfig.sdkPath;
154
+ const ANDROID_SDK = (0, _utils.expandHome)(androidConfig.sdkPath);
160
155
  const EMULATOR_PATH = `${ANDROID_SDK}/emulator/emulator`;
161
156
  const ADB_PATH = `${ANDROID_SDK}/platform-tools/adb`;
162
157
  if (!ANDROID_SDK) {
163
- throw new Error("Android SDK path is not configured");
158
+ throw Object.assign(new Error("Android SDK not found"), {
159
+ hint: _utils.ANDROID_SDK_HINT
160
+ });
164
161
  }
165
162
  if (!_fs.default.existsSync(ANDROID_SDK)) {
166
163
  throw new Error(`Android SDK path does not exist: ${ANDROID_SDK}`);
@@ -170,7 +167,6 @@ function validateAndroidTools(androidConfig) {
170
167
  }
171
168
  try {
172
169
  (0, _utils.runCommand)(`${ADB_PATH} version`);
173
- progress.log("ADB is valid", "success");
174
170
  } catch (error) {
175
171
  throw new Error(`ADB is not working properly: ${error.message}`);
176
172
  }
@@ -179,7 +175,6 @@ function validateAndroidTools(androidConfig) {
179
175
  }
180
176
  try {
181
177
  (0, _utils.runCommand)(`${EMULATOR_PATH} -version`);
182
- progress.log("Emulator is valid", "success");
183
178
  } catch (error) {
184
179
  throw new Error(`Emulator is not working properly: ${error.message}`);
185
180
  }
@@ -188,11 +183,10 @@ function validateAndroidTools(androidConfig) {
188
183
  if (!avdOutput.includes(androidConfig.emulatorName)) {
189
184
  throw new Error(`Specified emulator "${androidConfig.emulatorName}" not found in available AVDs`);
190
185
  }
191
- progress.log(`Emulator "${androidConfig.emulatorName}" exists`, "success");
186
+ progress.log(`Using emulator "${androidConfig.emulatorName}"`, "info");
192
187
  } catch (error) {
193
188
  throw new Error(`Error checking emulator AVD: ${error.message}`);
194
189
  }
195
- progress.log("Android tools validation completed successfully!", "success");
196
190
  return {
197
191
  EMULATOR_PATH,
198
192
  ADB_PATH
@@ -200,7 +194,9 @@ function validateAndroidTools(androidConfig) {
200
194
  }
201
195
  async function checkEmulator(ADB_PATH) {
202
196
  try {
203
- const devices = (0, _utils.runCommand)(`${ADB_PATH} devices`);
197
+ const devices = (0, _utils.runCommand)(`${ADB_PATH} devices`, {
198
+ timeout: 15000
199
+ });
204
200
  return devices.includes("emulator");
205
201
  } catch (error) {
206
202
  progress.log(`Error checking emulator: ${error}`, "error");
@@ -223,7 +219,6 @@ async function updateLocalProperties(sdkPath) {
223
219
  progress.start("properties");
224
220
  try {
225
221
  (0, _utils.runCommand)(`cd ${pwd}/androidProject && ./gradlew updateSdkPath -PsdkPath="${sdkPath}"`);
226
- progress.log("Updated local.properties successfully", "success");
227
222
  progress.complete("properties");
228
223
  } catch (error) {
229
224
  progress.fail("properties", `Failed to update local.properties: ${error.message}`);
@@ -249,11 +244,11 @@ async function setupAndroidEnvironment() {
249
244
  progress.log("No emulator running, attempting to start one...");
250
245
  await startEmulator(EMULATOR_PATH, config.android);
251
246
  } else {
252
- progress.log("Emulator already running, proceeding with installation...", "success");
247
+ progress.log("Emulator already running", "info");
253
248
  }
254
249
  progress.complete("emulator");
255
250
  if (config.android?.sdkPath) {
256
- await updateLocalProperties(config.android.sdkPath);
251
+ await updateLocalProperties((0, _utils.expandHome)(config.android.sdkPath));
257
252
  }
258
253
  progress.start("saveConfig");
259
254
  await saveConfig({
@@ -261,40 +256,34 @@ async function setupAndroidEnvironment() {
261
256
  });
262
257
  progress.complete("saveConfig");
263
258
  progress.start("setupServer");
264
- await (0, _setupServer.setupServer)(configPath);
259
+ await (0, _setupServer.setupServer)(configPath, message => progress.log(message, "info"));
265
260
  progress.complete("setupServer");
266
- progress.printTreeContent("Configuration Explanation", ["WEBVIEW_CONFIG: Main configuration object for the WebView setup", {
267
- text: "port: Port number for the WebView server",
268
- indent: 1,
269
- prefix: "├─ ",
270
- color: "gray"
271
- }, {
272
- text: "android: Android-specific configuration",
273
- indent: 1,
274
- prefix: "└─ ",
261
+
262
+ // What was configured, not what the keys mean -- the explanation
263
+ // belongs in the docs, not in every successful run's transcript.
264
+ // validateAndCompleteConfig returns { port, android: {...} } -- flat,
265
+ // not nested under WEBVIEW_CONFIG.
266
+ const androidCfg = config.android || {};
267
+ progress.printTreeContent("Config config/config.json", [{
268
+ text: `port ${config.port ?? ""}`,
275
269
  color: "gray"
276
270
  }, {
277
- text: "buildType: Build type (debug/release)",
278
- indent: 2,
279
- prefix: "├─ ",
271
+ text: `buildType ${androidCfg.buildType ?? ""}`,
280
272
  color: "gray"
281
273
  }, {
282
- text: "sdkPath: Android SDK path",
283
- indent: 2,
284
- prefix: "├─ ",
274
+ text: `sdkPath ${androidCfg.sdkPath ?? ""}`,
285
275
  color: "gray"
286
276
  }, {
287
- text: "emulatorName: Selected Android emulator name",
288
- indent: 2,
289
- prefix: "└─ ",
277
+ text: `emulatorName ${androidCfg.emulatorName ?? ""}`,
290
278
  color: "gray"
291
279
  }]);
292
- progress.printTreeContent("Final Configuration", [JSON.stringify(config, null, 2)]);
280
+ progress.summary("Ready", "Run catalyst buildApp:android to install the app");
293
281
  process.exit(0);
294
282
  } catch (error) {
295
283
  if (progress.currentStep) {
296
284
  progress.fail(progress.currentStep.id, error.message);
297
285
  }
286
+ progress.hint(error.hint);
298
287
  process.exit(1);
299
288
  }
300
289
  }
@@ -93,22 +93,36 @@ function createAssetsPhase(ctx) {
93
93
  progress.log(`Error processing splash screen: ${error.message}`, "warning");
94
94
  }
95
95
  }
96
- async function copyOfflinePage() {
96
+
97
+ // Copies public/<fileName> to assets/<destSubdir>/<fileName>. Optional files are
98
+ // removed from assets when absent so deleting the source turns the feature off.
99
+ async function copyPublicHtml(fileName, destSubdir, optional = false) {
97
100
  try {
98
- const sourcePath = `${process.cwd()}/public/offline.html`;
99
- const destDir = `${pwd}/androidProject/app/src/main/assets/offline`;
100
- const destPath = `${destDir}/offline.html`;
101
+ const sourcePath = `${process.cwd()}/public/${fileName}`;
102
+ const destDir = `${pwd}/androidProject/app/src/main/assets/${destSubdir}`;
103
+ const destPath = `${destDir}/${fileName}`;
101
104
  if (!fs.existsSync(sourcePath)) {
102
- progress.log("offline.html not found in public/; skipping offline asset copy", "warning");
105
+ if (optional) {
106
+ fs.rmSync(destPath, {
107
+ force: true
108
+ });
109
+ return;
110
+ }
111
+ progress.log(`${fileName} not found in public/; skipping ${destSubdir} asset copy`, "warning");
103
112
  return;
104
113
  }
105
114
  ctx.runCommand(`mkdir -p ${destDir}`);
106
115
  fs.copyFileSync(sourcePath, destPath);
107
- progress.log("offline.html copied to Android assets", "success");
108
116
  } catch (error) {
109
- progress.log(`Warning: Error copying offline.html: ${error.message}`, "warning");
117
+ progress.log(`Warning: Error copying ${fileName}: ${error.message}`, "warning");
110
118
  }
111
119
  }
120
+ async function copyOfflinePage() {
121
+ await copyPublicHtml("offline.html", "offline");
122
+ }
123
+ async function copySplashHtml() {
124
+ await copyPublicHtml("splashscreen.html", "splash", true);
125
+ }
112
126
  async function copyIconAssets() {
113
127
  try {
114
128
  const destPath = `${pwd}/androidProject/app/src/main/res`;
@@ -202,9 +216,6 @@ function createAssetsPhase(ctx) {
202
216
  if (hasCustomIcons) {
203
217
  progress.log("Applied Android launcher icons from public/android/appIcons.", "success");
204
218
  }
205
- if (usedFallback) {
206
- progress.log("Used bundled Catalyst fallback icon for launcher.", "info");
207
- }
208
219
  } catch (error) {
209
220
  progress.log(`Warning: Error copying app icon assets: ${error.message}`, "warning");
210
221
  }
@@ -243,7 +254,6 @@ function createAssetsPhase(ctx) {
243
254
  });
244
255
  }
245
256
  fs.copyFileSync(rootGoogleServicesPath, androidGoogleServicesPath);
246
- progress.log("Copied google-services.json to androidProject/app/", "success");
247
257
  return true;
248
258
  } else if (fs.existsSync(androidGoogleServicesPath)) {
249
259
  progress.log("google-services.json already exists in androidProject/app/", "info");
@@ -267,7 +277,6 @@ function createAssetsPhase(ctx) {
267
277
  await cleanupNotificationMetadata();
268
278
  await cleanupNotificationAssets();
269
279
  if (!hasNotificationConfig) {
270
- progress.log("Notifications disabled - cleaned up notification configurations", "info");
271
280
  return;
272
281
  }
273
282
  const hasGoogleServices = await handleGoogleServicesJson();
@@ -288,7 +297,6 @@ function createAssetsPhase(ctx) {
288
297
  const propertiesPath = `${pwd}/androidProject/app/catalyst-build.properties`;
289
298
  const propertiesContent = ["# Generated by Catalyst. Used by Android Studio builds when -PconfigPath is not provided.", `notifications.enabled=${notificationsEnabled ? "true" : "false"}`, ""].join("\n");
290
299
  fs.writeFileSync(propertiesPath, propertiesContent);
291
- progress.log("Updated generated Android build properties", "success");
292
300
  } catch (error) {
293
301
  throw new Error(`Failed to write generated Android build properties: ${error.message}`);
294
302
  }
@@ -541,6 +549,7 @@ function createAssetsPhase(ctx) {
541
549
  copyBuildAssets,
542
550
  copySplashscreenAssets,
543
551
  copyOfflinePage,
552
+ copySplashHtml,
544
553
  copyIconAssets,
545
554
  configureAppName,
546
555
  processNotifications
@@ -1,14 +1,25 @@
1
1
  "use strict";
2
2
 
3
- const fs = require("fs");
4
- const path = require("path");
5
3
  const {
6
4
  spawn,
7
5
  execFileSync
8
6
  } = require("child_process");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
9
  const {
10
10
  buildAndroidAAB
11
11
  } = require("../renameAndroidProject.js");
12
+ const {
13
+ ensureLocalProperties
14
+ } = require("../utils.js");
15
+
16
+ // adb answers in milliseconds when healthy. A wedged adb server accepts the
17
+ // connection and never replies, so without a bound the build sat on
18
+ // "detecting physical devices" forever.
19
+ const ADB_TIMEOUT_MS = 15000;
20
+
21
+ /** A timed-out device tool is fatal: every later adb call would hang the same way. */
22
+ const isToolTimeout = error => error?.code === "ETOOLTIMEOUT";
12
23
  const DEFAULT_DEPLOYMENT_PATH = "./deployment";
13
24
  const DEFAULT_OLD_PROJECT_NAME = "androidProject";
14
25
  const DEFAULT_OVERWRITE_EXISTING = true;
@@ -21,9 +32,11 @@ function createBuildPhase(ctx) {
21
32
  } = ctx;
22
33
  async function detectPhysicalDevice(ADB_PATH) {
23
34
  try {
24
- progress.log("Detecting physical devices...", "info");
35
+ progress.status("detecting physical devices");
25
36
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
26
- const devices = ctx.runCommand(`${ADB_PATH} devices -l`);
37
+ const devices = ctx.runCommand(`${ADB_PATH} devices -l`, {
38
+ timeout: ADB_TIMEOUT_MS
39
+ });
27
40
  const lines = devices.split("\n").filter(line => line.trim() && !line.includes("List of devices"));
28
41
  for (const line of lines) {
29
42
  const parts = line.trim().split(/\s+/);
@@ -32,13 +45,17 @@ function createBuildPhase(ctx) {
32
45
  if (!deviceId.startsWith("emulator-")) {
33
46
  try {
34
47
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
35
- const qemuCheck = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell getprop ro.kernel.qemu`);
48
+ const qemuCheck = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell getprop ro.kernel.qemu`, {
49
+ timeout: ADB_TIMEOUT_MS
50
+ });
36
51
  if (!qemuCheck.trim()) {
37
52
  progress.log(`Physical device detected: ${deviceId}`, "success");
38
53
  let deviceModel = "Unknown Device";
39
54
  try {
40
55
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - deviceId comes from adb devices output, not user input.
41
- const model = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell getprop ro.product.model`);
56
+ const model = ctx.runCommand(`${ADB_PATH} -s ${deviceId} shell getprop ro.product.model`, {
57
+ timeout: ADB_TIMEOUT_MS
58
+ });
42
59
  deviceModel = model.trim() || deviceModel;
43
60
  } catch (e) {
44
61
  // Ignore model detection errors
@@ -57,6 +74,7 @@ function createBuildPhase(ctx) {
57
74
  progress.log("No physical devices detected", "info");
58
75
  return null;
59
76
  } catch (error) {
77
+ if (isToolTimeout(error)) throw error;
60
78
  progress.log("Error detecting physical devices: " + error.message, "error");
61
79
  return null;
62
80
  }
@@ -114,15 +132,44 @@ function createBuildPhase(ctx) {
114
132
  async function checkEmulator(ADB_PATH) {
115
133
  try {
116
134
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
117
- const devices = ctx.runCommand(`${ADB_PATH} devices`);
135
+ const devices = ctx.runCommand(`${ADB_PATH} devices`, {
136
+ timeout: ADB_TIMEOUT_MS
137
+ });
118
138
  const onlineEmulator = devices.split("\n").map(line => line.trim().split(/\s+/)).find(([id, state]) => id?.startsWith("emulator-") && state === "device");
119
139
  return onlineEmulator ? onlineEmulator[0] : null;
120
140
  } catch (error) {
141
+ if (isToolTimeout(error)) throw error;
121
142
  progress.log("Error checking emulator status: " + error.message, "error");
122
143
  return null;
123
144
  }
124
145
  }
125
146
 
147
+ /**
148
+ * The task name from a gradle line, or null for lines worth ignoring.
149
+ * "> Task :app:compileDebugKotlin" becomes "app:compileDebugKotlin".
150
+ */
151
+ function gradleTask(line) {
152
+ const match = /^>\s*Task\s+:(\S+)/.exec(line.trim());
153
+ return match ? match[1] : null;
154
+ }
155
+
156
+ /**
157
+ * Run something that writes directly to the terminal, with the progress
158
+ * tree stood down for the duration.
159
+ *
160
+ * The resume must happen on the failure path too -- a build that throws
161
+ * while paused would otherwise leave the tree silent for the rest of the
162
+ * run, hiding exactly the output someone needs when a build fails.
163
+ */
164
+ async function withStreamedOutput(fn) {
165
+ progress.pause();
166
+ try {
167
+ return await fn();
168
+ } finally {
169
+ progress.resume();
170
+ }
171
+ }
172
+
126
173
  // The Android emulator binary is a long-running server process — it does not
127
174
  // exit after boot, it keeps running until the emulator window is closed. Using
128
175
  // runInteractiveCommand (which only resolves on the child's `close` event, see
@@ -179,9 +226,12 @@ function createBuildPhase(ctx) {
179
226
  function listEmulatorSerials(ADB_PATH) {
180
227
  try {
181
228
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
182
- const devices = ctx.runCommand(`${ADB_PATH} devices`);
229
+ const devices = ctx.runCommand(`${ADB_PATH} devices`, {
230
+ timeout: ADB_TIMEOUT_MS
231
+ });
183
232
  return devices.split("\n").map(line => line.trim().split(/\s+/)[0]).filter(id => id && id.startsWith("emulator-"));
184
- } catch {
233
+ } catch (error) {
234
+ if (isToolTimeout(error)) throw error;
185
235
  return [];
186
236
  }
187
237
  }
@@ -226,7 +276,10 @@ function createBuildPhase(ctx) {
226
276
  const remaining = Math.max(deadline - Date.now(), 1000);
227
277
  return execFileSync(ADB_PATH, ["-s", serial, "shell", "getprop", prop], {
228
278
  encoding: "utf8",
229
- timeout: remaining
279
+ timeout: remaining,
280
+ // "adb: device offline" is the expected answer while the
281
+ // emulator boots; inherited, it landed on the spinner's row.
282
+ stdio: ["ignore", "pipe", "ignore"]
230
283
  }).trim();
231
284
  };
232
285
  while (Date.now() < deadline) {
@@ -252,7 +305,7 @@ function createBuildPhase(ctx) {
252
305
  return new Error(`[ANDROID-001] Timed out waiting for the Android emulator to boot: ${detail}`);
253
306
  }
254
307
  async function handleEmulatorSetup(ADB_PATH, EMULATOR_PATH, androidConfig) {
255
- progress.log("Setting up emulator...", "info");
308
+ progress.status("setting up emulator");
256
309
  let serial = await checkEmulator(ADB_PATH);
257
310
  let pid = null;
258
311
  if (!serial) {
@@ -281,19 +334,35 @@ function createBuildPhase(ctx) {
281
334
  };
282
335
  }
283
336
  async function buildApp(ADB_PATH, androidConfig, buildOptimisation, targetDevice = null) {
284
- progress.log("Building and installing app...", "info");
337
+ progress.status("building and installing app");
285
338
  try {
339
+ // Gradle needs the SDK location in local.properties. Only
340
+ // setupEmulator:android wrote it, so building without a prior
341
+ // setup died in gradle despite a configured sdkPath.
342
+ if (androidConfig.sdkPath) ensureLocalProperties(`${pwd}/androidProject`, androidConfig.sdkPath);
343
+
344
+ // --quiet was the Android silence: it suppresses gradle's task and
345
+ // error output entirely. --console=rich emits ANSI redraws, which
346
+ // are meaningless on a pipe; plain gives one line per task.
286
347
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - pwd and configPath are internal resolved paths, not user input.
287
- let buildCommand = `cd ${pwd}/androidProject && ./gradlew generateWebViewConfig -PconfigPath=${configPath} -PbuildOptimisation=${buildOptimisation} && ./gradlew clean installDebug -PconfigPath=${configPath} --quiet --console=rich`;
348
+ let buildCommand = `cd ${pwd}/androidProject && ./gradlew generateWebViewConfig -PconfigPath=${configPath} -PbuildOptimisation=${buildOptimisation} && ./gradlew clean installDebug -PconfigPath=${configPath} --console=plain`;
288
349
  if (targetDevice && targetDevice.type === "physical") {
289
350
  buildCommand = buildCommand.replace("installDebug", `installDebug -Pandroid.injected.target.device=${targetDevice.id}`);
290
351
  }
352
+
353
+ // Gradle emits hundreds of lines that nobody reads on success and
354
+ // that the error path re-derives from error.output anyway. Show the
355
+ // current task on the spinner's own row instead of scrolling them.
291
356
  await ctx.runInteractiveCommand("sh", ["-c", buildCommand], {
292
357
  "BUILD SUCCESSFUL": ""
358
+ }, {
359
+ onLine: line => progress.status(gradleTask(line))
293
360
  });
294
- progress.log("App build and installation completed successfully!", "success");
295
361
  } catch (error) {
296
- throw new Error("Error building/installing app: " + error.message);
362
+ // Re-wrapping with a new Error dropped `output`, and with it the
363
+ // compiler errors. Keep the original and let the reporter mine it.
364
+ error.stage = "Building and installing the app";
365
+ throw error;
297
366
  }
298
367
  }
299
368
  async function launchApp(ADB_PATH, buildType = "debug", targetDevice = null) {
@@ -303,16 +372,15 @@ function createBuildPhase(ctx) {
303
372
  }
304
373
  if (targetDevice.type === "physical") {
305
374
  progress.log(`App installed on physical device: ${targetDevice.model}`, "success");
306
- progress.log("Manual launch required - check your device to open the app", "info");
375
+ progress.log("Open the app on your device to launch it", "note");
307
376
  return;
308
377
  }
309
378
  try {
310
- progress.log("Launching app on emulator...", "info");
379
+ progress.status("launching app on emulator");
311
380
  const packageName = `${ANDROID_PACKAGE}${buildType === "debug" ? ".debug" : ""}`;
312
381
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - ADB_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
313
382
  const launchCommand = `${ADB_PATH} shell monkey -p ${packageName} 1`;
314
383
  await ctx.runInteractiveCommand("sh", ["-c", launchCommand], {});
315
- progress.log("App launched successfully on emulator!", "success");
316
384
  } catch (error) {
317
385
  progress.log(`Warning: Could not auto-launch app: ${error.message}`, "warning");
318
386
  progress.log("App was installed successfully, but auto-launch failed", "info");
@@ -362,12 +430,9 @@ function createBuildPhase(ctx) {
362
430
  return null;
363
431
  }
364
432
  const currentDate = new Date().toLocaleDateString("en-GB").replace(/\//g, "-");
365
- const currentTime = new Date().toLocaleTimeString("en-US", {
366
- hour12: true,
367
- hour: "2-digit",
368
- minute: "2-digit",
369
- second: "2-digit"
370
- }).replace(/:/g, "-");
433
+ // 24-hour and locale-free: "02-05-09 PM" put a space in every
434
+ // artifact name, which breaks unquoted shell use of the path.
435
+ const currentTime = new Date().toTimeString().slice(0, 8).replace(/:/g, "-");
371
436
  let destinationApkFileName = "";
372
437
  if (appName) {
373
438
  destinationApkFileName = buildType === "release" ? `${appName}-${currentTime}.release.apk` : `${appName}-${currentTime}.debug.apk`;
@@ -405,12 +470,16 @@ function createBuildPhase(ctx) {
405
470
  async function buildSignedAAB(androidConfig) {
406
471
  progress.log("Building signed AAB for release...", "info");
407
472
  try {
473
+ // Same as buildApp: gradle needs local.properties on a fresh machine.
474
+ if (androidConfig.sdkPath) ensureLocalProperties(`${pwd}/androidProject`, androidConfig.sdkPath);
408
475
  progress.log("Generating webview configuration for release...", "info");
409
476
  try {
410
477
  // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - pwd and configPath are internal resolved paths, not user input.
411
478
  const generateConfigCommand = `cd ${pwd}/androidProject && ./gradlew generateWebViewConfig -PconfigPath=${configPath}`;
412
479
  await ctx.runInteractiveCommand("sh", ["-c", generateConfigCommand], {
413
480
  "BUILD SUCCESSFUL": ""
481
+ }, {
482
+ onLine: line => progress.status(gradleTask(line))
414
483
  });
415
484
  progress.log("Webview config generated successfully", "success");
416
485
  } catch (configError) {
@@ -418,10 +487,13 @@ function createBuildPhase(ctx) {
418
487
  throw new Error("Webview config generation is required for release builds");
419
488
  }
420
489
  const aabConfig = await createAABConfig(androidConfig);
421
- await buildAndroidAAB(aabConfig);
490
+ // buildAndroidAAB shells out to `./gradlew bundleRelease` and prints
491
+ // its own progress lines, so it owns the screen for its duration.
492
+ await withStreamedOutput(() => buildAndroidAAB(aabConfig));
422
493
  progress.log("Signed AAB build completed successfully!", "success");
423
494
  } catch (error) {
424
- throw new Error("Error building signed AAB: " + error.message);
495
+ error.stage = "Building the signed AAB";
496
+ throw error;
425
497
  }
426
498
  }
427
499
  return {
@@ -1,6 +1,21 @@
1
1
  "use strict";
2
2
 
3
3
  const fs = require("fs");
4
+ const os = require("os");
5
+ const {
6
+ detectAndroidSdk,
7
+ listAvds,
8
+ expandHome,
9
+ ANDROID_SDK_HINT
10
+ } = require("../utils.js");
11
+
12
+ /** An error carrying the one action that fixes it, shown as the diagnostic's hint. */
13
+ function withHint(message, hint) {
14
+ return Object.assign(new Error(message), {
15
+ hint
16
+ });
17
+ }
18
+ const tilde = dir => dir.startsWith(os.homedir()) ? `~${dir.slice(os.homedir().length)}` : dir;
4
19
  function createConfigPhase(ctx) {
5
20
  const {
6
21
  configPath,
@@ -17,10 +32,9 @@ function createConfigPhase(ctx) {
17
32
  throw new Error("WebView Config missing in " + configPath);
18
33
  }
19
34
  if (!WEBVIEW_CONFIG.android) {
20
- throw new Error("Android config missing in WebView Config");
35
+ throw withHint("Android config missing in WebView Config", "Run catalyst setupEmulator:android to create it, or add WEBVIEW_CONFIG.android to config/config.json.");
21
36
  }
22
37
  const buildType = WEBVIEW_CONFIG.android.buildType || "debug";
23
- progress.log(`Build Type: ${buildType}`, "info");
24
38
  if (buildType === "release") {
25
39
  progress.log("Release build detected - AAB will be generated", "info");
26
40
  }
@@ -30,23 +44,34 @@ function createConfigPhase(ctx) {
30
44
  };
31
45
  }
32
46
  function validateAndroidTools(androidConfig) {
47
+ progress.status("validating tools");
48
+
49
+ // An unset sdkPath is usually just an SDK in the default place. Use it,
50
+ // and say so, rather than failing on a field most people never set.
51
+ if (!androidConfig.sdkPath) {
52
+ const detected = detectAndroidSdk();
53
+ if (!detected) throw withHint("Android SDK not found", ANDROID_SDK_HINT);
54
+ androidConfig.sdkPath = detected;
55
+ progress.log(`Using the Android SDK at ${tilde(detected)}`, "note");
56
+ }
57
+ // "~/Library/Android/sdk", as the hint suggests. Expanded in memory
58
+ // only; the config file keeps what the user wrote.
59
+ androidConfig.sdkPath = expandHome(androidConfig.sdkPath);
33
60
  const ANDROID_SDK = androidConfig.sdkPath;
34
61
  const ADB_PATH = `${ANDROID_SDK}/platform-tools/adb`;
35
62
  const EMULATOR_PATH = `${ANDROID_SDK}/emulator/emulator`;
36
- progress.log("Validating Android tools...", "info");
37
- if (!ANDROID_SDK) {
38
- throw new Error("Android SDK path is not configured");
39
- }
40
63
  if (!fs.existsSync(ANDROID_SDK)) {
41
- throw new Error(`Android SDK path does not exist: ${ANDROID_SDK}`);
64
+ throw withHint(`Android SDK path does not exist: ${ANDROID_SDK}`, ANDROID_SDK_HINT);
42
65
  }
43
66
  if (!fs.existsSync(ADB_PATH)) {
44
- throw new Error(`ADB not found at: ${ADB_PATH}`);
67
+ throw withHint(`adb not found at ${ADB_PATH}`, "Install Android SDK Platform-Tools from Android Studio's SDK Manager.");
45
68
  }
46
69
  try {
47
- ctx.runCommand(`${ADB_PATH} version`);
48
- progress.log("ADB validation successful", "success");
70
+ ctx.runCommand(`${ADB_PATH} version`, {
71
+ timeout: 15000
72
+ });
49
73
  } catch (error) {
74
+ if (error.hint) throw error;
50
75
  throw new Error(`ADB is not working properly: ${error.message}`);
51
76
  }
52
77
  const buildType = androidConfig.buildType || "debug";
@@ -56,10 +81,17 @@ function createConfigPhase(ctx) {
56
81
  }
57
82
  try {
58
83
  ctx.runCommand(`${EMULATOR_PATH} -version`);
59
- progress.log("Emulator validation successful", "success");
60
84
  } catch (error) {
61
85
  throw new Error(`Emulator is not working properly: ${error.message}`);
62
86
  }
87
+ if (!androidConfig.emulatorName) {
88
+ const [first] = listAvds(ANDROID_SDK);
89
+ if (!first) {
90
+ throw withHint("No Android emulator found", "Create one in Android Studio's Device Manager, or set WEBVIEW_CONFIG.android.emulatorName.");
91
+ }
92
+ androidConfig.emulatorName = first;
93
+ progress.log(`Using the "${first}" emulator`, "note");
94
+ }
63
95
  try {
64
96
  const avdOutput = ctx.runCommand(`${EMULATOR_PATH} -list-avds`);
65
97
  if (!avdOutput.includes(androidConfig.emulatorName)) {
@@ -72,7 +104,6 @@ function createConfigPhase(ctx) {
72
104
  } else {
73
105
  progress.log("Skipping emulator validation for release build", "info");
74
106
  }
75
- progress.log("Android tools validation completed successfully!", "success");
76
107
  return {
77
108
  ANDROID_SDK,
78
109
  ADB_PATH,