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
@@ -1,165 +1,561 @@
1
1
  "use strict";
2
2
 
3
- const {
4
- exec,
5
- execFile,
6
- execSync
7
- } = require("child_process");
8
- const fs = require("fs");
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.DEFAULT_HMR_PORT = void 0;
7
+ exports.devServerPorts = devServerPorts;
8
+ exports.ensureDevServer = ensureDevServer;
9
+ exports.getDeviceReachableHost = getDeviceReachableHost;
10
+ exports.getLocalIPAddress = getLocalIPAddress;
11
+ exports.isProcessAlive = isProcessAlive;
12
+ exports.isServerAnswering = isServerAnswering;
13
+ exports.migrateLanAddresses = migrateLanAddresses;
14
+ exports.planLanAddressMigration = planLanAddressMigration;
15
+ exports.readHmrPort = readHmrPort;
16
+ exports.readLocalHostName = readLocalHostName;
17
+ exports.startDevServerProcess = startDevServerProcess;
18
+ var _child_process = require("child_process");
19
+ var _fs = _interopRequireDefault(require("fs"));
20
+ var _http = _interopRequireDefault(require("http"));
21
+ var _net = _interopRequireDefault(require("net"));
22
+ var _os = _interopRequireDefault(require("os"));
23
+ var _path = _interopRequireDefault(require("path"));
24
+ var _utils = require("./utils.js");
25
+ var _nativeConfig = require("./nativeConfig.js");
26
+ var _localState = require("./localState.js");
27
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
28
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
29
+ // The dev server for debug native builds. The CLI never rewrites
30
+ // config.json to reach it: Android emulators and USB phones get
31
+ // `adb reverse` on the ports below, the iOS simulator uses localhost, and a
32
+ // physical iPhone gets the Mac's Bonjour name (or LAN IP) injected into the
33
+ // build only. The server is always started through `catalyst start` with
34
+ // KEY=value overrides.
35
+
36
+ const DEFAULT_PORT = 3005;
37
+ // Vite's HMR websocket port when buildConfig.devServer.hmr sets none.
38
+ const DEFAULT_HMR_PORT = exports.DEFAULT_HMR_PORT = 24678;
39
+
40
+ /** Lower is better: the Wi-Fi/Ethernet a phone on the same network can reach. */
41
+ function interfaceRank(name) {
42
+ if (name === "en0") return 0;
43
+ if (/^(en\d+|wlan\d+|wl\w+)$/.test(name)) return 1;
44
+ if (/^(eth\d+|enp\w+|eno\w+)$/.test(name)) return 2;
45
+ return 3;
46
+ }
9
47
 
10
48
  /**
11
- * Get local IP address on macOS using ifconfig
49
+ * This machine's LAN address: the first non-internal IPv4, preferring en0 and
50
+ * Wi-Fi/Ethernet over VPN tunnels and VM bridges. Null when there is none.
51
+ *
52
+ * @param {ReturnType<typeof os.networkInterfaces>} [interfaces]
53
+ * @returns {string | null}
12
54
  */
13
- function getLocalIPAddress() {
55
+ function getLocalIPAddress(interfaces = _os.default.networkInterfaces()) {
56
+ const candidates = [];
57
+ for (const [name, entries] of Object.entries(interfaces || {})) {
58
+ for (const entry of entries || []) {
59
+ // `family` is the number 4 on Node 18.0-18.3.
60
+ const v4 = entry.family === "IPv4" || entry.family === 4;
61
+ if (v4 && !entry.internal) candidates.push({
62
+ name,
63
+ address: entry.address
64
+ });
65
+ }
66
+ }
67
+ candidates.sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name));
68
+ return candidates.length ? candidates[0].address : null;
69
+ }
70
+
71
+ /**
72
+ * The host a physical device on the same network reaches this Mac on:
73
+ * `<LocalHostName>.local` (Bonjour, survives DHCP changes), else the LAN IP.
74
+ * Null when neither is available.
75
+ *
76
+ * @param {object} [options]
77
+ * @param {() => string} [options.localHostName] - returns `scutil --get LocalHostName`
78
+ * @param {ReturnType<typeof os.networkInterfaces>} [options.interfaces]
79
+ * @returns {{ host: string, source: "bonjour" | "lan" } | null}
80
+ */
81
+ function getDeviceReachableHost({
82
+ localHostName = readLocalHostName,
83
+ interfaces
84
+ } = {}) {
85
+ let name = "";
14
86
  try {
15
- const command = `ifconfig | grep "inet " | grep -v 127.0.0.1 | head -n 1 | awk '{print $2}'`;
16
- return execSync(command).toString().trim();
87
+ name = String(localHostName() || "").trim();
17
88
  } catch (error) {
18
- console.warn("Could not get local IP, using localhost");
19
- return "localhost";
89
+ // Not macOS, or scutil failed: fall back to the LAN IP.
90
+ }
91
+ if (/^[A-Za-z0-9-]+$/.test(name)) return {
92
+ host: `${name}.local`,
93
+ source: "bonjour"
94
+ };
95
+ const ip = getLocalIPAddress(interfaces);
96
+ return ip ? {
97
+ host: ip,
98
+ source: "lan"
99
+ } : null;
100
+ }
101
+ function readLocalHostName({
102
+ platform = process.platform,
103
+ exec = _child_process.execFileSync
104
+ } = {}) {
105
+ if (platform !== "darwin") return "";
106
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - fixed argv.
107
+ return exec("scutil", ["--get", "LocalHostName"], {
108
+ encoding: "utf8",
109
+ stdio: ["ignore", "pipe", "ignore"],
110
+ timeout: 5000
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Vite's HMR port for the app: buildConfig.js `devServer.hmr.port`, else
116
+ * 24678. Best effort: an unreadable buildConfig gives the default.
117
+ *
118
+ * @param {object} [options]
119
+ * @param {string} [options.cwd]
120
+ * @returns {Promise<number>}
121
+ */
122
+ async function readHmrPort({
123
+ cwd = process.cwd()
124
+ } = {}) {
125
+ for (const name of ["buildConfig.js", "webpackConfig.js"]) {
126
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - fixed file names in the app root.
127
+ const file = _path.default.join(cwd, name);
128
+ if (!_fs.default.existsSync(file)) continue;
129
+ try {
130
+ // A plain path, not a file:// URL: the published copy is compiled
131
+ // to CommonJS, where import() becomes require(), which loads the
132
+ // app's ESM buildConfig.js by path (Node 22.12+) but not by URL.
133
+ const mod = await (specifier => new Promise(r => r(`${specifier}`)).then(s => _interopRequireWildcard(require(s))))(file);
134
+ const port = Number((mod.default || mod)?.devServer?.hmr?.port);
135
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_HMR_PORT;
136
+ } catch (error) {
137
+ return DEFAULT_HMR_PORT;
138
+ }
139
+ }
140
+ return DEFAULT_HMR_PORT;
141
+ }
142
+
143
+ /** Every `localhost:<port>` / `127.0.0.1:<port>` in the config's string values. */
144
+ function localhostPortsIn(value, found = new Set()) {
145
+ if (typeof value === "string") {
146
+ for (const match of value.matchAll(/(?:localhost|127\.0\.0\.1|\[::1\]):(\d{1,5})\b/gi)) {
147
+ const port = Number(match[1]);
148
+ if (port > 0 && port < 65536) found.add(port);
149
+ }
150
+ } else if (value && typeof value === "object") {
151
+ for (const child of Object.values(value)) localhostPortsIn(child, found);
20
152
  }
153
+ return found;
21
154
  }
22
155
 
23
156
  /**
24
- * Check if server is running on given port
157
+ * The ports an Android device needs reversed to reach the dev machine:
158
+ * NODE_SERVER_PORT, the HMR port, and every localhost:<port> in config.json
159
+ * (API_URL and the like). Sorted, no duplicates.
160
+ *
161
+ * @param {object} config - the parsed config.json
162
+ * @param {object} [options]
163
+ * @param {number} [options.hmrPort] - from readHmrPort(); 24678 when omitted
164
+ * @returns {number[]}
25
165
  */
26
- async function isServerRunning(port) {
166
+ function devServerPorts(config = {}, {
167
+ hmrPort = DEFAULT_HMR_PORT
168
+ } = {}) {
169
+ const ports = localhostPortsIn(config);
170
+ ports.add(Number(config.NODE_SERVER_PORT || config.WEBVIEW_CONFIG?.port || DEFAULT_PORT));
171
+ if (hmrPort) ports.add(Number(hmrPort));
172
+ return [...ports].filter(port => Number.isInteger(port) && port > 0).sort((a, b) => a - b);
173
+ }
174
+
175
+ /**
176
+ * Whether an HTTP server answers at host:port. Any status counts: a 500 from
177
+ * a broken page still means the server is up.
178
+ *
179
+ * @param {{ host?: string, port: number, timeoutMs?: number }} target
180
+ * @returns {Promise<boolean>}
181
+ */
182
+ function isServerAnswering({
183
+ host = "localhost",
184
+ port,
185
+ timeoutMs = 2000
186
+ }) {
27
187
  return new Promise(resolve => {
28
- const normalizedPort = Number(port);
29
- if (!Number.isInteger(normalizedPort) || normalizedPort < 1 || normalizedPort > 65535) {
188
+ const request = _http.default.get({
189
+ host,
190
+ port,
191
+ path: "/",
192
+ timeout: timeoutMs
193
+ }, response => {
194
+ response.resume();
195
+ resolve(true);
196
+ });
197
+ request.on("timeout", () => {
198
+ request.destroy();
30
199
  resolve(false);
31
- return;
32
- }
33
- execFile("lsof", ["-i", `:${normalizedPort}`], (error, stdout) => {
34
- if (error) {
35
- resolve(false);
36
- return;
37
- }
38
- resolve(stdout.includes("LISTEN"));
39
200
  });
201
+ request.on("error", () => resolve(false));
40
202
  });
41
203
  }
42
204
 
205
+ /** The bin script of this catalyst-core, for `catalyst start`. */
206
+ function catalystBin() {
207
+ // dist/native/setupServer.js (and src/native in a checkout) -> bin/catalyst.js
208
+ // eslint-disable-next-line no-undef
209
+ const here = typeof __dirname !== "undefined" ? __dirname : process.cwd();
210
+ return _path.default.resolve(here, "..", "..", "bin", "catalyst.js");
211
+ }
212
+
43
213
  /**
44
- * Start server in background using npm start
214
+ * Start `catalyst start KEY=value ...` detached, output in a log file. It has
215
+ * to outlive this process, so it gets no pipe back to it. The log goes to the
216
+ * app's .catalyst/ folder (owner only, kept out of git), not a shared temp
217
+ * directory where another user could plant a file or link at that name.
218
+ *
219
+ * @param {object} options
220
+ * @param {string} options.cwd - the app root
221
+ * @param {number} [options.port]
222
+ * @param {Record<string, string | number>} [options.overrides]
223
+ * @param {typeof spawn} [options.spawnProcess] - for tests
224
+ * @returns {{ child: import("child_process").ChildProcess, logFile: string }}
45
225
  */
46
- function startServerBackground() {
47
- const serverProcess = exec("npm start", {
226
+ function startDevServerProcess({
227
+ cwd,
228
+ port,
229
+ overrides = {},
230
+ spawnProcess = _child_process.spawn
231
+ }) {
232
+ const logFile = _path.default.join((0, _localState.ensureLocalStateDir)(cwd), `dev-server-${port || "default"}.log`);
233
+ const out = _fs.default.openSync(logFile, "a", 0o600);
234
+ const args = [catalystBin(), "start", ...Object.entries(overrides).map(([key, value]) => `${key}=${value}`)];
235
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - fixed command with an argv array.
236
+ const child = spawnProcess(process.execPath, args, {
237
+ cwd,
48
238
  detached: true,
49
- stdio: "ignore"
239
+ stdio: ["ignore", out, out],
240
+ // `catalyst start` reads the app from PWD, not cwd.
241
+ env: {
242
+ ...process.env,
243
+ PWD: cwd
244
+ }
50
245
  });
51
- if (serverProcess.pid) {
52
- serverProcess.unref(); // Allow parent to exit
246
+ child.on("error", () => {});
247
+ child.unref();
248
+ _fs.default.closeSync(out);
249
+ return {
250
+ child,
251
+ logFile
252
+ };
253
+ }
254
+ const isLoopback = host => ["localhost", "127.0.0.1", "::1"].includes(String(host).toLowerCase());
255
+
256
+ /** Whether a process with this pid exists (EPERM: it does, owned by someone else). */
257
+ function isProcessAlive(pid) {
258
+ try {
259
+ process.kill(pid, 0);
260
+ return true;
261
+ } catch (error) {
262
+ return error.code === "EPERM";
53
263
  }
54
264
  }
55
265
 
56
266
  /**
57
- * Recursively replace localhost and different IPs with current local IP
267
+ * The dev server this CLI started on `port` and that is still running, from
268
+ * .catalyst/local.json: { host, port, pid }. Null for a server the user
269
+ * started, or when the recorded process is gone.
58
270
  */
59
- function replaceIPInObject(obj, localIP, path = "") {
60
- let updated = false;
61
- for (const [key, value] of Object.entries(obj)) {
62
- const currentPath = path ? `${path}.${key}` : key;
63
- if (typeof value === "string") {
64
- let newValue = value;
65
- let shouldUpdate = false;
66
-
67
- // Replace localhost with local IP
68
- if (value.includes("localhost")) {
69
- newValue = value.replace(/localhost/g, localIP);
70
- shouldUpdate = true;
71
- }
72
-
73
- // Replace different IP addresses in URLs
74
- const urlPattern = /http:\/\/(\d+\.\d+\.\d+\.\d+)/g;
75
- const matches = [...value.matchAll(urlPattern)];
76
- for (const match of matches) {
77
- if (match[1] !== localIP) {
78
- newValue = newValue.replace(match[1], localIP);
79
- shouldUpdate = true;
80
- }
81
- }
271
+ function startedDevServer({
272
+ cwd,
273
+ port,
274
+ isAlive
275
+ }) {
276
+ const record = (0, _localState.readLocalState)({
277
+ cwd
278
+ }).devServer;
279
+ if (!record || typeof record !== "object" || Number(record.port) !== port) return null;
280
+ if (!Number.isInteger(record.pid) || record.pid <= 0 || !isAlive(record.pid)) return null;
281
+ return record;
282
+ }
82
283
 
83
- // Replace standalone IP addresses that are not localhost
84
- const ipPattern = /^(\d+\.\d+\.\d+\.\d+)$/;
85
- const ipMatch = value.match(ipPattern);
86
- if (ipMatch && ipMatch[1] !== localIP) {
87
- newValue = localIP;
88
- shouldUpdate = true;
89
- }
90
- if (shouldUpdate && newValue !== value) {
91
- obj[key] = newValue;
92
- updated = true;
284
+ /**
285
+ * Make sure the dev server answers, starting it when it does not.
286
+ *
287
+ * `host` is the host the app will load the server on: "localhost" for
288
+ * emulators, simulators and adb-reversed phones; `<mac>.local` or the LAN IP
289
+ * for a physical iPhone. For a non-loopback host the server is started bound
290
+ * to all interfaces with PUBLIC_STATIC_ASSET_URL on that host, and a server
291
+ * already running on localhost only is an error (NATIVE-006) rather than a
292
+ * silent blank screen. A server this CLI started is recorded in
293
+ * .catalyst/local.json with the host it was started for; reusing it for a
294
+ * different host (an iPhone after the simulator, or back) is NATIVE-006 too,
295
+ * because its asset URL and bind address are for the other target.
296
+ *
297
+ * @param {object} options
298
+ * @param {string} [options.host] - default "localhost"
299
+ * @param {string} [options.cwd] - the app root
300
+ * @param {object} [options.config] - the parsed config.json; read from cwd when omitted
301
+ * @param {Record<string, string | number>} [options.overrides] - extra KEY=value for `catalyst start`
302
+ * @param {number} [options.timeoutMs] - default 120000
303
+ * @param {number} [options.intervalMs] - default 500
304
+ * @param {(message: string, type?: string) => void} [options.log]
305
+ * @param {typeof isServerAnswering} [options.probe] - for tests
306
+ * @param {typeof startDevServerProcess} [options.start] - for tests
307
+ * @param {(pid: number) => boolean} [options.isAlive] - for tests
308
+ * @returns {Promise<{ started: boolean, url: string, port: number, logFile?: string }>}
309
+ */
310
+ async function ensureDevServer({
311
+ host = "localhost",
312
+ cwd = process.cwd(),
313
+ config,
314
+ overrides = {},
315
+ timeoutMs = 120000,
316
+ intervalMs = 500,
317
+ log = () => {},
318
+ probe = isServerAnswering,
319
+ start = startDevServerProcess,
320
+ isAlive = isProcessAlive
321
+ } = {}) {
322
+ const appConfig =
323
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - cwd is the app root; the file name is fixed.
324
+ config || JSON.parse(_fs.default.readFileSync(_path.default.join(cwd, "config", "config.json"), "utf8"));
325
+ const port = Number(appConfig.NODE_SERVER_PORT || appConfig.WEBVIEW_CONFIG?.port || DEFAULT_PORT);
326
+ const loopback = isLoopback(host);
327
+ const hostPart = String(host).includes(":") ? `[${host}]` : host;
328
+ const url = `http://${hostPart}:${port}`;
329
+ const answers = () => probe({
330
+ host: loopback ? "localhost" : host,
331
+ port
332
+ });
333
+ const hostKey = loopback ? "localhost" : String(host).toLowerCase();
334
+ if (await answers()) {
335
+ const ours = startedDevServer({
336
+ cwd,
337
+ port,
338
+ isAlive
339
+ });
340
+ if (ours && ours.host !== hostKey) {
341
+ throw (0, _utils.nativeError)("NATIVE-006", `the dev server on port ${port} was started for ${ours.host}, and this build needs ${url}`, {
342
+ hint: `Stop that dev server (kill ${ours.pid}) and run the build again, so Catalyst starts one for ${hostKey}.`
343
+ });
344
+ }
345
+ log(`Dev server already running at ${url}`, "info");
346
+ return {
347
+ started: false,
348
+ url,
349
+ port
350
+ };
351
+ }
352
+ if (!loopback && (await probe({
353
+ host: "localhost",
354
+ port
355
+ }))) {
356
+ throw (0, _utils.nativeError)("NATIVE-006", `a dev server is running on port ${port} but only for this machine, so ${url} does not answer`, {
357
+ hint: "Stop the running dev server (Ctrl+C in its terminal) and run the build again, so Catalyst starts one the phone can reach."
358
+ });
359
+ }
360
+ const startOverrides = {
361
+ ...(loopback ? {} : {
362
+ NODE_SERVER_HOSTNAME: "0.0.0.0",
363
+ PUBLIC_STATIC_ASSET_URL: url
364
+ }),
365
+ // Vite 6 answers 403 to a Host header that is not localhost or an IP
366
+ // (server.allowedHosts), so a `<mac>.local` name has to be allowed.
367
+ ...(loopback || _net.default.isIP(host) ? {} : {
368
+ CATALYST_DEV_ALLOWED_HOSTS: host
369
+ }),
370
+ ...overrides
371
+ };
372
+ const {
373
+ child,
374
+ logFile
375
+ } = start({
376
+ cwd,
377
+ port,
378
+ overrides: startOverrides
379
+ });
380
+ log(`Starting the dev server at ${url} (output in ${logFile})`, "info");
381
+ if (Number.isInteger(child?.pid)) {
382
+ (0, _localState.writeLocalState)({
383
+ devServer: {
384
+ host: hostKey,
385
+ port,
386
+ pid: child.pid
93
387
  }
94
- } else if (typeof value === "object" && value !== null) {
95
- // Recursively process nested objects
96
- const nestedUpdated = replaceIPInObject(value, localIP, currentPath);
97
- updated = updated || nestedUpdated;
388
+ }, {
389
+ cwd
390
+ });
391
+ }
392
+ let exited = null;
393
+ child?.on?.("exit", code => {
394
+ exited = code ?? "signal";
395
+ });
396
+ const deadline = Date.now() + timeoutMs;
397
+ while (Date.now() < deadline) {
398
+ if (await answers()) return {
399
+ started: true,
400
+ url,
401
+ port,
402
+ logFile
403
+ };
404
+ if (exited !== null) {
405
+ throw (0, _utils.nativeError)("NATIVE-006", `catalyst start exited (${exited}) before ${url} answered; output in ${logFile}`);
98
406
  }
407
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
99
408
  }
100
- return updated;
409
+ throw (0, _utils.nativeError)("NATIVE-006", `${url} did not answer within ${Math.round(timeoutMs / 1000)}s; output in ${logFile}`);
101
410
  }
102
411
 
103
- /**
104
- * Update config.json with current local IP address
105
- */
106
- function updateConfigWithLocalIP(configPath, localIP, log = () => {}) {
107
- try {
108
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
412
+ // ---------------------------------------------------------------------------
413
+ // One-time migration: older setups wrote this machine's LAN address into
414
+ // config.json (every "localhost", and any http://<ip>), which makes Express
415
+ // bind only that address and breaks adb reverse. Put localhost back, but only
416
+ // for this machine's own addresses: a container IP, 0.0.0.0 or a loopback
417
+ // address is the app's own setting.
418
+ // ---------------------------------------------------------------------------
109
419
 
110
- // Recursively update all IP addresses in the config
111
- const updated = replaceIPInObject(config, localIP);
420
+ const MIGRATION_KEY = "lanAddresses";
421
+ const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
422
+ const isCI = env => {
423
+ const value = env.CI;
424
+ return value !== undefined && value !== "" && value !== "0" && String(value).toLowerCase() !== "false";
425
+ };
112
426
 
113
- // Write back if updated
114
- if (updated) {
115
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
116
- log(`Updated config addresses to ${localIP}`);
427
+ /**
428
+ * The changes the migration would make, without writing anything. Only a
429
+ * value equal to one of this machine's current IPv4 addresses, or to the
430
+ * private address in WEBVIEW_CONFIG.LOCAL_IP (what an earlier setup wrote
431
+ * there, even after DHCP moved the machine), or an http:// URL on one of
432
+ * those hosts, goes back to localhost. WEBVIEW_CONFIG.url is never touched,
433
+ * and nothing is when WEBVIEW_CONFIG.allowPrivateHost is true.
434
+ *
435
+ * @param {object} config - the parsed config.json (not modified)
436
+ * @param {object} [options]
437
+ * @param {Set<string>} [options.localHosts] - this machine's addresses; detected when omitted
438
+ * @returns {{ config: object, changes: { path: string, from: string, to: string }[] }}
439
+ */
440
+ function planLanAddressMigration(config, {
441
+ localHosts = (0, _nativeConfig.localMachineHosts)()
442
+ } = {}) {
443
+ const changes = [];
444
+ const wv = config?.WEBVIEW_CONFIG;
445
+ // An app that opts into private hosts means its addresses.
446
+ if (wv?.allowPrivateHost === true) return {
447
+ config,
448
+ changes
449
+ };
450
+ // This machine's IPv4 addresses, without loopback and 0.0.0.0/8.
451
+ const saved = new Set([...localHosts].filter(host => IPV4.test(host) && !/^(?:127|0)\./.test(host)));
452
+ const localIp = typeof wv?.LOCAL_IP === "string" ? wv.LOCAL_IP.trim() : "";
453
+ if ((0, _nativeConfig.isPrivateIPv4)(localIp)) saved.add(localIp);
454
+ if (!saved.size) return {
455
+ config,
456
+ changes
457
+ };
458
+ const restore = value => {
459
+ if (saved.has(value.trim())) return "localhost";
460
+ return value.replace(/http:\/\/(\d{1,3}(?:\.\d{1,3}){3})(?=[:/?#]|$)/g, (match, ip) => saved.has(ip) ? "http://localhost" : match);
461
+ };
462
+ const walk = (value, at) => {
463
+ // WEBVIEW_CONFIG.url is new: no earlier setup ever wrote it.
464
+ if (at === "WEBVIEW_CONFIG.url") return value;
465
+ if (typeof value === "string") {
466
+ const next = restore(value);
467
+ if (next !== value) changes.push({
468
+ path: at,
469
+ from: value,
470
+ to: next
471
+ });
472
+ return next;
117
473
  }
118
- return config;
119
- } catch (error) {
120
- console.error("Error updating config:", error.message);
121
- throw error;
122
- }
474
+ if (Array.isArray(value)) return value.map((item, index) => walk(item, `${at}[${index}]`));
475
+ if (value && typeof value === "object") {
476
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, walk(child, at ? `${at}.${key}` : key)]));
477
+ }
478
+ return value;
479
+ };
480
+ return {
481
+ config: walk(config, ""),
482
+ changes
483
+ };
123
484
  }
124
485
 
125
486
  /**
126
- * Main function: Setup server with IP validation and background startup
127
- * @param {string} configPath - Path to config.json file
128
- * @returns {Promise<{serverURL: string, localIP: string, port: number}>}
129
- */
130
- /**
131
- * @param {string} configPath
132
- * @param {(message: string, type?: string) => void} [log] - progress sink.
133
- * Defaults to silence: this runs inside a progress sequence, where a stray
134
- * console.log lands at column 0 and collides with the live spinner row.
487
+ * Run the LAN-address migration once per checkout (recorded in
488
+ * .catalyst/local.json). Rewrites config/config.json only when something
489
+ * changes, keeps its indentation, and logs each change. Never throws: a
490
+ * failure is logged as a warning and the build goes on. It never runs for a
491
+ * release (`--release` or the deprecated buildType "release") or in CI: a
492
+ * store build or a CI checkout must not rewrite the config it was given.
493
+ *
494
+ * @param {object} [options]
495
+ * @param {string} [options.cwd]
496
+ * @param {string} [options.configPath] - defaults to <cwd>/config/config.json
497
+ * @param {boolean} [options.force] - run even when already recorded
498
+ * @param {boolean} [options.release] - `--release`
499
+ * @param {"android"|"ios"} [options.platform] - whose buildType to check
500
+ * @param {Record<string, string | undefined>} [options.env]
501
+ * @param {Set<string>} [options.localHosts] - this machine's addresses; detected when omitted
502
+ * @param {(message: string, type?: string) => void} [options.log]
503
+ * @returns {{ ran: boolean, changes: { path: string, from: string, to: string }[] }}
135
504
  */
136
- async function setupServer(configPath, log = () => {}) {
137
- // Get local IP
138
- const localIP = getLocalIPAddress();
139
-
140
- // Update config with current IP
141
- const config = updateConfigWithLocalIP(configPath, localIP, log);
142
-
143
- // Get port from config
144
- const port = config.WEBVIEW_CONFIG?.port || 3005;
145
- const serverURL = `http://${localIP}:${port}`;
146
-
147
- // Check if server is running
148
- const running = await isServerRunning(port);
149
- if (!running) {
150
- log(`Starting server at ${serverURL}`);
151
- startServerBackground();
152
- // Give it a moment to start
153
- await new Promise(resolve => setTimeout(resolve, 3000));
154
- } else {
155
- log(`Server already running at ${serverURL}`);
156
- }
157
- return {
158
- serverURL,
159
- localIP,
160
- port
505
+ function migrateLanAddresses({
506
+ cwd = process.cwd(),
507
+ configPath,
508
+ force = false,
509
+ release = false,
510
+ platform,
511
+ env = process.env,
512
+ localHosts,
513
+ log = () => {}
514
+ } = {}) {
515
+ const skipped = {
516
+ ran: false,
517
+ changes: []
161
518
  };
162
- }
163
- module.exports = {
164
- setupServer
165
- };
519
+ if (release || isCI(env)) return skipped;
520
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - cwd is the app root; the file name is fixed.
521
+ const file = configPath || _path.default.join(cwd, "config", "config.json");
522
+ if (!force && (0, _localState.readLocalState)({
523
+ cwd
524
+ }).migrations?.[MIGRATION_KEY]) return skipped;
525
+ try {
526
+ const text = _fs.default.readFileSync(file, "utf8");
527
+ const parsed = JSON.parse(text);
528
+ const buildType = platform ? parsed?.WEBVIEW_CONFIG?.[platform]?.buildType : undefined;
529
+ if (String(buildType || "").toLowerCase() === "release") return skipped;
530
+ const {
531
+ config,
532
+ changes
533
+ } = planLanAddressMigration(parsed, localHosts ? {
534
+ localHosts
535
+ } : undefined);
536
+ if (changes.length) {
537
+ const indent = /^[ \t]+(?=")/m.exec(text)?.[0] || " ";
538
+ _fs.default.writeFileSync(file, JSON.stringify(config, null, indent) + (text.endsWith("\n") ? "\n" : ""));
539
+ log(`Put localhost back in config/config.json where an earlier setup saved this machine's LAN address:`, "info");
540
+ for (const change of changes) log(` ${change.path}: ${change.from} -> ${change.to}`, "info");
541
+ }
542
+ const state = (0, _localState.readLocalState)({
543
+ cwd
544
+ });
545
+ (0, _localState.writeLocalState)({
546
+ migrations: {
547
+ ...(state.migrations || {}),
548
+ [MIGRATION_KEY]: new Date().toISOString()
549
+ }
550
+ }, {
551
+ cwd
552
+ });
553
+ return {
554
+ ran: true,
555
+ changes
556
+ };
557
+ } catch (error) {
558
+ log(`Could not check config/config.json for saved LAN addresses: ${error.message}`, "warning");
559
+ return skipped;
560
+ }
561
+ }