@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.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.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +69 -0
  24. package/executors.ts +30 -27
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -3,9 +3,11 @@ import path from "node:path";
3
3
  import type { AkanPlugin } from "akanjs";
4
4
  import type { AkanNativeTarget } from "../akanConfig";
5
5
  import type { App } from "../commandDecorators";
6
+ import { CsrArtifactBuilder } from "../frontendBuild/csrArtifactBuilder";
6
7
  import { tempDirs, writeText } from "../testHelpers";
7
8
  import { NativeApp } from "./nativeApp";
8
9
  import { NativeConfig } from "./nativeConfig";
10
+ import { NativePublicEnv } from "./nativePublicEnv";
9
11
 
10
12
  const makeTempRoot = tempDirs("akan-native-app-");
11
13
 
@@ -88,6 +90,139 @@ describe("NativeApp", () => {
88
90
  });
89
91
  });
90
92
 
93
+ describe("the server a release carries, signed or not", () => {
94
+ const releasing = async () => {
95
+ const root = await makeTempRoot();
96
+ const appPath = path.join(root, "apps/portal");
97
+ await writeText(
98
+ path.join(appPath, "env/env.server.main.ts"),
99
+ 'export const env = { security: { jwtSecret: "main-jwt-0123456789" } };\n',
100
+ );
101
+ const server = { dir: path.join(root, "server"), entry: "main.js", env: {} };
102
+ await writeText(path.join(server.dir, "main.js"), 'const jwt = "main-jwt-0123456789";\n');
103
+ const app = {
104
+ ...fakeApp(),
105
+ cwdPath: appPath,
106
+ workspace: { workspaceRoot: root },
107
+ logger: { info: () => undefined, warn: () => undefined },
108
+ } as unknown as App;
109
+ const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
110
+ const nativeApp = new NativeApp(app, target({ updates }), "main");
111
+ const built: string[] = [];
112
+ nativeApp.assembleWeb = async () => undefined as never;
113
+ nativeApp.prepare = async () =>
114
+ ({
115
+ config: {},
116
+ api: {
117
+ requestedDistribution: () => ({ notice: null, unused: [] }),
118
+ build: async ({ profile }: { profile: string }) => {
119
+ built.push(profile);
120
+ return { artifacts: [], warnings: [] };
121
+ },
122
+ publishUpdate: async () => {
123
+ built.push("update");
124
+ return {};
125
+ },
126
+ },
127
+ }) as never;
128
+ return { appPath, server, nativeApp, built };
129
+ };
130
+
131
+ test("an unsigned release build and an unsigned update are refused when it holds a secret, a debug build is not searched", async () => {
132
+ const { server, nativeApp, built } = await releasing();
133
+ await expect(nativeApp.build("linux", { server })).rejects.toThrow(
134
+ "main.js holds apps/portal/env/env.server.main.ts security.jwtSecret",
135
+ );
136
+ await expect(nativeApp.publishUpdate("linux", { server })).rejects.toThrow("security.jwtSecret");
137
+ await nativeApp.build("linux", { server, profile: "debug" });
138
+ expect(built).toEqual(["debug"]);
139
+ });
140
+
141
+ test("an env file that cannot be evaluated stops the release instead of going unsearched", async () => {
142
+ const { appPath, server, nativeApp, built } = await releasing();
143
+ await writeText(path.join(server.dir, "main.js"), "\n");
144
+ await writeText(path.join(appPath, "env/env.server.develop.ts"), 'export { env } from "./missing";\n');
145
+ await expect(nativeApp.build("linux", { server })).rejects.toThrow(
146
+ "The server this app carries was not searched for the secret values of apps/portal/env/env.server.develop.ts",
147
+ );
148
+ expect(built).toEqual([]);
149
+ });
150
+ });
151
+
152
+ describe("the public env a build inlined, recorded in its bundle.json", () => {
153
+ const publicEnv = { AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com" };
154
+ const building = async (inlined: Record<string, string>, updates: object | undefined) => {
155
+ const root = await makeTempRoot();
156
+ const warned: string[] = [];
157
+ const app = {
158
+ ...fakeApp(),
159
+ workspace: { workspaceRoot: root },
160
+ dist: { cwdPath: path.join(root, "dist/apps/portal") },
161
+ logger: { info: () => undefined, warn: (message: string) => warned.push(message) },
162
+ } as unknown as App;
163
+ await CsrArtifactBuilder.writePublicEnv(
164
+ app,
165
+ Object.fromEntries(
166
+ Object.entries({ ...inlined, AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_RENDER_ENV: "csr" }).map(
167
+ ([key, value]) => [`process.env.${key}`, JSON.stringify(value)],
168
+ ),
169
+ ),
170
+ );
171
+ const nativeApp = new NativeApp(app, target(updates ? { updates } : {}), "main");
172
+ const built: string[] = [];
173
+ const writeBundle = async (dir: string) =>
174
+ await writeText(path.join(dir, "bundle.json"), JSON.stringify({ schema: 1, web: { hash: "w", files: 1 } }));
175
+ nativeApp.assembleWeb = async () => undefined as never;
176
+ nativeApp.prepare = async () =>
177
+ ({
178
+ config: { updates: { channel: "production" } },
179
+ api: {
180
+ packUpdate: async ({ out }: { out: string }) => {
181
+ built.push("pack");
182
+ await writeBundle(out);
183
+ return { out, manifest: { app: "com.portal.app", version: "1.0.0", nativeApi: "n", files: [] } };
184
+ },
185
+ release: async ({ outDir }: { outDir: string }) => {
186
+ built.push("release");
187
+ await writeBundle(outDir);
188
+ return {};
189
+ },
190
+ },
191
+ }) as never;
192
+ return { root, nativeApp, built, warned };
193
+ };
194
+ const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
195
+
196
+ test("a pack and a release record what the CSR build inlined, and the pack's record carries its hash", async () => {
197
+ const { root, nativeApp } = await building(publicEnv, updates);
198
+ const { pack } = await nativeApp.packUpdate("android", { out: path.join(root, "out") });
199
+ const bundle = await Bun.file(path.join(root, "out/bundle.json")).json();
200
+ expect(bundle.web).toEqual({
201
+ hash: "w",
202
+ files: 1,
203
+ publicEnv,
204
+ publicEnvHash: NativePublicEnv.hash(publicEnv),
205
+ });
206
+ expect(pack.publicEnvHash).toBe(NativePublicEnv.hash(publicEnv));
207
+ await nativeApp.releaseIos();
208
+ const release = await Bun.file(path.join(nativeApp.outDir("ios"), "bundle.json")).json();
209
+ expect(release.web.publicEnvHash).toBe(NativePublicEnv.hash(publicEnv));
210
+ });
211
+
212
+ test("an env an update cannot carry stops a target with updates before it builds, and only warns without", async () => {
213
+ const refused = { ...publicEnv, AKAN_PUBLIC_MAP_TOKEN: "pk.x" };
214
+ const withUpdates = await building(refused, updates);
215
+ await expect(withUpdates.nativeApp.releaseIos()).rejects.toThrow("AKAN_PUBLIC_MAP_TOKEN names a token");
216
+ expect(withUpdates.built).toEqual([]);
217
+ const without = await building(refused, undefined);
218
+ await without.nativeApp.releaseIos();
219
+ expect(without.built).toEqual(["release"]);
220
+ expect(without.warned.join("\n")).toContain("bundle.json records none");
221
+ const release = await Bun.file(path.join(without.nativeApp.outDir("ios"), "bundle.json")).json();
222
+ expect(release.web.publicEnv).toBeUndefined();
223
+ });
224
+ });
225
+
91
226
  test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
92
227
  const root = await makeTempRoot();
93
228
  const app = {
@@ -232,36 +367,3 @@ describe("NativeApp", () => {
232
367
  );
233
368
  });
234
369
  });
235
-
236
- describe("a pack read back from its folder", () => {
237
- test("takes the folder as the pack's place, and refuses what is not a takeable pack", async () => {
238
- const root = await makeTempRoot();
239
- const pack = {
240
- schema: 1,
241
- app: "lu",
242
- target: "default",
243
- platform: "android",
244
- env: "main",
245
- channel: "main",
246
- appId: "com.lu",
247
- appVersion: "1.0.0",
248
- nativeApi: "0123456789abcdef",
249
- webHash: "f".repeat(64),
250
- files: 1,
251
- size: 13,
252
- out: "/elsewhere/updates/android",
253
- };
254
- const moved = path.join(root, "moved");
255
- await writeText(path.join(moved, "pack.json"), JSON.stringify(pack));
256
- expect(await NativeApp.readPack(moved)).toEqual({ ...pack, out: moved } as never);
257
-
258
- await expect(NativeApp.readPack(path.join(root, "empty"))).rejects.toThrow("holds no pack.json");
259
- const later = path.join(root, "later");
260
- await writeText(path.join(later, "pack.json"), JSON.stringify({ ...pack, schema: 2 }));
261
- await expect(NativeApp.readPack(later)).rejects.toThrow("schema 2");
262
- const refused = path.join(root, "refused");
263
- const compat = { against: "store/bundle.json", compatible: false, problems: ["deepLinks differ"] };
264
- await writeText(path.join(refused, "pack.json"), JSON.stringify({ ...pack, compat }));
265
- expect((await NativeApp.readPack(refused)).compat).toEqual(compat);
266
- });
267
- });
@@ -1,7 +1,7 @@
1
- import { rm } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import type { AkanNativeConfig } from "@akanjs/native/config";
4
3
  import type { NativeEnv } from "akanjs";
4
+ import { toError } from "akanjs/common";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { Executor } from "../executors";
7
7
  import { type DesktopArch, DesktopBin } from "./desktopBin";
@@ -17,13 +17,16 @@ import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
17
17
  import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
18
18
  import { NativeConfig } from "./nativeConfig";
19
19
  import { NativePluginFolders } from "./nativePluginFolders";
20
+ import { NativePublicEnv, type PublicEnv } from "./nativePublicEnv";
20
21
  import { NativeWebDir } from "./nativeWebDir";
21
22
  import { ServerBundleSecrets } from "./serverBundleSecrets";
23
+ import { type NativeUpdatePack, UpdatePackFolder } from "./updatePackFolder";
22
24
 
23
25
  type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
24
26
  type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
25
27
  type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
26
28
  type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
29
+ type PhoneRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
27
30
 
28
31
  export interface NativeRunOptions {
29
32
  device?: DeviceSelector;
@@ -56,25 +59,6 @@ export interface NativeDevOptions extends NativeRunOptions {
56
59
  lang: string;
57
60
  }
58
61
 
59
- /** `<out>/pack.json`: what a signer needs of a pack, read instead of the log. */
60
- export interface NativeUpdatePack {
61
- schema: 1;
62
- app: string;
63
- target: string;
64
- platform: MobilePlatform;
65
- env: NativeEnv;
66
- channel: string;
67
- appId: string;
68
- appVersion: string;
69
- nativeApi: string;
70
- /** bundle.json `web.hash`: the web files as served, the part of a bundle id that names its content. */
71
- webHash: string;
72
- files: number;
73
- size: number;
74
- out: string;
75
- compat?: { against: string; compatible: boolean; problems: string[] };
76
- }
77
-
78
62
  /** One native target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
79
63
  * what it ships, and the calls into the API. */
80
64
  export class NativeApp {
@@ -224,16 +208,23 @@ export class NativeApp {
224
208
  NativeApp.#assertServerPlatform(platform, server);
225
209
  NativeApp.assertInstaller(platform, installer);
226
210
  await this.assembleWeb(platform);
211
+ const publicEnv = await this.#publicEnv();
227
212
  const { api, config } = await this.prepare(platform, server, arch);
228
213
  const distribution = await this.#distribution(api, platform, profile, { sign, notarize }, server);
229
- return await api.build({
230
- ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
231
- profile,
232
- ...(arch ? { arch } : {}),
233
- ...(platform === "windows" ? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } } : {}),
234
- ...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
235
- ...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
236
- });
214
+ return await this.#recorded(
215
+ this.outDir(platform),
216
+ publicEnv,
217
+ api.build({
218
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
219
+ profile,
220
+ ...(arch ? { arch } : {}),
221
+ ...(platform === "windows"
222
+ ? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } }
223
+ : {}),
224
+ ...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
225
+ ...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
226
+ }),
227
+ );
237
228
  }
238
229
 
239
230
  async run(
@@ -302,12 +293,17 @@ export class NativeApp {
302
293
 
303
294
  async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
304
295
  await this.assembleWeb("ios");
296
+ const publicEnv = await this.#publicEnv();
305
297
  const { api, config } = await this.prepare("ios");
306
- return await api.release({
307
- ...this.#task("ios", config, { env: await this.#runtimeEnv() }),
308
- platform: "ios",
309
- signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
310
- });
298
+ return await this.#recorded(
299
+ this.outDir("ios"),
300
+ publicEnv,
301
+ api.release({
302
+ ...this.#task("ios", config, { env: await this.#runtimeEnv() }),
303
+ platform: "ios",
304
+ signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
305
+ }),
306
+ );
311
307
  }
312
308
 
313
309
  /** Where publishUpdate writes: upload this folder as the target's updates.url. */
@@ -324,7 +320,15 @@ export class NativeApp {
324
320
  arch,
325
321
  sign = false,
326
322
  notarize = false,
327
- }: { channel?: string; server?: DesktopServerBundle; arch?: DesktopArch; sign?: boolean; notarize?: boolean } = {},
323
+ revoke,
324
+ resetSequence,
325
+ }: {
326
+ channel?: string;
327
+ server?: DesktopServerBundle;
328
+ arch?: DesktopArch;
329
+ sign?: boolean;
330
+ notarize?: boolean;
331
+ } & PhoneRevocation = {},
328
332
  ) {
329
333
  NativeApp.#assertServerPlatform(platform, server);
330
334
  this.#assertUpdates();
@@ -340,11 +344,14 @@ export class NativeApp {
340
344
  ...(arch ? { arch } : {}),
341
345
  ...(distribution.macos ? { macos: distribution.macos } : {}),
342
346
  ...(distribution.windows ? { windows: distribution.windows } : {}),
347
+ ...(revoke?.length ? { revoke } : {}),
348
+ ...(resetSequence ? { resetSequence } : {}),
343
349
  });
344
350
  }
345
351
 
346
- //* Only what the command asked for is signed or uploaded; a signed app's server is searched for the workspace's
347
- //* secret values first, and the one line naming the identity and the service comes right before the build.
352
+ //* Only what the command asked for is signed or uploaded. A release's server is searched for the workspace's secret
353
+ //* values first, signed or not: an unsigned app is handed out too, and an update is published to every install. The
354
+ //* one line naming the identity and the service comes right before the build.
348
355
  async #distribution(
349
356
  api: NativeBuildApiModule,
350
357
  platform: NativePlatform,
@@ -353,29 +360,40 @@ export class NativeApp {
353
360
  server: DesktopServerBundle | undefined,
354
361
  ) {
355
362
  const distribution = api.requestedDistribution(platform, profile, request);
363
+ if (server && profile === "release") await this.#assertServerClean(server);
356
364
  if (!distribution.notice) {
357
365
  if (distribution.unused.length && (platform === "macos" || platform === "windows"))
358
366
  this.app.logger.info(`${distribution.unused.join(", ")} set: this build is not signed without --sign.`);
359
367
  return distribution;
360
368
  }
361
- if (server) {
362
- const unread = await new ServerBundleSecrets(this.app.workspace.workspaceRoot, this.app.cwdPath).assertClean(
363
- server.dir,
364
- );
365
- if (unread.length)
366
- this.app.logger.warn(`Not searched for secrets, as they could not be evaluated: ${unread.join(", ")}.`);
367
- }
368
369
  this.app.logger.warn(distribution.notice);
369
370
  return distribution;
370
371
  }
371
372
 
373
+ async #assertServerClean(server: DesktopServerBundle) {
374
+ const unread = await new ServerBundleSecrets(this.app.workspace.workspaceRoot, this.app.cwdPath).assertClean(
375
+ server.dir,
376
+ );
377
+ if (unread.length)
378
+ throw new Error(
379
+ `The server this app carries was not searched for the secret values of ${unread.join(", ")}, which could not be evaluated with no app env set, so the release stops here.`,
380
+ );
381
+ }
382
+
372
383
  /**
373
384
  * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
374
- * another app's, and a desktop release whose server presence is not its channel's previous release's.
385
+ * another app's, a desktop release whose server presence is not its channel's previous release's, and a phone release
386
+ * whose revocations no phone would take.
375
387
  */
376
388
  async assertPublishable(
377
389
  platform: NativePlatform,
378
- { channel, server = false, arch }: { channel?: string; server?: boolean; arch?: DesktopArch } = {},
390
+ {
391
+ channel,
392
+ server = false,
393
+ arch,
394
+ revoke,
395
+ resetSequence,
396
+ }: { channel?: string; server?: boolean; arch?: DesktopArch } & PhoneRevocation = {},
379
397
  ) {
380
398
  this.#assertUpdates();
381
399
  const [api, { config }] = await Promise.all([
@@ -392,14 +410,13 @@ export class NativeApp {
392
410
  server,
393
411
  ...(channel ? { channel } : {}),
394
412
  ...(arch ? { arch } : {}),
413
+ ...(revoke?.length ? { revoke } : {}),
414
+ ...(resetSequence ? { resetSequence } : {}),
395
415
  });
396
416
  } catch (error) {
397
- throw new Error(
398
- `Native target '${this.target.name}': ${error instanceof Error ? error.message : String(error)}`,
399
- {
400
- cause: error,
401
- },
402
- );
417
+ throw new Error(`Native target '${this.target.name}': ${toError(error).message}`, {
418
+ cause: error,
419
+ });
403
420
  }
404
421
  }
405
422
 
@@ -422,29 +439,38 @@ export class NativeApp {
422
439
  async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
423
440
  const signing = NativeApp.androidSigning();
424
441
  await this.assembleWeb("android");
442
+ const publicEnv = await this.#publicEnv();
425
443
  const { api, config } = await this.prepare("android");
426
- return await api.release({
427
- ...this.#task("android", config, { env: await this.#runtimeEnv() }),
428
- platform: "android",
429
- signing,
430
- formats,
431
- });
444
+ return await this.#recorded(
445
+ this.outDir("android"),
446
+ publicEnv,
447
+ api.release({
448
+ ...this.#task("android", config, { env: await this.#runtimeEnv() }),
449
+ platform: "android",
450
+ signing,
451
+ formats,
452
+ }),
453
+ );
432
454
  }
433
455
 
434
456
  /** An unsigned OTA update of this target's web bundle for `platform`, and the record `pack.json` keeps of it. */
435
457
  async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
436
458
  await this.assembleWeb(platform);
459
+ const publicEnv = await this.#publicEnv();
437
460
  const { api, config } = await this.prepare(platform);
438
461
  if (!config.updates)
439
462
  throw new Error(
440
463
  `Native target '${this.target.name}' takes no updates: add native.updates: { url, publicKey } to akan.config.ts first.`,
441
464
  );
442
465
  const dir = path.resolve(out ?? path.join(this.updatesDir, platform));
443
- //? A pack that fails must not leave the previous pack's record behind for whoever reads the exit code.
444
- await Promise.all(["pack.json", "compat.json"].map((name) => rm(path.join(dir, name), { force: true })));
445
- const packed = await api.packUpdate({ ...this.#task(platform, config), platform, out: dir });
466
+ await UpdatePackFolder.clear(dir);
467
+ const packed = await this.#recorded(
468
+ dir,
469
+ publicEnv,
470
+ api.packUpdate({ ...this.#task(platform, config), platform, out: dir }),
471
+ );
446
472
  const channel = config.updates.channel ?? "production";
447
- const bundle = (await Bun.file(path.join(packed.out, "bundle.json")).json()) as { web: { hash: string } };
473
+ const bundle = await UpdatePackFolder.readBundle(path.join(packed.out, UpdatePackFolder.bundleFile));
448
474
  const pack: NativeUpdatePack = {
449
475
  schema: 1,
450
476
  app: this.app.name,
@@ -456,6 +482,7 @@ export class NativeApp {
456
482
  appVersion: packed.manifest.version,
457
483
  nativeApi: packed.manifest.nativeApi ?? "",
458
484
  webHash: bundle.web.hash,
485
+ ...(bundle.web.publicEnvHash ? { publicEnvHash: bundle.web.publicEnvHash } : {}),
459
486
  files: packed.manifest.files.length,
460
487
  size: packed.manifest.files.reduce((sum, file) => sum + file.size, 0),
461
488
  out: packed.out,
@@ -463,18 +490,27 @@ export class NativeApp {
463
490
  return { ...packed, channel, pack };
464
491
  }
465
492
 
466
- static async writePack(pack: NativeUpdatePack) {
467
- await Bun.write(path.join(pack.out, "pack.json"), `${JSON.stringify(pack, null, 2)}\n`);
468
- }
469
-
470
- /** A pack `packUpdate` wrote, read back from its folder wherever it was moved. */
471
- static async readPack(dir: string): Promise<NativeUpdatePack> {
472
- const file = Bun.file(path.join(dir, "pack.json"));
473
- if (!(await file.exists()))
474
- throw new Error(`${dir} holds no pack.json: give the folder \`akan pack-update\` wrote.`);
475
- const pack = (await file.json()) as NativeUpdatePack;
476
- if (pack.schema !== 1) throw new Error(`${dir}/pack.json is schema ${pack.schema}, and this akan reads schema 1.`);
477
- return { ...pack, out: path.resolve(dir) };
493
+ //* Checked before anything is built or signed: a target with updates would ship a binary Akan Cloud cannot update.
494
+ async #publicEnv() {
495
+ const publicEnv = await NativePublicEnv.built(this.app);
496
+ if (!publicEnv) return null;
497
+ const problems = NativePublicEnv.problems(publicEnv);
498
+ if (!problems.length) return publicEnv;
499
+ const message = `The web build inlines public env an update cannot carry:\n- ${problems.join("\n- ")}`;
500
+ if (this.target.config.updates) throw new Error(message);
501
+ this.app.logger.warn(`${message}\nbundle.json records none, so no update can be checked against this build.`);
502
+ return null;
503
+ }
504
+
505
+ //? After the build: the native CLI writes bundle.json, and a phone build with updates rewrites it once more.
506
+ async #recorded<T>(dir: string, publicEnv: PublicEnv | null, build: Promise<T>) {
507
+ const result = await build;
508
+ const file = path.join(dir, UpdatePackFolder.bundleFile);
509
+ if (!publicEnv || !(await Bun.file(file).exists())) return result;
510
+ const bundle = await UpdatePackFolder.readBundle(file);
511
+ const web = { ...bundle.web, publicEnv, publicEnvHash: NativePublicEnv.hash(publicEnv) };
512
+ await Bun.write(file, `${JSON.stringify({ ...bundle, web }, null, 2)}\n`);
513
+ return result;
478
514
  }
479
515
 
480
516
  static async compareBundles(appDir: string, shipped: string, bundle: string) {
@@ -261,7 +261,8 @@ export class NativeConfig {
261
261
  };
262
262
  }
263
263
 
264
- //? akanConfig refuses a target whose merged updates lack url or a key; the type still has them optional.
264
+ //? akanConfig refuses a target whose merged updates lack url or a key, or hold both key forms; the type still has
265
+ //? them optional.
265
266
  static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
266
267
  const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
267
268
  const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
@@ -0,0 +1,131 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+ import { WorkspaceExecutor } from "../executors";
5
+ import { tempDirs, writeText } from "../testHelpers";
6
+ import { NativePublicEnv } from "./nativePublicEnv";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-public-env-");
9
+
10
+ const recorded = {
11
+ AKAN_PUBLIC_ENV: "main",
12
+ AKAN_PUBLIC_REPO_NAME: "softs2",
13
+ AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com",
14
+ };
15
+
16
+ describe("NativePublicEnv", () => {
17
+ test("keeps the AKAN_PUBLIC_* the app sets, sorted, and leaves out what akan.config.ts derives", () => {
18
+ expect(
19
+ Object.keys(
20
+ NativePublicEnv.of({
21
+ AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com",
22
+ AKAN_PUBLIC_APP_NAME: "lu",
23
+ AKAN_PUBLIC_BASE_PATHS: "",
24
+ AKAN_PUBLIC_RENDER_ENV: "csr",
25
+ AKAN_PUBLIC_ENV: "main",
26
+ JWT_SECRET: "never",
27
+ }),
28
+ ),
29
+ ).toEqual(["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_SERVE_DOMAIN"]);
30
+ });
31
+
32
+ test("hashes the sorted map as JSON, whatever order it was written in", () => {
33
+ const reversed = Object.fromEntries(Object.entries(recorded).reverse());
34
+ const expected = createHash("sha256").update(JSON.stringify(recorded)).digest("hex");
35
+ expect(NativePublicEnv.hash(reversed)).toBe(expected);
36
+ });
37
+
38
+ test("refuses what Akan Cloud refuses in a recorded env", () => {
39
+ expect(NativePublicEnv.problems(recorded)).toEqual([]);
40
+ expect(
41
+ NativePublicEnv.problems({
42
+ AKAN_PUBLIC_lower: "x",
43
+ AKAN_PUBLIC_MAP_TOKEN: "x",
44
+ AKAN_PUBLIC_PORT: 8282,
45
+ AKAN_PUBLIC_NOTE: "line\nbreak",
46
+ }),
47
+ ).toEqual([
48
+ "AKAN_PUBLIC_lower is not an AKAN_PUBLIC_[A-Z0-9_] name",
49
+ "AKAN_PUBLIC_MAP_TOKEN names a token, which a page bundle must not carry",
50
+ "AKAN_PUBLIC_PORT is not a string",
51
+ "AKAN_PUBLIC_NOTE holds a control character",
52
+ ]);
53
+ const many = Object.fromEntries(Array.from({ length: 257 }, (_, idx) => [`AKAN_PUBLIC_K${idx}`, "v"]));
54
+ expect(NativePublicEnv.problems(many)).toEqual(["257 keys, more than 256"]);
55
+ expect(NativePublicEnv.problems({ AKAN_PUBLIC_BIG: "x".repeat(70_000) })[0]).toContain("bytes, more than 65536");
56
+ });
57
+
58
+ test("names the keys two maps disagree on, either side missing included", () => {
59
+ expect(
60
+ NativePublicEnv.diff(recorded, { ...recorded, AKAN_PUBLIC_SERVE_DOMAIN: "try.akanjs.com", AKAN_PUBLIC_X: "1" }),
61
+ ).toEqual(["AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_X"]);
62
+ });
63
+
64
+ test("loads --public-env from a JSON map or from a store build's bundle.json, and refuses the rest", async () => {
65
+ const root = await makeTempRoot();
66
+ const map = path.join(root, "store.env.json");
67
+ await writeText(map, JSON.stringify(recorded));
68
+ expect(await NativePublicEnv.load(map)).toEqual(recorded);
69
+
70
+ const bundle = path.join(root, "bundle.json");
71
+ await writeText(bundle, JSON.stringify({ schema: 1, web: { hash: "h", files: 1, publicEnv: recorded } }));
72
+ expect(await NativePublicEnv.load(bundle)).toEqual(recorded);
73
+ expect(await NativePublicEnv.recordedIn(bundle)).toEqual(recorded);
74
+
75
+ const older = path.join(root, "older.json");
76
+ await writeText(older, JSON.stringify({ schema: 1, web: { hash: "h", files: 1 } }));
77
+ await expect(NativePublicEnv.load(older)).rejects.toThrow("recorded no public env");
78
+ expect(await NativePublicEnv.recordedIn(older)).toBeNull();
79
+ expect(await NativePublicEnv.recordedIn(path.join(root, "missing.json"))).toBeNull();
80
+
81
+ const derived = path.join(root, "derived.json");
82
+ await writeText(derived, JSON.stringify({ ...recorded, AKAN_PUBLIC_APP_NAME: "lu" }));
83
+ await expect(NativePublicEnv.load(derived)).rejects.toThrow("AKAN_PUBLIC_APP_NAME, which akan.config.ts decides");
84
+
85
+ const secret = path.join(root, "secret.json");
86
+ await writeText(secret, JSON.stringify({ AKAN_PUBLIC_API_PASSWORD: "x" }));
87
+ await expect(NativePublicEnv.load(secret)).rejects.toThrow("names a password");
88
+ await expect(NativePublicEnv.load(path.join(root, "nope.json"))).rejects.toThrow("does not exist");
89
+ });
90
+
91
+ test("says which keys keep a pack out of a store build that recorded its env, and nothing when it recorded none", async () => {
92
+ const root = await makeTempRoot();
93
+ const shipped = path.join(root, "store.json");
94
+ const pack = path.join(root, "pack.json");
95
+ await writeText(shipped, JSON.stringify({ schema: 1, web: { publicEnv: recorded } }));
96
+ await writeText(
97
+ pack,
98
+ JSON.stringify({ schema: 1, web: { publicEnv: { ...recorded, AKAN_PUBLIC_SERVE_DOMAIN: "try.akanjs.com" } } }),
99
+ );
100
+ expect(await NativePublicEnv.problemAgainst(shipped, pack)).toBe(
101
+ `public env AKAN_PUBLIC_SERVE_DOMAIN differs from the store build's (pass --public-env ${shipped} to repeat it)`,
102
+ );
103
+ expect(await NativePublicEnv.problemAgainst(shipped, shipped)).toBeNull();
104
+ expect(await NativePublicEnv.problemAgainst(pack.replace("pack", "none"), pack)).toBeNull();
105
+ });
106
+
107
+ describe("pin", () => {
108
+ const saved = { ...process.env };
109
+ afterEach(() => {
110
+ for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
111
+ Object.assign(process.env, saved);
112
+ WorkspaceExecutor.publicEnvPin = null;
113
+ });
114
+
115
+ test("leaves exactly the pinned values and the derived ones, and outranks the root .env", async () => {
116
+ const root = await makeTempRoot();
117
+ const envFile = path.join(root, ".env");
118
+ await writeText(
119
+ envFile,
120
+ "AKAN_PUBLIC_REPO_NAME=from-dotenv\nAKAN_PUBLIC_SERVE_DOMAIN=try.akanjs.com\nAKAN_PUBLIC_LOG_LEVEL=debug\n",
121
+ );
122
+ Object.assign(process.env, { AKAN_PUBLIC_LOG_LEVEL: "debug", AKAN_PUBLIC_APP_NAME: "lu" });
123
+ NativePublicEnv.pin(recorded);
124
+ expect(NativePublicEnv.of(process.env)).toEqual(recorded);
125
+ expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("lu");
126
+ const base = WorkspaceExecutor.getBaseDevEnv(envFile);
127
+ expect(base.repoName).toBe("softs2");
128
+ expect(base.serveDomain).toBe("akamir.com");
129
+ });
130
+ });
131
+ });