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
@@ -0,0 +1,646 @@
1
+ import { execFileSync } from "child_process"
2
+ import fs from "fs"
3
+ import os from "os"
4
+ import path from "path"
5
+ import { afterEach, describe, expect, it } from "vitest"
6
+
7
+ import {
8
+ PLAY_UPLOAD_KEY_RESET_URL,
9
+ configHasPasswords,
10
+ createUploadKeystore,
11
+ distinguishedName,
12
+ ensureGitignored,
13
+ exportUploadCertificate,
14
+ legacyKeystorePath,
15
+ readKeystoreProperties,
16
+ resolveAndroidSigning,
17
+ signingGradleEnv,
18
+ verifyKeystore,
19
+ writeKeystoreProperties,
20
+ } from "../../src/native/buildAndroid/signing.js"
21
+ import { releaseArtifactPaths, releaseOutputDir } from "../../src/native/buildAndroid/output.js"
22
+ import { assertReleaseFolderFree } from "../../src/native/releaseOutput.js"
23
+
24
+ const temps: string[] = []
25
+ function tempDir() {
26
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-android-signing-test-"))
27
+ temps.push(dir)
28
+ return dir
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
33
+ })
34
+
35
+ const mode = (file: string) => fs.statSync(file).mode & 0o777
36
+
37
+ /** The key files in an app's credentials/android/. */
38
+ function credentialPaths(app: string) {
39
+ const dir = path.join(app, "credentials", "android")
40
+ return {
41
+ dir,
42
+ storeFile: path.join(dir, "upload.jks"),
43
+ propertiesFile: path.join(dir, "keystore.properties"),
44
+ }
45
+ }
46
+
47
+ /** A keytool stand-in: records calls, "creates" keystores, and can reject -list. */
48
+ function fakeKeytool({ rejectList = false } = {}) {
49
+ const calls: Array<{ args: string[]; env: NodeJS.ProcessEnv }> = []
50
+ const exec = (_cmd: string, args: string[], options: { env: NodeJS.ProcessEnv }) => {
51
+ calls.push({ args, env: options.env })
52
+ if (args[0] === "-genkeypair") fs.writeFileSync(args[args.indexOf("-keystore") + 1], "keystore")
53
+ if (args[0] === "-list" && rejectList) {
54
+ throw Object.assign(new Error("Command failed"), {
55
+ stderr: "keytool error: java.io.IOException: keystore password was incorrect",
56
+ })
57
+ }
58
+ return ""
59
+ }
60
+ return { exec, calls }
61
+ }
62
+
63
+ /** A CATALYST_ANDROID_CREDENTIALS value: base64 of a gzipped tar of the given files. */
64
+ function packSecret(files: Record<string, string>) {
65
+ const dir = tempDir()
66
+ for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body)
67
+ return execFileSync("tar", ["-czf", "-", "-C", dir, "."], {
68
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
69
+ }).toString("base64")
70
+ }
71
+
72
+ const properties = (storePassword: string, keyAlias = "upload") =>
73
+ `storeFile=upload.jks\nkeyAlias=${keyAlias}\nstorePassword=${storePassword}\nkeyPassword=${storePassword}\n`
74
+
75
+ /** Answers by question: create the key, never uploaded to Play, unless told otherwise. */
76
+ function answers({ create = "y", onPlay = "n" } = {}) {
77
+ const questions: string[] = []
78
+ const prompt = async (question: string) => {
79
+ questions.push(question)
80
+ return /Google Play/.test(question) ? onPlay : create
81
+ }
82
+ return { questions, prompt }
83
+ }
84
+
85
+ async function rejection(promise: Promise<unknown>): Promise<any> {
86
+ try {
87
+ await promise
88
+ } catch (error) {
89
+ return error
90
+ }
91
+ throw new Error("expected a rejection")
92
+ }
93
+
94
+ describe("keystore.properties and .gitignore", () => {
95
+ it("round-trips the properties file with 0600 permissions", () => {
96
+ const file = path.join(tempDir(), "credentials", "android", "keystore.properties")
97
+ writeKeystoreProperties(file, { keyAlias: "upload", storePassword: "s3cr=t", keyPassword: "k" })
98
+ expect(readKeystoreProperties(file)).toEqual({
99
+ storeFile: "upload.jks",
100
+ keyAlias: "upload",
101
+ storePassword: "s3cr=t",
102
+ keyPassword: "k",
103
+ })
104
+ if (process.platform !== "win32") expect(mode(file)).toBe(0o600)
105
+ })
106
+
107
+ it("reads nothing from a missing file", () => {
108
+ expect(readKeystoreProperties(path.join(tempDir(), "nope.properties"))).toEqual({})
109
+ })
110
+
111
+ it("appends credentials/ once, and respects an existing rule", () => {
112
+ const app = tempDir()
113
+ fs.writeFileSync(path.join(app, ".gitignore"), "node_modules")
114
+ expect(ensureGitignored(app)).toBe(true)
115
+ expect(fs.readFileSync(path.join(app, ".gitignore"), "utf8")).toBe("node_modules\ncredentials/\n")
116
+ expect(ensureGitignored(app)).toBe(false)
117
+
118
+ const other = tempDir()
119
+ fs.writeFileSync(path.join(other, ".gitignore"), "/credentials\n")
120
+ expect(ensureGitignored(other)).toBe(false)
121
+ })
122
+
123
+ it("creates .gitignore when the app has none", () => {
124
+ const app = tempDir()
125
+ expect(ensureGitignored(app)).toBe(true)
126
+ expect(fs.readFileSync(path.join(app, ".gitignore"), "utf8")).toBe("credentials/\n")
127
+ })
128
+ })
129
+
130
+ describe("keytool use", () => {
131
+ it("builds and escapes the certificate DN", () => {
132
+ expect(
133
+ distinguishedName({ companyName: "Acme, Inc", city: "Pune", state: "MH", countryCode: "IN" })
134
+ ).toBe("CN=Acme\\, Inc, O=Acme\\, Inc, L=Pune, ST=MH, C=IN")
135
+ expect(distinguishedName(undefined, "My App")).toBe("CN=My App")
136
+ })
137
+
138
+ it("hands passwords to keytool in its environment, never argv", () => {
139
+ const { exec, calls } = fakeKeytool()
140
+ const store = path.join(tempDir(), "upload.jks")
141
+ createUploadKeystore(store, { keyAlias: "upload", password: "hunter2" }, { exec, env: {} })
142
+ verifyKeystore({ storeFile: store, keyAlias: "upload", storePassword: "hunter2" }, { exec, env: {} })
143
+ for (const call of calls) {
144
+ expect(call.args.join(" ")).not.toContain("hunter2")
145
+ expect(call.env.CATALYST_KEYTOOL_STOREPASS).toBe("hunter2")
146
+ }
147
+ expect(calls[0].args).toEqual(expect.arrayContaining(["-storepass:env", "-keypass:env", "PKCS12"]))
148
+ if (process.platform !== "win32") expect(mode(store)).toBe(0o600)
149
+ })
150
+
151
+ it("reports a rejected password as ANDROID-006", () => {
152
+ const { exec } = fakeKeytool({ rejectList: true })
153
+ const error = (() => {
154
+ try {
155
+ verifyKeystore(
156
+ { storeFile: "/x/upload.jks", keyAlias: "upload", storePassword: "bad" },
157
+ { exec, env: {} }
158
+ )
159
+ } catch (e) {
160
+ return e as any
161
+ }
162
+ })()
163
+ expect(error.code).toBe("ANDROID-006")
164
+ expect(error.message).toContain("password was incorrect")
165
+ })
166
+ })
167
+
168
+ describe("resolveAndroidSigning", () => {
169
+ const noPrompt = async () => {
170
+ throw new Error("should not prompt")
171
+ }
172
+
173
+ it("unpacks CATALYST_ANDROID_CREDENTIALS to a private temp folder, masks the passwords, and cleans up", async () => {
174
+ const { exec } = fakeKeytool()
175
+ const app = tempDir()
176
+ const masked: string[] = []
177
+ const env = {
178
+ CATALYST_ANDROID_CREDENTIALS: packSecret({
179
+ "upload.jks": "binary-keystore",
180
+ "keystore.properties": properties("store-pw", "ci"),
181
+ }),
182
+ }
183
+ const signing = await resolveAndroidSigning({
184
+ appRoot: app,
185
+ env,
186
+ isTTY: false,
187
+ prompt: noPrompt,
188
+ exec,
189
+ mask: (value: string) => void masked.push(value),
190
+ })
191
+ expect(fs.readFileSync(signing.storeFile, "utf8")).toBe("binary-keystore")
192
+ expect(signing.storeFile.startsWith(app)).toBe(false)
193
+ expect(signing).toMatchObject({
194
+ storePassword: "store-pw",
195
+ keyPassword: "store-pw",
196
+ keyAlias: "ci",
197
+ source: "CATALYST_ANDROID_CREDENTIALS",
198
+ })
199
+ expect(masked).toEqual(["store-pw"])
200
+ // The project folder is never written from the secret.
201
+ expect(fs.existsSync(path.join(app, "credentials"))).toBe(false)
202
+ signing.cleanup()
203
+ expect(fs.existsSync(signing.storeFile)).toBe(false)
204
+ })
205
+
206
+ it("fails a secret without a password or without the key (ANDROID-006) and removes the temp folder", async () => {
207
+ const { exec } = fakeKeytool()
208
+ const count = () =>
209
+ fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith("catalyst-android-credentials-"))
210
+ .length
211
+ const before = count()
212
+ const noPassword = await rejection(
213
+ resolveAndroidSigning({
214
+ appRoot: tempDir(),
215
+ env: { CATALYST_ANDROID_CREDENTIALS: packSecret({ "upload.jks": "k" }) },
216
+ isTTY: false,
217
+ exec,
218
+ })
219
+ )
220
+ expect(noPassword.code).toBe("ANDROID-006")
221
+ const noKey = await rejection(
222
+ resolveAndroidSigning({
223
+ appRoot: tempDir(),
224
+ env: {
225
+ CATALYST_ANDROID_CREDENTIALS: packSecret({ "keystore.properties": properties("pw") }),
226
+ },
227
+ isTTY: true,
228
+ prompt: noPrompt,
229
+ exec,
230
+ })
231
+ )
232
+ expect(noKey.code).toBe("ANDROID-006")
233
+ expect(noKey.message).toContain("CATALYST_ANDROID_CREDENTIALS has no upload.jks")
234
+ expect(count()).toBe(before)
235
+ })
236
+
237
+ it("uses the app's credentials/android key and its keystore.properties", async () => {
238
+ const app = tempDir()
239
+ const paths = credentialPaths(app)
240
+ fs.mkdirSync(paths.dir, { recursive: true })
241
+ fs.writeFileSync(paths.storeFile, "keystore")
242
+ writeKeystoreProperties(paths.propertiesFile, {
243
+ keyAlias: "upload",
244
+ storePassword: "p1",
245
+ keyPassword: "p2",
246
+ })
247
+ const { exec } = fakeKeytool()
248
+ const signing = await resolveAndroidSigning({
249
+ appRoot: app,
250
+ env: {},
251
+ isTTY: false,
252
+ prompt: noPrompt,
253
+ exec,
254
+ })
255
+ expect(signing).toMatchObject({
256
+ storeFile: paths.storeFile,
257
+ keyAlias: "upload",
258
+ storePassword: "p1",
259
+ keyPassword: "p2",
260
+ })
261
+ expect(fs.readFileSync(path.join(app, ".gitignore"), "utf8")).toContain("credentials/")
262
+ })
263
+
264
+ it("uses the keystore file keystore.properties names, inside credentials/android", async () => {
265
+ const app = tempDir()
266
+ const dir = path.join(app, "credentials", "android")
267
+ fs.mkdirSync(dir, { recursive: true })
268
+ fs.writeFileSync(path.join(dir, "play.keystore"), "keystore")
269
+ fs.writeFileSync(
270
+ path.join(dir, "keystore.properties"),
271
+ "storeFile=../../elsewhere/play.keystore\nkeyAlias=mine\nstorePassword=pw\n"
272
+ )
273
+ const { exec } = fakeKeytool()
274
+ const signing = await resolveAndroidSigning({ appRoot: app, env: {}, isTTY: false, exec })
275
+ expect(signing.storeFile).toBe(path.join(dir, "play.keystore"))
276
+ expect(signing.keyAlias).toBe("mine")
277
+ })
278
+
279
+ it("refuses to create a key when keystore.properties is there without its keystore", async () => {
280
+ const app = tempDir()
281
+ const paths = credentialPaths(app)
282
+ fs.mkdirSync(paths.dir, { recursive: true })
283
+ fs.writeFileSync(paths.propertiesFile, properties("old-pw"))
284
+ const { prompt, questions } = answers()
285
+ const error = await rejection(resolveAndroidSigning({ appRoot: app, env: {}, isTTY: true, prompt }))
286
+ expect(error.code).toBe("ANDROID-006")
287
+ expect(error.message).toContain("restore the key")
288
+ expect(questions).toEqual([])
289
+ expect(fs.readFileSync(paths.propertiesFile, "utf8")).toBe(properties("old-pw"))
290
+ expect(fs.existsSync(paths.storeFile)).toBe(false)
291
+ })
292
+
293
+ it("fails without a key and without a terminal (ANDROID-005)", async () => {
294
+ const error = await rejection(
295
+ resolveAndroidSigning({ appRoot: tempDir(), env: {}, isTTY: false, prompt: noPrompt })
296
+ )
297
+ expect(error.code).toBe("ANDROID-005")
298
+ expect(error.hint).toContain("CATALYST_ANDROID_CREDENTIALS")
299
+ expect(error.hint).toContain("catalyst credentials:export android")
300
+ })
301
+
302
+ it("names a legacy build-output keystore when there is no terminal", async () => {
303
+ const app = tempDir()
304
+ const legacy = path.join(app, "build-output", "shop-keystore.jks")
305
+ fs.mkdirSync(path.dirname(legacy))
306
+ fs.writeFileSync(legacy, "old")
307
+ const error = await rejection(
308
+ resolveAndroidSigning({ appRoot: app, env: {}, isTTY: false, legacyKeystorePath: legacy })
309
+ )
310
+ expect(error.code).toBe("ANDROID-005")
311
+ expect(error.message).toContain("adopt")
312
+ })
313
+
314
+ it("never creates a key without a terminal, even with every flag", async () => {
315
+ const app = tempDir()
316
+ const error = await rejection(
317
+ resolveAndroidSigning({ appRoot: app, env: { CI: "true" }, isTTY: false, prompt: noPrompt })
318
+ )
319
+ expect(error.code).toBe("ANDROID-005")
320
+ expect(fs.existsSync(path.join(app, "credentials"))).toBe(false)
321
+ })
322
+
323
+ it("creates a key after a yes and a 'not on Play', with a generated password kept out of argv", async () => {
324
+ const app = tempDir()
325
+ const { exec, calls } = fakeKeytool()
326
+ const { prompt, questions } = answers()
327
+ const logs: string[] = []
328
+ const signing = await resolveAndroidSigning({
329
+ appRoot: app,
330
+ androidConfig: { keystoreConfig: { organizationInfo: { companyName: "Acme" } } },
331
+ env: {},
332
+ isTTY: true,
333
+ prompt,
334
+ log: (message: string) => logs.push(message),
335
+ exec,
336
+ })
337
+ const paths = credentialPaths(app)
338
+ expect(questions).toHaveLength(2)
339
+ expect(questions[0]).toContain("Create one in")
340
+ expect(questions[1]).toContain("Google Play")
341
+ expect(signing).toMatchObject({ storeFile: paths.storeFile, keyAlias: "upload", created: true })
342
+ expect(signing.storePassword).toMatch(/^[A-Za-z0-9_-]{32}$/)
343
+ expect(readKeystoreProperties(paths.propertiesFile).storePassword).toBe(signing.storePassword)
344
+ if (process.platform !== "win32") expect(mode(paths.propertiesFile)).toBe(0o600)
345
+ expect(fs.readFileSync(path.join(app, ".gitignore"), "utf8")).toBe("credentials/\n")
346
+ for (const call of calls) expect(call.args.join(" ")).not.toContain(signing.storePassword)
347
+ expect(calls[0].args).toContain("CN=Acme, O=Acme")
348
+ expect(logs.some((line) => line.includes("catalyst credentials:export android"))).toBe(true)
349
+ })
350
+
351
+ it("refuses a new key for an app already on Google Play, pointing to the upload key reset", async () => {
352
+ const app = tempDir()
353
+ const { exec, calls } = fakeKeytool()
354
+ const { prompt } = answers({ onPlay: "y" })
355
+ const error = await rejection(
356
+ resolveAndroidSigning({ appRoot: app, env: {}, isTTY: true, prompt, exec })
357
+ )
358
+ expect(error.code).toBe("ANDROID-005")
359
+ expect(error.hint).toContain(PLAY_UPLOAD_KEY_RESET_URL)
360
+ expect(calls).toEqual([])
361
+ expect(fs.existsSync(credentialPaths(app).storeFile)).toBe(false)
362
+ })
363
+
364
+ it("fails when the user declines to create a key", async () => {
365
+ const { prompt, questions } = answers({ create: "n" })
366
+ const error = await rejection(
367
+ resolveAndroidSigning({ appRoot: tempDir(), env: {}, isTTY: true, prompt })
368
+ )
369
+ expect(error.code).toBe("ANDROID-005")
370
+ expect(questions).toHaveLength(1)
371
+ })
372
+
373
+ it("never overwrites an existing key or its settings (#508)", () => {
374
+ const dir = tempDir()
375
+ const store = path.join(dir, "upload.jks")
376
+ const props = path.join(dir, "keystore.properties")
377
+ fs.writeFileSync(store, "the key Play knows")
378
+ fs.writeFileSync(props, properties("original"))
379
+ const { exec, calls } = fakeKeytool()
380
+ expect(() => createUploadKeystore(store, { password: "new" }, { exec, env: {} })).toThrow(
381
+ /Refusing to replace/
382
+ )
383
+ expect(() =>
384
+ writeKeystoreProperties(props, { keyAlias: "upload", storePassword: "new", keyPassword: "new" })
385
+ ).toThrow(/EEXIST/)
386
+ expect(calls).toEqual([])
387
+ expect(fs.readFileSync(store, "utf8")).toBe("the key Play knows")
388
+ expect(fs.readFileSync(props, "utf8")).toBe(properties("original"))
389
+ })
390
+
391
+ it("adopts a legacy keystore with the config's passwords and warns about them", async () => {
392
+ const app = tempDir()
393
+ const legacy = path.join(app, "build-output", "shop-keystore.jks")
394
+ fs.mkdirSync(path.dirname(legacy))
395
+ fs.writeFileSync(legacy, "old-key")
396
+ const { exec } = fakeKeytool()
397
+ const logs: Array<[string, string | undefined]> = []
398
+ const signing = await resolveAndroidSigning({
399
+ appRoot: app,
400
+ androidConfig: { keystoreConfig: { keyAlias: "release", storePassword: "legacy-pw" } },
401
+ env: {},
402
+ isTTY: true,
403
+ prompt: async () => "yes",
404
+ log: (message: string, type?: string) => logs.push([message, type]),
405
+ legacyKeystorePath: legacy,
406
+ exec,
407
+ })
408
+ expect(fs.readFileSync(signing.storeFile, "utf8")).toBe("old-key")
409
+ expect(signing).toMatchObject({ keyAlias: "release", storePassword: "legacy-pw" })
410
+ expect(
411
+ logs.some(([message, type]) => type === "warning" && /holds keystore passwords/.test(message))
412
+ ).toBe(true)
413
+ })
414
+
415
+ it("adopts a legacy keystore from the older android.keystore shape, with its alias", async () => {
416
+ const app = tempDir()
417
+ const legacy = path.join(app, "build-output", "shop-keystore.jks")
418
+ fs.mkdirSync(path.dirname(legacy))
419
+ fs.writeFileSync(legacy, "old-key")
420
+ const { exec, calls } = fakeKeytool()
421
+ const signing = await resolveAndroidSigning({
422
+ appRoot: app,
423
+ androidConfig: { keystore: { alias: "shop", storePassword: "older-pw" } },
424
+ env: {},
425
+ isTTY: true,
426
+ prompt: async () => "yes",
427
+ legacyKeystorePath: legacy,
428
+ exec,
429
+ })
430
+ expect(signing).toMatchObject({ keyAlias: "shop", storePassword: "older-pw", source: "adopted" })
431
+ expect(readKeystoreProperties(credentialPaths(app).propertiesFile)).toMatchObject({
432
+ keyAlias: "shop",
433
+ storePassword: "older-pw",
434
+ })
435
+ expect(calls.map((call) => call.args[0])).toEqual(["-list"])
436
+ })
437
+
438
+ it("removes the unpacked secret when its key does not open (ANDROID-006)", async () => {
439
+ const { exec, calls } = fakeKeytool({ rejectList: true })
440
+ const masked: string[] = []
441
+ const error = await rejection(
442
+ resolveAndroidSigning({
443
+ appRoot: tempDir(),
444
+ env: {
445
+ CATALYST_ANDROID_CREDENTIALS: packSecret({
446
+ "upload.jks": "k",
447
+ "keystore.properties": `storeFile=upload.jks\nkeyAlias=ci\nstorePassword=pw1\nkeyPassword=pw2\n`,
448
+ }),
449
+ },
450
+ isTTY: false,
451
+ exec,
452
+ mask: (value: string) => void masked.push(value),
453
+ })
454
+ )
455
+ expect(error.code).toBe("ANDROID-006")
456
+ expect(error.message).toContain("keystore password was incorrect")
457
+ // Both passwords are masked, and the folder the secret was unpacked to is gone.
458
+ expect(masked).toEqual(["pw1", "pw2"])
459
+ const storeFile = calls[0].args[calls[0].args.indexOf("-keystore") + 1]
460
+ expect(path.basename(storeFile)).toBe("upload.jks")
461
+ expect(fs.existsSync(path.dirname(storeFile))).toBe(false)
462
+ })
463
+
464
+ it("names keytool's first line when the key cannot be created or exported", () => {
465
+ const failing = () => {
466
+ throw Object.assign(new Error("Command failed"), {
467
+ stderr: "keytool error: java.lang.Exception: bad dname\n\tat sun.security.tools\n",
468
+ })
469
+ }
470
+ const dir = tempDir()
471
+ expect(() =>
472
+ createUploadKeystore(
473
+ path.join(dir, "upload.jks"),
474
+ { password: "pw", organizationInfo: {} },
475
+ { exec: failing, env: {} }
476
+ )
477
+ ).toThrow("keytool could not create the upload key: keytool error: java.lang.Exception: bad dname")
478
+ expect(fs.existsSync(path.join(dir, "upload.jks"))).toBe(false)
479
+
480
+ expect(() =>
481
+ exportUploadCertificate(
482
+ { storeFile: "/k/upload.jks", keyAlias: "upload", storePassword: "pw" },
483
+ path.join(dir, "out", "upload_certificate.pem"),
484
+ { exec: failing, env: {} }
485
+ )
486
+ ).toThrow(
487
+ "keytool could not export the upload certificate: keytool error: java.lang.Exception: bad dname"
488
+ )
489
+
490
+ const silent = () => {
491
+ throw new Error("spawn keytool EACCES")
492
+ }
493
+ expect(() =>
494
+ exportUploadCertificate(
495
+ { storeFile: "/k/upload.jks", keyAlias: "upload", storePassword: "pw" },
496
+ path.join(dir, "out", "upload_certificate.pem"),
497
+ { exec: silent, env: {} }
498
+ )
499
+ ).toThrow("keytool could not export the upload certificate: spawn keytool EACCES")
500
+ })
501
+
502
+ it("appends to a .gitignore without a trailing newline through one descriptor", () => {
503
+ const app = tempDir()
504
+ fs.writeFileSync(path.join(app, ".gitignore"), "node_modules\r\ndist")
505
+ expect(ensureGitignored(app)).toBe(true)
506
+ expect(fs.readFileSync(path.join(app, ".gitignore"), "utf8")).toBe(
507
+ "node_modules\r\ndist\ncredentials/\n"
508
+ )
509
+ expect(ensureGitignored(app)).toBe(false)
510
+ })
511
+
512
+ it("detects passwords in either legacy config shape", () => {
513
+ expect(configHasPasswords({ keystore: { storePassword: "x" } })).toBe(true)
514
+ expect(configHasPasswords({ keystoreConfig: { organizationInfo: {} } })).toBe(false)
515
+ })
516
+
517
+ it("exports the upload certificate as PEM, password in the environment", () => {
518
+ const { exec, calls } = fakeKeytool()
519
+ const dest = path.join(tempDir(), "build-output", "android", "1.2.0-42", "upload_certificate.pem")
520
+ const out = exportUploadCertificate(
521
+ { storeFile: "/k/upload.jks", keyAlias: "upload", storePassword: "secret-pw" },
522
+ dest,
523
+ { exec, env: {} }
524
+ )
525
+ expect(out).toBe(dest)
526
+ expect(fs.existsSync(path.dirname(dest))).toBe(true)
527
+ expect(calls[0].args).toEqual(
528
+ expect.arrayContaining(["-exportcert", "-rfc", "-keystore", "/k/upload.jks", "-file", dest])
529
+ )
530
+ expect(calls[0].args.join(" ")).not.toContain("secret-pw")
531
+ expect(calls[0].env.CATALYST_KEYTOOL_STOREPASS).toBe("secret-pw")
532
+ })
533
+
534
+ it("maps the key to Gradle project properties", () => {
535
+ expect(
536
+ signingGradleEnv({ storeFile: "/k.jks", storePassword: "s", keyAlias: "a", keyPassword: "k" })
537
+ ).toEqual({
538
+ ORG_GRADLE_PROJECT_catalystStoreFile: "/k.jks",
539
+ ORG_GRADLE_PROJECT_catalystStorePassword: "s",
540
+ ORG_GRADLE_PROJECT_catalystKeyAlias: "a",
541
+ ORG_GRADLE_PROJECT_catalystKeyPassword: "k",
542
+ })
543
+ })
544
+ })
545
+
546
+ function hasKeytool() {
547
+ try {
548
+ execFileSync("keytool", ["-help"], { stdio: "ignore" })
549
+ return true
550
+ } catch {
551
+ return false
552
+ }
553
+ }
554
+
555
+ describe.skipIf(!hasKeytool())("with the real keytool", () => {
556
+ it("creates a PKCS12 upload key that verifies, and rejects a wrong password", () => {
557
+ const store = path.join(tempDir(), "upload.jks")
558
+ createUploadKeystore(store, { keyAlias: "upload", password: "correct-horse", commonName: "Test" })
559
+ expect(() =>
560
+ verifyKeystore({ storeFile: store, keyAlias: "upload", storePassword: "correct-horse" })
561
+ ).not.toThrow()
562
+ expect(() =>
563
+ verifyKeystore({ storeFile: store, keyAlias: "upload", storePassword: "wrong-pass" })
564
+ ).toThrow(/Upload key settings/)
565
+ const pem = exportUploadCertificate(
566
+ { storeFile: store, keyAlias: "upload", storePassword: "correct-horse" },
567
+ path.join(path.dirname(store), "upload_certificate.pem")
568
+ )
569
+ expect(fs.readFileSync(pem, "utf8")).toMatch(/^-----BEGIN CERTIFICATE-----/)
570
+ }, 30000)
571
+ })
572
+
573
+ describe("release output", () => {
574
+ const version = { versionName: "1.2.0", buildNumber: 1440 }
575
+
576
+ it("puts each release in build-output/android/<version>-<build>/", () => {
577
+ const dir = releaseOutputDir({ appRoot: "/app", version })
578
+ expect(dir).toBe(path.join("/app", "build-output", "android", "1.2.0-1440"))
579
+ expect(releaseArtifactPaths(dir, "shop")).toEqual({
580
+ aab: path.join(dir, "shop.aab"),
581
+ certificate: path.join(dir, "upload_certificate.pem"),
582
+ })
583
+ })
584
+
585
+ it("honours android.outputPath and a missing version", () => {
586
+ expect(
587
+ releaseOutputDir({
588
+ appRoot: "/app",
589
+ androidConfig: { outputPath: "dist/store" },
590
+ version: { buildNumber: 7 },
591
+ })
592
+ ).toBe(path.join("/app", "dist", "store", "1.0.0-7"))
593
+ })
594
+
595
+ it("makes the app name and version safe for file names", () => {
596
+ const dir = releaseOutputDir({
597
+ appRoot: "/app",
598
+ version: { versionName: "2.0.0/beta 1", buildNumber: 3 },
599
+ })
600
+ expect(dir).toBe(path.join("/app", "build-output", "android", "2.0.0-beta-1-3"))
601
+ expect(releaseArtifactPaths(dir, 'My Shop: "Pro"').aab).toBe(path.join(dir, "My-Shop-Pro.aab"))
602
+ expect(releaseArtifactPaths(dir, "").aab).toBe(path.join(dir, "app.aab"))
603
+ })
604
+
605
+ it("refuses to build over an earlier release with the same build number (NATIVE-011)", () => {
606
+ const dir = path.join(tempDir(), "1.2.0-1440")
607
+ expect(() => assertReleaseFolderFree(dir, [".aab"])).not.toThrow()
608
+ fs.mkdirSync(dir)
609
+ fs.writeFileSync(path.join(dir, "upload_certificate.pem"), "pem")
610
+ expect(() => assertReleaseFolderFree(dir, [".aab"])).not.toThrow()
611
+ fs.writeFileSync(path.join(dir, "shop.aab"), "aab")
612
+ let error: any
613
+ try {
614
+ assertReleaseFolderFree(dir, [".aab"])
615
+ } catch (caught) {
616
+ error = caught
617
+ }
618
+ expect(error.code).toBe("NATIVE-011")
619
+ expect(error.message).toContain("shop.aab")
620
+ expect(error.hint).toContain("CATALYST_BUILD_NUMBER")
621
+ })
622
+ })
623
+
624
+ describe("legacyKeystorePath", () => {
625
+ it("names the keystore the way releases before 1.1.1 did, from the config as written", () => {
626
+ // A config that now also sets the top-level appName and appId: the
627
+ // resolved android block carries those, the raw one still has the
628
+ // names the old release used.
629
+ const raw = { appName: "shop", packageName: "com.acme.shop" }
630
+ expect(legacyKeystorePath(raw, { cwd: "/app" })).toBe(
631
+ path.join("/app", "build-output", "shop-keystore.jks")
632
+ )
633
+ expect(legacyKeystorePath({ newProjectName: "store", appName: "shop" }, { cwd: "/app" })).toBe(
634
+ path.join("/app", "build-output", "store-keystore.jks")
635
+ )
636
+ expect(legacyKeystorePath({ packageName: "com.acme.market" }, { cwd: "/app" })).toBe(
637
+ path.join("/app", "build-output", "market-keystore.jks")
638
+ )
639
+ expect(legacyKeystorePath({ outputPath: "dist" }, { cwd: "/app" })).toBe(
640
+ path.join("/app", "dist", "catalystapp-keystore.jks")
641
+ )
642
+ expect(legacyKeystorePath(undefined, { cwd: "/app" })).toBe(
643
+ path.join("/app", "build-output", "catalystapp-keystore.jks")
644
+ )
645
+ })
646
+ })