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,4 +1,4 @@
1
- import { execSync, spawn } from "child_process"
1
+ import { execFileSync, execSync, spawn } from "child_process"
2
2
  import fs from "fs"
3
3
  import os from "os"
4
4
  import path from "path"
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
8
8
  // utils.js is ESM, so it is imported here the same way redactSecrets.test.ts
9
9
  // imports it: one module copy for v8 coverage to merge.
10
10
  //
11
- // execSync and spawn are wrapped so each test can fake a tool's answer, while
11
+ // execSync, execFileSync and spawn are wrapped so each test can fake a tool's answer, while
12
12
  // anything a test does not stub still runs for real (node itself, never adb
13
13
  // or an emulator).
14
14
  vi.mock("child_process", async (importOriginal) => {
@@ -17,6 +17,7 @@ vi.mock("child_process", async (importOriginal) => {
17
17
  ...actual,
18
18
  default: actual,
19
19
  execSync: vi.fn(actual.execSync),
20
+ execFileSync: vi.fn(actual.execFileSync),
20
21
  spawn: vi.fn(actual.spawn),
21
22
  }
22
23
  })
@@ -31,12 +32,13 @@ import {
31
32
  redactError,
32
33
  redactSecrets,
33
34
  runCommand,
35
+ runFile,
36
+ runInheritedCommand,
34
37
  runInteractiveCommand,
35
- runSdkManagerCommand,
36
- validateAndCompleteConfig,
37
38
  } from "../../src/native/utils.js"
38
39
 
39
40
  const mockedExec = vi.mocked(execSync)
41
+ const mockedExecFile = vi.mocked(execFileSync)
40
42
  const mockedSpawn = vi.mocked(spawn)
41
43
  const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g")
42
44
  const plain = (text: unknown) => String(text).replace(ANSI, "")
@@ -71,6 +73,7 @@ afterEach(() => {
71
73
  vi.restoreAllMocks()
72
74
  // mockReset puts back the real implementation vi.fn was given.
73
75
  mockedExec.mockReset()
76
+ mockedExecFile.mockReset()
74
77
  mockedSpawn.mockReset()
75
78
  setTTY(originalTTY)
76
79
  for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
@@ -147,6 +150,89 @@ describe("runCommand", () => {
147
150
  })
148
151
  })
149
152
 
153
+ describe("runFile", () => {
154
+ it("runs the program with an argv array and no shell", () => {
155
+ mockedExecFile.mockImplementationOnce(() => "List of devices attached\n" as any)
156
+ const adb = "/sdk dir/$(touch x)/adb"
157
+ expect(runFile(adb, ["devices"], { timeout: 15000 })).toBe("List of devices attached\n")
158
+ expect(mockedExecFile).toHaveBeenCalledWith(adb, ["devices"], {
159
+ encoding: "utf8",
160
+ stdio: ["ignore", "pipe", "pipe"],
161
+ timeout: 15000,
162
+ })
163
+ expect(mockedExec).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it("refuses an empty path or one with a NUL or line break", () => {
167
+ for (const bad of ["", " ", "/sdk/adb\nrm -rf /", "/sdk/adb\u0000", undefined]) {
168
+ expect(() => runFile(bad as any, ["version"])).toThrow(/Not a valid tool path/)
169
+ }
170
+ expect(mockedExecFile).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it("names the tool on a timeout, even when its folder has spaces", () => {
174
+ mockedExecFile.mockImplementationOnce(() => {
175
+ throw Object.assign(new Error("spawnSync ETIMEDOUT"), { code: "ETIMEDOUT" })
176
+ })
177
+ let error: any
178
+ try {
179
+ runFile("/Android Sdk/platform-tools/adb", ["devices"], { timeout: 10000 })
180
+ } catch (caught) {
181
+ error = caught
182
+ }
183
+ expect(error.message).toBe("adb did not respond within 10s")
184
+ expect(error.hint).toContain("adb kill-server")
185
+ expect(console.error).not.toHaveBeenCalled()
186
+ })
187
+
188
+ it("logs the failure with passwords redacted and rethrows", () => {
189
+ const failure = Object.assign(new Error("Command failed: keytool -storepass hunter2"), { status: 1 })
190
+ mockedExecFile.mockImplementationOnce(() => {
191
+ throw failure
192
+ })
193
+ expect(() => runFile("keytool", ["-storepass", "hunter2"])).toThrow(failure)
194
+ expect(failure.message).toBe("Command failed: keytool -storepass ***")
195
+ const logged = vi.mocked(console.error).mock.calls.flat().join("\n")
196
+ expect(logged).toContain("Error executing command: keytool -storepass ***")
197
+ expect(logged).not.toContain("hunter2")
198
+ })
199
+
200
+ it("runs a real program", () => {
201
+ expect(runFile(process.execPath, ["-e", "process.stdout.write('ok')"])).toBe("ok")
202
+ })
203
+ })
204
+
205
+ describe("runInheritedCommand", () => {
206
+ it("resolves on exit code 0 and passes env and cwd through", async () => {
207
+ const cwd = tempDir()
208
+ await runInheritedCommand(process.execPath, ["-e", "process.exit(0)"], {
209
+ env: { ...process.env },
210
+ cwd,
211
+ })
212
+ expect(mockedSpawn).toHaveBeenLastCalledWith(process.execPath, ["-e", "process.exit(0)"], {
213
+ stdio: "inherit",
214
+ env: expect.any(Object),
215
+ cwd,
216
+ })
217
+ })
218
+
219
+ it("rejects with the exit code", async () => {
220
+ const error: any = await runInheritedCommand(process.execPath, ["-e", "process.exit(3)"]).catch(
221
+ (caught) => caught
222
+ )
223
+ expect(error.message).toBe(`${process.execPath} exited with code 3`)
224
+ expect(error.code).toBe(3)
225
+ })
226
+
227
+ it("names a command that is not installed", async () => {
228
+ const error: any = await runInheritedCommand("catalyst-no-such-tool-xyz", []).catch(
229
+ (caught) => caught
230
+ )
231
+ expect(error.message).toBe("Command not found: catalyst-no-such-tool-xyz")
232
+ expect(error.cause.code).toBe("ENOENT")
233
+ })
234
+ })
235
+
150
236
  describe("redactSecrets and redactError", () => {
151
237
  it("redacts every password form and passes non-strings through", () => {
152
238
  expect(
@@ -237,23 +323,30 @@ describe("listAvds", () => {
237
323
  it("asks the SDK's emulator, expanding ~ and dropping INFO noise", () => {
238
324
  const home = tempDir()
239
325
  vi.spyOn(os, "homedir").mockReturnValue(home)
240
- mockedExec.mockImplementationOnce(() => "INFO | Storing crashdata\nPixel_7_API_34\n\n Tablet \n")
326
+ mockedExecFile.mockImplementationOnce(
327
+ () => "INFO | Storing crashdata\nPixel_7_API_34\n\n Tablet \n" as any
328
+ )
241
329
 
242
330
  expect(listAvds("~/sdk")).toEqual(["Pixel_7_API_34", "Tablet"])
243
- expect(mockedExec).toHaveBeenLastCalledWith(
244
- `"${path.join(home, "sdk", "emulator", "emulator")}" -list-avds`,
331
+ // The path is one argv entry, never a shell string.
332
+ expect(mockedExecFile).toHaveBeenLastCalledWith(
333
+ path.join(home, "sdk", "emulator", "emulator"),
334
+ ["-list-avds"],
245
335
  { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }
246
336
  )
247
337
  })
248
338
 
249
339
  it("returns [] without an SDK or when the emulator cannot be run", () => {
250
340
  expect(listAvds(null)).toEqual([])
251
- expect(mockedExec).not.toHaveBeenCalled()
341
+ expect(mockedExecFile).not.toHaveBeenCalled()
252
342
 
253
- mockedExec.mockImplementationOnce(() => {
343
+ mockedExecFile.mockImplementationOnce(() => {
254
344
  throw new Error("ENOENT")
255
345
  })
256
346
  expect(listAvds("/no/sdk")).toEqual([])
347
+ // A path with a line break is refused before anything runs.
348
+ expect(listAvds("/sdk\nrm")).toEqual([])
349
+ expect(mockedExecFile).toHaveBeenCalledTimes(1)
257
350
  })
258
351
  })
259
352
 
@@ -411,122 +504,4 @@ describe("runInteractiveCommand", () => {
411
504
  expect(output.endsWith("END\n")).toBe(true)
412
505
  expect(output).not.toContain("got:")
413
506
  })
414
-
415
- it("feeds sdkmanager's licence prompts a yes", async () => {
416
- const script = `
417
- process.stdout.write("Accept? (y/N): ")
418
- process.stdin.once("data", (answer) => { console.log("answer=" + String(answer).trim()); process.stdin.destroy() })
419
- `
420
- await expect(runSdkManagerCommand(node, ["-e", script])).resolves.toContain("answer=y")
421
- })
422
- })
423
-
424
- describe("validateAndCompleteConfig", () => {
425
- function writeConfig(config: unknown) {
426
- const file = path.join(tempDir(), "config.json")
427
- fs.writeFileSync(file, typeof config === "string" ? config : JSON.stringify(config))
428
- return file
429
- }
430
- const read = (file: string) => JSON.parse(fs.readFileSync(file, "utf8"))
431
-
432
- it("returns a complete config untouched", async () => {
433
- const android = { buildType: "release", sdkPath: "/sdk", emulatorName: "Pixel" }
434
- const file = writeConfig({ WEBVIEW_CONFIG: { port: "3005", android } })
435
- mockedExec.mockImplementation(() => "Pixel\n")
436
- const before = fs.readFileSync(file, "utf8")
437
-
438
- await expect(validateAndCompleteConfig("android", file)).resolves.toEqual({ port: "3005", android })
439
- expect(fs.readFileSync(file, "utf8")).toBe(before)
440
- })
441
-
442
- it("fills what it can work out for Android and saves it without asking", async () => {
443
- const sdk = fakeSdk()
444
- const file = writeConfig({ NODE_SERVER_PORT: 3005, WEBVIEW_CONFIG: { android: { sdkPath: sdk } } })
445
- mockedExec.mockImplementation(() => "Pixel_7_API_34\n")
446
- setTTY(false)
447
-
448
- const result = await validateAndCompleteConfig("android", file)
449
-
450
- expect(result).toEqual({
451
- port: "3005",
452
- android: { sdkPath: sdk, buildType: "debug", emulatorName: "Pixel_7_API_34" },
453
- })
454
- expect(read(file).WEBVIEW_CONFIG).toEqual(result)
455
- const saved = plain(vi.mocked(console.log).mock.calls.flat().join("\n"))
456
- expect(saved).toContain("Saved port, buildType, emulatorName to config/config.json")
457
- })
458
-
459
- it("detects the SDK when none is configured", async () => {
460
- const sdk = fakeSdk()
461
- vi.stubEnv("ANDROID_HOME", sdk)
462
- mockedExec.mockImplementation(() => {
463
- throw new Error("no emulator")
464
- })
465
- const file = writeConfig({
466
- WEBVIEW_CONFIG: { port: "3005", android: { buildType: "debug", emulatorName: "Pixel" } },
467
- })
468
- setTTY(false)
469
- try {
470
- const result = await validateAndCompleteConfig("android", file)
471
- expect(result.android.sdkPath).toBe(sdk)
472
- } finally {
473
- vi.unstubAllEnvs()
474
- }
475
- })
476
-
477
- it("fails with ENOTTY for an iOS field it cannot guess in CI", async () => {
478
- const file = writeConfig({ WEBVIEW_CONFIG: { port: "3005", ios: { buildType: "debug" } } })
479
- setTTY(false)
480
- const error: any = await validateAndCompleteConfig("ios", file).catch((caught) => caught)
481
- expect(error.code).toBe("ENOTTY")
482
- expect(error.message).toContain("iOS bundle ID")
483
- })
484
-
485
- it("asks on the terminal for what it cannot guess", async () => {
486
- const file = writeConfig({ WEBVIEW_CONFIG: { port: "3005", ios: { buildType: "debug" } } })
487
- setTTY(true)
488
- const answers = ["com.example.app", "iPhone 16 Pro"]
489
- vi.spyOn(readline, "createInterface").mockImplementation(
490
- () =>
491
- ({
492
- question: (_text: string, callback: (answer: string) => void) =>
493
- callback(answers.shift()!),
494
- close: () => {},
495
- }) as any
496
- )
497
- vi.spyOn(process.stdout, "write").mockImplementation(() => true)
498
-
499
- await expect(validateAndCompleteConfig("ios", file)).resolves.toEqual({
500
- port: "3005",
501
- ios: { buildType: "debug", appBundleId: "com.example.app", simulatorName: "iPhone 16 Pro" },
502
- })
503
- expect(read(file).WEBVIEW_CONFIG.ios.simulatorName).toBe("iPhone 16 Pro")
504
- })
505
-
506
- it("starts from an empty config when the file is unreadable, then rejects an unknown platform", async () => {
507
- const file = writeConfig("{ not json")
508
- await expect(validateAndCompleteConfig("windows", file)).rejects.toThrow(
509
- 'Invalid platform. Must be either "android" or "ios"'
510
- )
511
- expect(plain(vi.mocked(console.error).mock.calls.flat().join("\n"))).toContain(
512
- "Error reading config file"
513
- )
514
- })
515
-
516
- it("surfaces a config file it cannot save", async () => {
517
- const file = writeConfig({
518
- WEBVIEW_CONFIG: {
519
- port: "3005",
520
- ios: { buildType: "", appBundleId: "a.b", simulatorName: "iPhone" },
521
- },
522
- })
523
- vi.spyOn(fs, "writeFileSync").mockImplementation(() => {
524
- throw new Error("EROFS: read-only file system")
525
- })
526
-
527
- await expect(validateAndCompleteConfig("ios", file)).rejects.toThrow("EROFS")
528
- expect(plain(vi.mocked(console.error).mock.calls.flat().join("\n"))).toContain(
529
- "Error saving config file: EROFS"
530
- )
531
- })
532
507
  })
@@ -6,6 +6,7 @@ import {
6
6
  toMountPathPrefix,
7
7
  resolveDevFsAllow,
8
8
  buildDevServer,
9
+ withExtraAllowedHosts,
9
10
  } from "../../src/vite/resolveDevServerConfig.js"
10
11
 
11
12
  // ─── resolveDevBase ───────────────────────────────────────────────────────────
@@ -114,3 +115,27 @@ test("buildDevServer: 'base' is not leaked into the server block", () => {
114
115
  const server = buildDevServer({ base: "/o-mweb" }, { frameworkPaths: FRAMEWORK, isProduction: false })
115
116
  assert.equal(server.base, undefined)
116
117
  })
118
+
119
+ // ─── CATALYST_DEV_ALLOWED_HOSTS (buildApp:ios on a physical iPhone) ───────────
120
+
121
+ test("withExtraAllowedHosts: adds the env hosts to the app's list", () => {
122
+ assert.deepEqual(withExtraAllowedHosts(undefined, "my-mac.local"), ["my-mac.local"])
123
+ assert.deepEqual(withExtraAllowedHosts(["a.test"], "my-mac.local, a.test"), ["a.test", "my-mac.local"])
124
+ assert.equal(withExtraAllowedHosts(true, "my-mac.local"), true)
125
+ assert.equal(withExtraAllowedHosts(undefined, ""), undefined)
126
+ })
127
+
128
+ test("buildDevServer: CATALYST_DEV_ALLOWED_HOSTS reaches server.allowedHosts", () => {
129
+ const before = process.env.CATALYST_DEV_ALLOWED_HOSTS
130
+ process.env.CATALYST_DEV_ALLOWED_HOSTS = "my-mac.local"
131
+ try {
132
+ const server = buildDevServer(
133
+ { allowedHosts: ["local.example.com"] },
134
+ { frameworkPaths: FRAMEWORK, isProduction: false }
135
+ )
136
+ assert.deepEqual(server.allowedHosts, ["local.example.com", "my-mac.local"])
137
+ } finally {
138
+ if (before === undefined) delete process.env.CATALYST_DEV_ALLOWED_HOSTS
139
+ else process.env.CATALYST_DEV_ALLOWED_HOSTS = before
140
+ }
141
+ })