catalyst-core 0.3.0-canary.2 → 0.3.0-canary.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.md CHANGED
@@ -78,7 +78,7 @@ The app contract validators in `server/utils/validator.js` used to log a bare me
78
78
 
79
79
  The route and store checks moved out of the per-request path. `validateGetRoutes` and `validateConfigureStore` now run once when the renderer module loads — at server startup in production, and on the first SSR request in development, where the handler is loaded lazily — rather than once per request inside the handler's `try`/`catch`. The shape of `addMiddlewares` from `server/server.js` is checked during server setup.
80
80
 
81
- Module aliases and `config/config.json` keys are not checked at load time; a missing alias or key still surfaces where it is used. An application that satisfies the contract sees no change. The full contract — the files Catalyst imports by fixed path and the required module aliases — is documented under File Conventions.
81
+ Separately, `catalyst start`, `build` and `serve` now check `config/config.json` and `package.json` before starting and exit with a coded error when either is missing, a required config key is missing, or a required module alias is missing. An application that satisfies the contract sees no change. The full contract — the files Catalyst imports by fixed path and the required module aliases — is documented under File Conventions.
82
82
  The webpack-era keys `WEBPACK_DEV_SERVER_HOSTNAME`, `WEBPACK_DEV_SERVER_PORT` and `ANALYZE_BUNDLE` are no longer required or read; remove them from `config/config.json`.
83
83
 
84
84
  ## [0.3.0-beta.5] - 2026-08-18
@@ -28,7 +28,9 @@ export const ERROR_CODES = {
28
28
 
29
29
  BUNDLE_UPSTREAM_ERROR: "BUNDLE-000",
30
30
  IOS_UPSTREAM_ERROR: "IOS-000",
31
+ IOS_SIMULATOR_APP_NOT_FOUND: "IOS-001",
31
32
  ANDROID_UPSTREAM_ERROR: "ANDROID-000",
33
+ ANDROID_EMULATOR_BOOT_TIMEOUT: "ANDROID-001",
32
34
 
33
35
  RUNTIME_NATIVE_PERMISSION_DENIED: "RUNTIME-NATIVE-001",
34
36
  RUNTIME_NATIVE_PERMISSION_REQUIRED: "RUNTIME-NATIVE-002",
@@ -231,12 +233,27 @@ export const ERROR_DEFINITIONS = {
231
233
  defaultDetails: "This wraps an error from Xcode/CocoaPods. See the printed upstream output for the cause.",
232
234
  suggestedAction: "Read the upstream Xcode/CocoaPods error printed above and fix the underlying issue.",
233
235
  },
236
+ [ERROR_CODES.IOS_SIMULATOR_APP_NOT_FOUND]: {
237
+ category: "IOS",
238
+ defaultMessage: "Could not find an installed iOS Simulator app",
239
+ defaultDetails:
240
+ "Neither DeviceHub.app (Xcode 27+, bundle id com.apple.dt.Devices) nor the legacy Simulator.app (bundle id com.apple.iphonesimulator) is installed under Xcode's Contents/Applications. The simulator itself may still boot headlessly via simctl, but catalyst-core cannot open or focus its window.",
241
+ suggestedAction: "Confirm a full Xcode.app (not just Command Line Tools) is installed and selected via xcode-select -p, then reopen it once from Spotlight so macOS registers its app bundle.",
242
+ },
234
243
  [ERROR_CODES.ANDROID_UPSTREAM_ERROR]: {
235
244
  category: "ANDROID",
236
245
  defaultMessage: "Android build failed in an upstream toolchain step",
237
246
  defaultDetails: "This wraps an error from Gradle. See the printed upstream output for the cause.",
238
247
  suggestedAction: "Read the upstream Gradle error printed above and fix the underlying issue.",
239
248
  },
249
+ [ERROR_CODES.ANDROID_EMULATOR_BOOT_TIMEOUT]: {
250
+ category: "ANDROID",
251
+ defaultMessage: "Timed out waiting for the Android emulator to boot",
252
+ defaultDetails:
253
+ "buildApp:android launched the configured AVD but it did not appear in `adb devices` or did not finish booting (sys.boot_completed=1 and init.svc.bootanim=stopped) within the timeout.",
254
+ suggestedAction:
255
+ "Try booting the AVD manually first (emulator -avd <name>) to see the actual startup error, check available disk space/RAM, or increase the emulator's cold-boot allowance if the device is unusually slow.",
256
+ },
240
257
 
241
258
  [ERROR_CODES.RUNTIME_NATIVE_PERMISSION_DENIED]: {
242
259
  category: "RUNTIME-NATIVE",
@@ -244,6 +244,13 @@
244
244
  "suggestedAction": "Read the upstream Xcode/CocoaPods error printed above and fix the underlying issue.",
245
245
  "docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/IOS/IOS-000.md"
246
246
  },
247
+ "IOS-001": {
248
+ "category": "IOS",
249
+ "message": "Could not find an installed iOS Simulator app",
250
+ "details": "Neither DeviceHub.app (Xcode 27+, bundle id com.apple.dt.Devices) nor the legacy Simulator.app (bundle id com.apple.iphonesimulator) is installed under Xcode's Contents/Applications. The simulator itself may still boot headlessly via simctl, but catalyst-core cannot open or focus its window.",
251
+ "suggestedAction": "Confirm a full Xcode.app (not just Command Line Tools) is installed and selected via xcode-select -p, then reopen it once from Spotlight so macOS registers its app bundle.",
252
+ "docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/IOS/IOS-001.md"
253
+ },
247
254
  "ANDROID-000": {
248
255
  "category": "ANDROID",
249
256
  "message": "Android build failed in an upstream toolchain step",
@@ -251,6 +258,13 @@
251
258
  "suggestedAction": "Read the upstream Gradle error printed above and fix the underlying issue.",
252
259
  "docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/ANDROID/ANDROID-000.md"
253
260
  },
261
+ "ANDROID-001": {
262
+ "category": "ANDROID",
263
+ "message": "Timed out waiting for the Android emulator to boot",
264
+ "details": "buildApp:android launched the configured AVD but it did not appear in `adb devices` or did not finish booting (sys.boot_completed=1 and init.svc.bootanim=stopped) within the timeout.",
265
+ "suggestedAction": "Try booting the AVD manually first (emulator -avd <name>) to see the actual startup error, check available disk space/RAM, or increase the emulator's cold-boot allowance if the device is unusually slow.",
266
+ "docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/ANDROID/ANDROID-001.md"
267
+ },
254
268
  "RUNTIME-NATIVE-001": {
255
269
  "category": "RUNTIME-NATIVE",
256
270
  "message": "Permission denied",
@@ -2,6 +2,10 @@
2
2
 
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
+ const {
6
+ spawn,
7
+ execFileSync
8
+ } = require("child_process");
5
9
  const {
6
10
  buildAndroidAAB
7
11
  } = require("../renameAndroidProject.js");
@@ -101,36 +105,176 @@ function createBuildPhase(ctx) {
101
105
  return false;
102
106
  }
103
107
  }
108
+
109
+ // Returns the serial of an online emulator (adb state "device"), or null if
110
+ // none is online. `devices.includes("emulator")` alone would also match a
111
+ // serial like "emulator-5554" whose state is "offline" or still enumerating —
112
+ // i.e. a device that's connected but not actually ready — so this checks the
113
+ // state column explicitly rather than doing a substring match.
104
114
  async function checkEmulator(ADB_PATH) {
105
115
  try {
106
116
  // 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.
107
117
  const devices = ctx.runCommand(`${ADB_PATH} devices`);
108
- return devices.includes("emulator");
118
+ const onlineEmulator = devices.split("\n").map(line => line.trim().split(/\s+/)).find(([id, state]) => id?.startsWith("emulator-") && state === "device");
119
+ return onlineEmulator ? onlineEmulator[0] : null;
109
120
  } catch (error) {
110
121
  progress.log("Error checking emulator status: " + error.message, "error");
111
- return false;
122
+ return null;
112
123
  }
113
124
  }
125
+
126
+ // The Android emulator binary is a long-running server process — it does not
127
+ // exit after boot, it keeps running until the emulator window is closed. Using
128
+ // runInteractiveCommand (which only resolves on the child's `close` event, see
129
+ // utils.js) would block forever waiting for an exit that never happens before
130
+ // the app finishes booting. Spawn it detached instead (same approach as
131
+ // androidSetup.js's startEmulator) and let handleEmulatorSetup poll for
132
+ // readiness separately.
133
+ /**
134
+ * Launch the emulator detached and return once it has actually spawned
135
+ * (not once it exits — see the note above on why runInteractiveCommand
136
+ * can't be used here).
137
+ * @returns {Promise<number>} the spawned process's pid, so a caller whose
138
+ * boot-readiness wait times out can kill it instead of leaving a
139
+ * CPU/RAM-heavy orphaned emulator running past a failed build.
140
+ */
114
141
  async function startEmulator(EMULATOR_PATH, androidConfig) {
115
142
  progress.log(`Starting emulator: ${androidConfig.emulatorName}...`, "info");
116
- // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - EMULATOR_PATH is derived from androidConfig.sdkPath, a trusted internal config value.
117
- return ctx.runInteractiveCommand(EMULATOR_PATH, ["-avd", androidConfig.emulatorName, "-read-only"], {}).then(() => {
118
- progress.log("Emulator started successfully", "success");
119
- }).catch(error => {
120
- progress.log("Error starting emulator: " + error.message, "error");
121
- throw error;
143
+ return new Promise((resolve, reject) => {
144
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - spawn with an argv array (no shell), so androidConfig.emulatorName can't be interpreted as shell syntax even though it comes from a locally-editable config file.
145
+ const child = spawn(EMULATOR_PATH, ["-avd", androidConfig.emulatorName, "-read-only"], {
146
+ detached: true,
147
+ stdio: "ignore"
148
+ });
149
+ child.once("error", error => {
150
+ progress.log("Error starting emulator: " + error.message, "error");
151
+ reject(error);
152
+ });
153
+ // "error" only fires for spawn failures (e.g. bad EMULATOR_PATH); once the
154
+ // process has actually spawned we can detach and let the caller poll adb
155
+ // for boot readiness rather than waiting on this child at all.
156
+ child.once("spawn", () => {
157
+ const {
158
+ pid
159
+ } = child;
160
+ child.unref();
161
+ progress.log("Emulator process started, waiting for it to boot...", "info");
162
+ resolve(pid);
163
+ });
122
164
  });
123
165
  }
166
+
167
+ // Best-effort cleanup for an emulator process this build spawned but that
168
+ // never became ready — kills the whole detached process group (negative pid)
169
+ // so we don't leave a CPU/RAM-heavy orphaned emulator running after a failed
170
+ // build. Never throws: a failure here shouldn't mask the original timeout error.
171
+ function killOrphanedEmulator(pid, emulatorName) {
172
+ try {
173
+ process.kill(-pid, "SIGTERM");
174
+ progress.log(`Stopped orphaned emulator process (pid ${pid}) after boot failure`, "info");
175
+ } catch (error) {
176
+ progress.log(`Could not stop orphaned emulator "${emulatorName}" (pid ${pid}): ${error.message}. ` + "You may need to quit it manually.", "warning");
177
+ }
178
+ }
179
+ function listEmulatorSerials(ADB_PATH) {
180
+ try {
181
+ // 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`);
183
+ return devices.split("\n").map(line => line.trim().split(/\s+/)[0]).filter(id => id && id.startsWith("emulator-"));
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+
189
+ // Waits for a NEW emulator-XXXX serial to appear in `adb devices` (one not in
190
+ // knownSerials, captured before startEmulator was called), so boot polling can
191
+ // be scoped to the emulator this build just launched rather than whichever
192
+ // device `adb shell` (with no -s) happens to pick when multiple are connected.
193
+ async function waitForNewEmulatorSerial(ADB_PATH, knownSerials, {
194
+ timeoutMs = 30000,
195
+ pollIntervalMs = 1000
196
+ } = {}) {
197
+ const deadline = Date.now() + timeoutMs;
198
+ while (Date.now() < deadline) {
199
+ const current = listEmulatorSerials(ADB_PATH);
200
+ const newSerial = current.find(id => !knownSerials.includes(id));
201
+ if (newSerial) return newSerial;
202
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Poll a specific emulator (by adb serial, e.g. "emulator-5554") until it
209
+ * reports a completed boot and a stopped boot animation, or the timeout
210
+ * elapses.
211
+ * @param {string} ADB_PATH Path to the adb executable.
212
+ * @param {string} serial The emulator's adb serial to target (adb -s).
213
+ * @param {Object} [options]
214
+ * @param {number} [options.timeoutMs=120000] Overall deadline in milliseconds.
215
+ * @param {number} [options.pollIntervalMs=2000] Delay between readiness checks.
216
+ * @returns {Promise<boolean>} true once both readiness signals are observed;
217
+ * false if the deadline elapses first (a transient/hung adb call counts
218
+ * against the same deadline via the per-call execFileSync timeout below).
219
+ */
220
+ async function waitForEmulatorBoot(ADB_PATH, serial, {
221
+ timeoutMs = 120000,
222
+ pollIntervalMs = 2000
223
+ } = {}) {
224
+ const deadline = Date.now() + timeoutMs;
225
+ const getprop = prop => {
226
+ const remaining = Math.max(deadline - Date.now(), 1000);
227
+ return execFileSync(ADB_PATH, ["-s", serial, "shell", "getprop", prop], {
228
+ encoding: "utf8",
229
+ timeout: remaining
230
+ }).trim();
231
+ };
232
+ while (Date.now() < deadline) {
233
+ try {
234
+ const bootCompleted = getprop("sys.boot_completed");
235
+ const bootAnimDone = getprop("init.svc.bootanim");
236
+ if (bootCompleted === "1" && bootAnimDone === "stopped") return true;
237
+ } catch {
238
+ // adb not ready yet (device still enumerating), or this call hit its
239
+ // own per-call timeout — either way, keep polling against the outer
240
+ // deadline rather than treating it as fatal.
241
+ }
242
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
243
+ }
244
+ return false;
245
+ }
246
+
247
+ // Message mirrors errors/registry.js ERROR_DEFINITIONS[ANDROID-001] (see
248
+ // errors/ANDROID/ANDROID-001.md) — this CJS subtree can't import the ESM
249
+ // error registry (see buildErrorFormat.js), so the code is prefixed onto the
250
+ // thrown message directly rather than imported.
251
+ function emulatorBootTimeoutError(detail) {
252
+ return new Error(`[ANDROID-001] Timed out waiting for the Android emulator to boot: ${detail}`);
253
+ }
124
254
  async function handleEmulatorSetup(ADB_PATH, EMULATOR_PATH, androidConfig) {
125
255
  progress.log("Setting up emulator...", "info");
126
- const emulatorRunning = await checkEmulator(ADB_PATH);
127
- if (!emulatorRunning) {
256
+ let serial = await checkEmulator(ADB_PATH);
257
+ let pid = null;
258
+ if (!serial) {
128
259
  progress.log("No emulator running, attempting to start one...", "info");
129
- await startEmulator(EMULATOR_PATH, androidConfig);
130
- await new Promise(resolve => setTimeout(resolve, 5000));
260
+ const knownSerials = listEmulatorSerials(ADB_PATH);
261
+ pid = await startEmulator(EMULATOR_PATH, androidConfig);
262
+ serial = await waitForNewEmulatorSerial(ADB_PATH, knownSerials);
263
+ if (!serial) {
264
+ killOrphanedEmulator(pid, androidConfig.emulatorName);
265
+ throw emulatorBootTimeoutError(`emulator "${androidConfig.emulatorName}" never appeared in adb devices`);
266
+ }
131
267
  } else {
132
- progress.log("Emulator already running", "success");
268
+ progress.log(`Emulator ${serial} already online, confirming it's fully booted...`, "info");
269
+ }
270
+ // Wait for boot readiness whether the emulator was already online (it may
271
+ // still be finishing its boot animation) or was just launched above.
272
+ const booted = await waitForEmulatorBoot(ADB_PATH, serial);
273
+ if (!booted) {
274
+ if (pid) killOrphanedEmulator(pid, androidConfig.emulatorName);
275
+ throw emulatorBootTimeoutError(`emulator "${androidConfig.emulatorName}" (${serial}) never finished booting`);
133
276
  }
277
+ progress.log("Emulator booted successfully", "success");
134
278
  return {
135
279
  type: "emulator",
136
280
  name: androidConfig.emulatorName
@@ -286,6 +430,7 @@ function createBuildPhase(ctx) {
286
430
  checkEmulator,
287
431
  handleEmulatorSetup,
288
432
  startEmulator,
433
+ waitForEmulatorBoot,
289
434
  buildApp,
290
435
  launchApp,
291
436
  createAABConfig,
@@ -72,6 +72,33 @@ module.exports = function createBuildPhase(ctx) {
72
72
  }
73
73
  }
74
74
 
75
+ // Xcode 27 replaced the standalone Simulator.app (com.apple.iphonesimulator)
76
+ // with DeviceHub.app (com.apple.dt.Devices); older Xcode installs still ship
77
+ // the former. Resolve whichever is actually installed instead of hardcoding
78
+ // the app name, which no longer resolves via `open -a` on either app.
79
+ function resolveSimulatorAppBundleId() {
80
+ const candidates = ["com.apple.dt.Devices", "com.apple.iphonesimulator"];
81
+ for (const bundleId of candidates) {
82
+ try {
83
+ execSync(`osascript -e 'id of app id "${bundleId}"'`, {
84
+ stdio: "ignore"
85
+ }); // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - bundleId is a fixed internal candidate list, not user input.
86
+ return bundleId;
87
+ } catch {
88
+ // Not installed under this bundle id; try the next candidate.
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ // Text mirrors errors/registry.js ERROR_DEFINITIONS[IOS-001] (see
95
+ // errors/IOS/IOS-001.md) — this CJS subtree can't import the ESM error
96
+ // registry (see buildErrorFormat.js), so the message is duplicated here
97
+ // rather than imported. This condition is recoverable (the simulator can
98
+ // still boot headlessly via simctl) so callers log it as a warning
99
+ // instead of throwing.
100
+ const SIMULATOR_APP_NOT_FOUND_WARNING = "[IOS-001] Could not find an installed iOS Simulator app (checked DeviceHub.app and the legacy Simulator.app). " + "The simulator is booted but its window may not be visible. Suggested action: confirm a full Xcode.app is installed and selected via xcode-select -p, then open it once from Spotlight.";
101
+
75
102
  // ─── Clean ────────────────────────────────────────────────────────────────
76
103
 
77
104
  async function cleanBuildArtifacts() {
@@ -304,7 +331,12 @@ module.exports = function createBuildPhase(ctx) {
304
331
  }
305
332
  async function focusSimulator() {
306
333
  console.log("Focusing on Simulator...");
307
- await runCommand(`osascript -e 'tell application "Simulator" to activate'`);
334
+ const simulatorAppBundleId = resolveSimulatorAppBundleId();
335
+ if (!simulatorAppBundleId) {
336
+ progress.log(SIMULATOR_APP_NOT_FOUND_WARNING, "warning");
337
+ return;
338
+ }
339
+ await runCommand(`osascript -e 'tell application id "${simulatorAppBundleId}" to activate'`);
308
340
  }
309
341
  async function installAndLaunchApp(APP_PATH) {
310
342
  progress.start("install");
@@ -326,6 +358,60 @@ module.exports = function createBuildPhase(ctx) {
326
358
 
327
359
  // ─── Physical device ──────────────────────────────────────────────────────
328
360
 
361
+ // `instruments -s devices` is deprecated and its plain-text output is fragile
362
+ // to parse. `xcrun devicectl list devices --json-output <path>` is the modern,
363
+ // structured replacement (Xcode 15+) and is already used elsewhere in this
364
+ // file for install/launch, so use it as the primary detection source too.
365
+ // Note: devicectl lists simulators as well as physical devices, so filter on
366
+ // hardwareProperties.reality === "physical".
367
+ //
368
+ // Write to a temp file rather than `--json-output -` (stdout): Apple's own
369
+ // scripting guidance is a file path, and stdout support for `-` isn't
370
+ // guaranteed across every Xcode version this needs to run on — a temp file
371
+ // works the same way on every devicectl version. Falls back to
372
+ // xcodebuild -showdestinations below on any failure (old devicectl, no
373
+ // physical device, malformed output), so this only needs to work when it can.
374
+ // Matches the identifier format Apple's own tooling uses (hex digits and
375
+ // hyphens only, e.g. a UUID or the shorter legacy 40-char UDID). device.udid
376
+ // is later interpolated unquoted into shell command strings (xcodebuild
377
+ // -destination, xcrun devicectl --device, see installAndLaunchOnPhysicalDevice
378
+ // and buildProjectForPhysicalDevice) — the old instruments-based parser
379
+ // structurally constrained what could reach that string via its own regex;
380
+ // this is the equivalent guard for the JSON-based devicectl path, so a
381
+ // malformed or hostile identifier in devicectl's output can't inject shell
382
+ // syntax rather than just being rejected as "not a device".
383
+ const VALID_UDID_RE = /^[A-Fa-f0-9-]+$/;
384
+ function tryDevicectl() {
385
+ const tmpPath = path.join(os.tmpdir(), `catalyst-devicectl-${process.pid}-${Date.now()}.json`);
386
+ try {
387
+ execSync(`xcrun devicectl list devices --json-output "${tmpPath}"`); // nosemgrep: javascript.lang.security.audit.dangerous-spawn-shell-command.dangerous-spawn-shell-command - tmpPath is built from os.tmpdir()/process.pid/Date.now(), not user input.
388
+ const raw = fs.readFileSync(tmpPath, "utf8");
389
+ const parsed = JSON.parse(raw);
390
+ const devices = parsed?.result?.devices ?? [];
391
+ return devices.filter(d => d.hardwareProperties?.reality === "physical")
392
+ // devicectl lists every device it has ever paired with, reachable or
393
+ // not — a device that's unplugged or out of range still shows up with
394
+ // reality: "physical". Only tunnelState !== "unavailable" is actually
395
+ // installable/launchable right now; "disconnected" (paired, USB/Wi-Fi
396
+ // not currently active but developer-mode discoverable) still works for
397
+ // wired installs, so only "unavailable" is excluded here.
398
+ .filter(d => d.connectionProperties?.tunnelState !== "unavailable").map(d => ({
399
+ name: d.deviceProperties?.name ?? "Physical Device",
400
+ version: d.deviceProperties?.osVersionNumber ?? "Unknown",
401
+ udid: d.hardwareProperties?.udid ?? d.identifier,
402
+ type: "physical"
403
+ })).filter(d => typeof d.udid === "string" && VALID_UDID_RE.test(d.udid));
404
+ } catch (error) {
405
+ progress.log(`devicectl detection failed (${error.message}); falling back to xcodebuild destinations`, "warning");
406
+ return [];
407
+ } finally {
408
+ try {
409
+ fs.unlinkSync(tmpPath);
410
+ } catch {
411
+ // Nothing to clean up if the command failed before writing the file.
412
+ }
413
+ }
414
+ }
329
415
  async function detectPhysicalDevices() {
330
416
  progress.start("deviceDetection");
331
417
  const {
@@ -337,57 +423,19 @@ module.exports = function createBuildPhase(ctx) {
337
423
  const configuredUDID = iosConfig.deviceUDID;
338
424
  if (configuredUDID) {
339
425
  progress.log(`Using configured device UDID: ${configuredUDID}`, "info");
340
- try {
341
- const instrumentsOutput = execSync("instruments -s devices").toString();
342
- if (instrumentsOutput.includes(configuredUDID)) {
343
- const deviceLine = instrumentsOutput.split("\n").find(line => line.includes(configuredUDID));
344
- if (deviceLine) {
345
- const nameMatch = deviceLine.match(/^(.+?)\s+\(/);
346
- const versionMatch = deviceLine.match(/\((\d+\.\d+(?:\.\d+)?)\)/);
347
- const deviceName = nameMatch ? nameMatch[1].trim() : "Physical Device";
348
- const deviceVersion = versionMatch ? versionMatch[1] : "Unknown";
349
- progress.log(`✅ Found configured physical device: ${deviceName} (${deviceVersion})`, "success");
350
- physicalDevices.push({
351
- name: deviceName,
352
- version: deviceVersion,
353
- udid: configuredUDID,
354
- type: "physical"
355
- });
356
- progress.complete("deviceDetection");
357
- return physicalDevices[0];
358
- }
359
- } else {
360
- progress.log(`⚠️ Configured device UDID not found in connected devices`, "warning");
361
- progress.log("Falling back to auto-detection...", "info");
362
- }
363
- } catch (error) {
364
- progress.log(`Error verifying configured device: ${error.message}`, "warning");
365
- progress.log("Falling back to auto-detection...", "info");
426
+ const configuredDevice = tryDevicectl().find(d => d.udid === configuredUDID);
427
+ if (configuredDevice) {
428
+ progress.log(`✅ Found configured physical device: ${configuredDevice.name} (${configuredDevice.version})`, "success");
429
+ progress.complete("deviceDetection");
430
+ return configuredDevice;
366
431
  }
432
+ progress.log(`⚠️ Configured device UDID not found in connected devices`, "warning");
433
+ progress.log("Falling back to auto-detection...", "info");
367
434
  } else {
368
435
  progress.log("No device UDID configured, using auto-detection", "info");
369
436
  }
370
- const tryInstruments = () => {
371
- try {
372
- const out = execSync("instruments -s devices").toString();
373
- return out.split("\n").filter(line => {
374
- const m = line.match(/^(.+?)\s+\((\d+\.\d+(?:\.\d+)?)\)\s+\[([A-F0-9-]{36})\](?:\s+\(Simulator\))?$/);
375
- return m && !line.includes("(Simulator)");
376
- }).map(line => {
377
- const [, name, version, udid] = line.match(/^(.+?)\s+\((\d+\.\d+(?:\.\d+)?)\)\s+\[([A-F0-9-]{36})\]/);
378
- return {
379
- name: name.trim(),
380
- version,
381
- udid,
382
- type: "physical"
383
- };
384
- });
385
- } catch {
386
- return [];
387
- }
388
- };
389
- physicalDevices = tryInstruments();
390
- if (physicalDevices.length > 0) progress.log(`Found ${physicalDevices.length} physical device(s) via instruments`, "success");
437
+ physicalDevices = tryDevicectl();
438
+ if (physicalDevices.length > 0) progress.log(`Found ${physicalDevices.length} physical device(s) via devicectl`, "success");
391
439
  if (physicalDevices.length === 0) {
392
440
  try {
393
441
  const xcodebuildOutput = execSync(`xcodebuild -scheme "${SCHEME_NAME}" -showdestinations` // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - SCHEME_NAME is generated from trusted iOS project config.
@@ -417,25 +465,6 @@ module.exports = function createBuildPhase(ctx) {
417
465
  progress.log(`Error: ${error.message}`, "error");
418
466
  }
419
467
  }
420
- if (physicalDevices.length === 0) physicalDevices = tryInstruments();
421
- if (physicalDevices.length === 0) {
422
- try {
423
- for (const line of execSync("xcrun devicectl list devices").toString().split("\n")) {
424
- if (line.includes("Connected") && !line.includes("Simulator")) {
425
- const udidMatch = line.match(/([A-F0-9-]{36})/);
426
- const nameMatch = line.match(/^(.+?)\s+\(/);
427
- if (udidMatch && nameMatch) physicalDevices.push({
428
- name: nameMatch[1].trim(),
429
- version: "Unknown",
430
- udid: udidMatch[1],
431
- type: "physical"
432
- });
433
- }
434
- }
435
- } catch {
436
- progress.log("devicectl command not available or failed", "warning");
437
- }
438
- }
439
468
  if (physicalDevices.length > 0) {
440
469
  progress.log(`Found ${physicalDevices.length} physical device(s):`, "success");
441
470
  physicalDevices.forEach(d => progress.log(` 📱 ${d.name} (${d.version || "Unknown iOS"}) - ${d.udid}`, "info"));
@@ -561,15 +590,26 @@ module.exports = function createBuildPhase(ctx) {
561
590
  try {
562
591
  progress.log("Launching iOS Simulator...");
563
592
  const simulatorsJson = JSON.parse(execSync("xcrun simctl list devices -j").toString());
593
+ // Multiple devices can share a name across runtimes (e.g. leftover devices
594
+ // from an old Xcode install after an upgrade) — simctl marks the ones whose
595
+ // runtime bundle is gone as isAvailable: false, and those fail to boot with
596
+ // "cannot determine the runtime bundle". Don't let iteration order silently
597
+ // pick one of those over the available device: prefer an available match,
598
+ // and among available matches prefer one that's already booted.
599
+ const isDeviceAvailable = device => device.isAvailable !== false;
564
600
  let foundSimulator = null,
565
601
  foundSimulatorId = null,
566
602
  isBooted = false;
567
603
  Object.entries(simulatorsJson.devices).forEach(([, devices]) => {
568
604
  devices.forEach(device => {
569
- if (device.name === simulatorName) {
605
+ if (device.name !== simulatorName) return;
606
+ const available = isDeviceAvailable(device);
607
+ const alreadyBooted = device.state === "Booted";
608
+ const isBetterMatch = !foundSimulator || available && !isDeviceAvailable(foundSimulator) || available === isDeviceAvailable(foundSimulator) && alreadyBooted && !isBooted;
609
+ if (isBetterMatch) {
570
610
  foundSimulator = device;
571
611
  foundSimulatorId = device.udid;
572
- isBooted = device.state === "Booted";
612
+ isBooted = alreadyBooted;
573
613
  }
574
614
  });
575
615
  });
@@ -600,10 +640,15 @@ module.exports = function createBuildPhase(ctx) {
600
640
  } else {
601
641
  progress.log(`Simulator ${simulatorName} is already booted`, "success");
602
642
  }
603
- progress.log("Opening Simulator.app...");
604
- await runCommand("open -a Simulator");
605
- await new Promise(resolve => setTimeout(resolve, 1000));
606
- await runCommand("osascript -e 'tell application \"Simulator\" to activate'");
643
+ const simulatorAppBundleId = resolveSimulatorAppBundleId();
644
+ if (simulatorAppBundleId) {
645
+ progress.log("Opening simulator UI...");
646
+ await runCommand(`open -b ${simulatorAppBundleId}`);
647
+ await new Promise(resolve => setTimeout(resolve, 1000));
648
+ await runCommand(`osascript -e 'tell application id "${simulatorAppBundleId}" to activate'`);
649
+ } else {
650
+ progress.log(SIMULATOR_APP_NOT_FOUND_WARNING, "warning");
651
+ }
607
652
  progress.log("iOS Simulator launched successfully.", "success");
608
653
  progress.complete("launchSimulator");
609
654
  } catch (error) {
@@ -161,6 +161,7 @@ public enum ConfigConstants {
161
161
  for (const [key, value] of Object.entries(iosConfig)) {
162
162
  if (addedKeys.has(key)) continue;
163
163
  configContent += "\n" + generateSwiftProperty(key, value);
164
+ addedKeys.add(key);
164
165
  }
165
166
  }
166
167
  if (WEBVIEW_CONFIG.accessControl) {
@@ -220,6 +221,16 @@ public enum ConfigConstants {
220
221
  configContent += '\n public static let appInfo = ""';
221
222
  addedKeys.add("appInfo");
222
223
  }
224
+
225
+ // Ensure cachePattern always exists (default to no patterns, i.e. caching
226
+ // opt-in disabled) — CacheManager.swift references ConfigConstants.cachePattern
227
+ // unconditionally, and it's only emitted above when WEBVIEW_CONFIG.ios.cachePattern
228
+ // is set, which every create-catalyst-app scaffold omits by default (#485).
229
+ if (!addedKeys.has("cachePattern")) {
230
+ progress.log("cachePattern not found in config, adding default (no patterns)", "info");
231
+ configContent += "\n" + generateSwiftProperty("cachePattern", []);
232
+ addedKeys.add("cachePattern");
233
+ }
223
234
  configContent += `\n}`;
224
235
  fs.writeFileSync(appConfigPath, configContent, "utf8");
225
236
  progress.log("Configuration constants generated successfully (SPM Package)", "success");
@@ -242,6 +242,32 @@ async function configureSimulator(config) {
242
242
  // }
243
243
  // }
244
244
 
245
+ // Xcode 27 replaced the standalone Simulator.app (com.apple.iphonesimulator)
246
+ // with DeviceHub.app (com.apple.dt.Devices); older Xcode installs still ship
247
+ // the former. Resolve whichever is actually installed instead of hardcoding
248
+ // the app name, which no longer resolves via `open -a` on either app.
249
+ function resolveSimulatorAppBundleId() {
250
+ const candidates = ["com.apple.dt.Devices", "com.apple.iphonesimulator"];
251
+ for (const bundleId of candidates) {
252
+ try {
253
+ (0, _child_process.execSync)(`osascript -e 'id of app id "${bundleId}"'`, {
254
+ stdio: "ignore"
255
+ });
256
+ return bundleId;
257
+ } catch {
258
+ // Not installed under this bundle id; try the next candidate.
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ // Text mirrors errors/registry.js ERROR_DEFINITIONS[IOS-001] (see
265
+ // errors/IOS/IOS-001.md) — this CJS subtree can't import the ESM error
266
+ // registry (see native/buildErrorFormat.js), so the message is duplicated
267
+ // here rather than imported. This condition is recoverable (the simulator
268
+ // can still boot headlessly via simctl) so callers log it as a warning
269
+ // instead of throwing.
270
+ const SIMULATOR_APP_NOT_FOUND_WARNING = "[IOS-001] Could not find an installed iOS Simulator app (checked DeviceHub.app and the legacy Simulator.app). " + "The simulator is booted but its window may not be visible. Suggested action: confirm a full Xcode.app is installed and selected via xcode-select -p, then open it once from Spotlight.";
245
271
  async function launchIOSSimulator(simulatorName) {
246
272
  progress.log("Launching iOS Simulator...");
247
273
  try {
@@ -285,15 +311,20 @@ async function launchIOSSimulator(simulatorName) {
285
311
  console.log(`Simulator ${simulatorName} is already booted`);
286
312
  }
287
313
 
288
- // Open Simulator.app and focus
289
- progress.log("Opening Simulator.app...");
290
- (0, _utils.runCommand)("open -a Simulator");
314
+ // Open the simulator UI (DeviceHub on Xcode 27+, Simulator.app on older Xcode) and focus it
315
+ const simulatorAppBundleId = resolveSimulatorAppBundleId();
316
+ if (simulatorAppBundleId) {
317
+ progress.log("Opening simulator UI...");
318
+ (0, _utils.runCommand)(`open -b ${simulatorAppBundleId}`);
291
319
 
292
- // Give the simulator a moment to open/focus
293
- await new Promise(resolve => setTimeout(resolve, 1000));
320
+ // Give the simulator a moment to open/focus
321
+ await new Promise(resolve => setTimeout(resolve, 1000));
294
322
 
295
- // Activate the Simulator.app window to bring it to front
296
- (0, _utils.runCommand)("osascript -e 'tell application \"Simulator\" to activate'");
323
+ // Activate the simulator UI window to bring it to front
324
+ (0, _utils.runCommand)(`osascript -e 'tell application id "${simulatorAppBundleId}" to activate'`);
325
+ } else {
326
+ progress.log(SIMULATOR_APP_NOT_FOUND_WARNING, "warning");
327
+ }
297
328
  console.log("iOS Simulator launched successfully.");
298
329
  } catch (error) {
299
330
  console.error("Failed to launch iOS Simulator. Error:", error.message);
@@ -24,7 +24,26 @@ const loadEnvironmentVariables = async () => {
24
24
  newConfig[key] =
25
25
  typeof process.env[key] === "object" ? JSON.stringify(process.env[key]) : process.env[key]
26
26
  }
27
- process.env = newConfig
27
+ // Merge config.json + filterKeys into the existing process.env instead of
28
+ // replacing it outright. start.js/serve.js forward the full parent
29
+ // process.env into this process (spawnSync(..., { env: { ...process.env, ... } }))
30
+ // specifically so arbitrary env vars (e.g. catalyst-ai's AI_CONFIG, or anything
31
+ // else a consumer/package expects to read from process.env) survive into the
32
+ // server process — a wholesale replace here silently discarded all of them
33
+ // except the small filterKeys allowlist, before any request handler got a
34
+ // chance to read them.
35
+ //
36
+ // Build the merge in a plain object and reassign process.env, rather than
37
+ // Object.assign(process.env, newConfig) directly: writing through the real
38
+ // process.env setter coerces every value to a string (process.env.X = true
39
+ // becomes the string "true"), which would break consumers that compare a
40
+ // config boolean strictly (e.g. expressServer.ts's
41
+ // `process.env.OTEL_ENABLE === true` — see its own comment noting it relies
42
+ // on process.env holding a real boolean here). Reassigning a plain object
43
+ // preserves the primitive types newConfig already carries, same as the
44
+ // original wholesale-replace behavior did for config.json values, while
45
+ // still keeping every pre-existing process.env entry via the spread.
46
+ process.env = { ...process.env, ...newConfig }
28
47
  } catch (error) {
29
48
  console.error("Error loading environment variables:", error)
30
49
  throw error
@@ -60,10 +60,20 @@ function findCatalystRoot() {
60
60
  let dir = start
61
61
  while (dir !== path.parse(dir).root) {
62
62
  const pkgPath = path.join(dir, "package.json")
63
+ // A package.json inside a node_modules segment is an installed copy, not
64
+ // the catalyst-core source repo itself — for the standard install layout
65
+ // (<app>/node_modules/catalyst-core/mcp_v2/mcp.js), walking up from
66
+ // __dirname hits catalyst-core's own installed package.json (name:
67
+ // "catalyst-core") before it ever reaches the consumer app's root. Only
68
+ // the isSourcePackage branch below should match on pkg.name, and only for
69
+ // a real (non-node_modules) source checkout — otherwise keep climbing so
70
+ // the walk finds the consumer's package.json instead, which matches via
71
+ // the deps["catalyst-core"] branch further down.
72
+ const isInstalledCopy = dir.split(path.sep).includes("node_modules")
63
73
  if (fs.existsSync(pkgPath)) {
64
74
  try {
65
75
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"))
66
- if (pkg.name === "catalyst-core") {
76
+ if (pkg.name === "catalyst-core" && !isInstalledCopy) {
67
77
  const sourceVersion = pkg.version || null
68
78
  return {
69
79
  dir,
@@ -86,19 +96,21 @@ function findCatalystRoot() {
86
96
  const deps = { ...pkg.dependencies, ...pkg.devDependencies }
87
97
  const declaredRef = deps["catalyst-core"]
88
98
  if (declaredRef) {
89
- const nmPath = path.join(dir, "node_modules", "catalyst-core")
90
- const installed = fs.existsSync(nmPath)
91
- // Read the actual installed version from node_modules
99
+ // A fixed `<dir>/node_modules/catalyst-core` path only finds a
100
+ // flat install. In a hoisted workspace (npm/yarn workspaces,
101
+ // pnpm with hoisting) the package can live several levels above
102
+ // `dir` instead. require.resolve with `paths` walks node_modules
103
+ // up the tree the same way Node's own module resolution does, so
104
+ // it finds the installed copy regardless of hoisting.
92
105
  let installedVersion = null
93
- if (installed) {
94
- try {
95
- const nmPkg = JSON.parse(
96
- fs.readFileSync(path.join(nmPath, "package.json"), "utf8")
97
- )
98
- installedVersion = nmPkg.version || null
99
- } catch {
100
- /* ignore */
101
- }
106
+ let installed = false
107
+ try {
108
+ const nmPkgPath = require.resolve("catalyst-core/package.json", { paths: [dir] })
109
+ const nmPkg = JSON.parse(fs.readFileSync(nmPkgPath, "utf8"))
110
+ installedVersion = nmPkg.version || null
111
+ installed = true
112
+ } catch {
113
+ /* not installed relative to dir */
102
114
  }
103
115
  const isGithubRef = declaredRef.startsWith("github:") || declaredRef.includes("#")
104
116
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "0.3.0-canary.2",
3
+ "version": "0.3.0-canary.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.jsx",
6
6
  "description": "Web framework that provides great performance out of the box",