@akanjs/devkit 3.0.2 → 3.1.0-rc.1

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 (71) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.ts +3 -2
  3. package/akanConfig/akanConfig.test.ts +76 -6
  4. package/akanConfig/akanConfig.ts +111 -13
  5. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  6. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +23 -1
  9. package/akanContext.ts +1 -1
  10. package/applicationBuildRunner.test.ts +1 -1
  11. package/applicationBuildRunner.ts +73 -84
  12. package/artifact/implicitRootLayout.test.ts +6 -2
  13. package/artifact/implicitRootLayout.ts +12 -7
  14. package/biome.base.json +7 -2
  15. package/cloud/cloudApi.ts +87 -0
  16. package/cloud/globalConfig.ts +4 -0
  17. package/cloud/index.ts +1 -0
  18. package/cloud/updatePackUpload.test.ts +250 -0
  19. package/cloud/updatePackUpload.ts +132 -0
  20. package/commandDecorators/command.ts +8 -2
  21. package/commandDecorators/targetMeta.ts +1 -0
  22. package/executors.test.ts +21 -0
  23. package/executors.ts +9 -2
  24. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  25. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  26. package/frontendBuild/cssCandidateCache.ts +4 -2
  27. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  29. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  30. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  32. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  33. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  34. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  35. package/lint/no-import-external-library-in-test.grit +24 -0
  36. package/mobile/desktopServerStage.test.ts +6 -0
  37. package/mobile/desktopServerStage.ts +25 -3
  38. package/mobile/index.ts +1 -0
  39. package/mobile/nativeApp.test.ts +34 -0
  40. package/mobile/nativeApp.ts +106 -34
  41. package/mobile/nativeConfig.test.ts +59 -1
  42. package/mobile/nativeConfig.ts +13 -3
  43. package/mobile/serverBundleSecrets.test.ts +61 -0
  44. package/mobile/serverBundleSecrets.ts +85 -0
  45. package/mobile/updateUploader.test.ts +49 -0
  46. package/mobile/updateUploader.ts +71 -0
  47. package/package.json +3 -2
  48. package/pipeline/PipelineCompiler.test.ts +631 -0
  49. package/pipeline/PipelineCompiler.ts +646 -0
  50. package/pipeline/PipelineDefinition.test.ts +89 -0
  51. package/pipeline/PipelineDefinition.ts +117 -0
  52. package/pipeline/PipelineDryRun.test.ts +93 -0
  53. package/pipeline/PipelineDryRun.ts +89 -0
  54. package/pipeline/PipelineInputReader.ts +739 -0
  55. package/pipeline/index.ts +4 -0
  56. package/qualityScanner.test.ts +9 -3
  57. package/qualityScanner.ts +5 -2
  58. package/scanInfo.ts +13 -6
  59. package/subspace.test.ts +108 -4
  60. package/subspace.ts +162 -10
  61. package/subspaceConfig.ts +11 -14
  62. package/testHelpers.ts +4 -0
  63. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  64. package/transforms/serverEnvPlugin.test.ts +93 -2
  65. package/transforms/serverEnvPlugin.ts +71 -18
  66. package/transforms/serverEnvProfile.test.ts +82 -0
  67. package/transforms/serverEnvProfile.ts +114 -0
  68. package/typecheck/projectTypecheck.test.ts +32 -0
  69. package/typecheck/projectTypecheck.ts +71 -0
  70. package/workspaceLayout.test.ts +26 -3
  71. package/workspaceLayout.ts +15 -4
@@ -3,8 +3,9 @@ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from
3
3
 
4
4
  export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
5
5
 
6
- // `base` is the boot build: it always runs alone, before the builder can serve anything.
7
- export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr";
6
+ // `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the single-file
7
+ // CSR build an MCP Apps view needs under the dev CSR registry, made on request and reported to no tab.
8
+ export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr" | "shell";
8
9
 
9
10
  // JSON only: the worker is spawned per batch and receives this in its argv.
10
11
  export interface BuildBatchRequest {
@@ -82,6 +82,102 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
82
82
  expect(log).toContain(reason);
83
83
  }, 30_000);
84
84
 
85
+ test("builds a view's shell once on request, answers with its failure, and reports it to no tab", async () => {
86
+ const { root } = track(await createTempApp("demo"));
87
+ const appDir = path.join(root, "apps/demo");
88
+ // A stand-in batch: boots, and fails a shell batch by naming the needs it was handed.
89
+ await writeText(
90
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
91
+ `const { generation, needs } = JSON.parse(process.argv[2]);
92
+ const errors = needs.includes("base") ? {} : { shell: "handed " + needs.join(",") };
93
+ process.send({ type: "build-batch-result", data: { generation, errors, artifact: {}, optimizedFonts: { css: "", files: [] } } });
94
+ `,
95
+ );
96
+ await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
97
+ const messages: BuilderMessage[] = [];
98
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
99
+ cwd: appDir,
100
+ env: {
101
+ ...process.env,
102
+ AKAN_WORKSPACE_ROOT: root,
103
+ AKAN_PUBLIC_APP_NAME: "demo",
104
+ AKAN_PUBLIC_REPO_NAME: "repo",
105
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
106
+ AKAN_PUBLIC_ENV: "local",
107
+ AKAN_WATCH: "0",
108
+ },
109
+ stdio: ["ignore", "ignore", "pipe"],
110
+ serialization: "advanced",
111
+ ipc: (message: BuilderMessage) => {
112
+ messages.push(message);
113
+ },
114
+ });
115
+ try {
116
+ await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
117
+ proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
118
+ await until(proc, "build-csr-res", () => messages.some((message) => message.type === "build-csr-res"));
119
+ } finally {
120
+ proc.kill();
121
+ await proc.exited;
122
+ }
123
+ expect(messages.find((message) => message.type === "build-csr-res")).toEqual({
124
+ type: "build-csr-res",
125
+ id: 7,
126
+ ok: false,
127
+ error: "handed shell",
128
+ });
129
+ expect(messages.some((message) => message.type === "build-status")).toBe(false);
130
+ }, 30_000);
131
+
132
+ test("answers a second shell request in the same generation without building again", async () => {
133
+ const { root } = track(await createTempApp("demo"));
134
+ const appDir = path.join(root, "apps/demo");
135
+ const ledger = path.join(root, "batches.log");
136
+ await writeText(
137
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
138
+ `const { generation, needs } = JSON.parse(process.argv[2]);
139
+ require("node:fs").appendFileSync(${JSON.stringify(ledger)}, needs.join(",") + "\\n");
140
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
141
+ `,
142
+ );
143
+ await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
144
+ const messages: BuilderMessage[] = [];
145
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
146
+ cwd: appDir,
147
+ env: {
148
+ ...process.env,
149
+ AKAN_WORKSPACE_ROOT: root,
150
+ AKAN_PUBLIC_APP_NAME: "demo",
151
+ AKAN_PUBLIC_REPO_NAME: "repo",
152
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
153
+ AKAN_PUBLIC_ENV: "local",
154
+ AKAN_WATCH: "0",
155
+ },
156
+ stdio: ["ignore", "ignore", "pipe"],
157
+ serialization: "advanced",
158
+ ipc: (message: BuilderMessage) => {
159
+ messages.push(message);
160
+ },
161
+ });
162
+ const answers = () => messages.filter((message) => message.type === "build-csr-res");
163
+ try {
164
+ await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
165
+ proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
166
+ await until(proc, "the first build-csr-res", () => answers().length === 1);
167
+ proc.send({ type: "build-csr", id: 8, reason: "mcp app view", shell: true } satisfies BuilderMessage);
168
+ await until(proc, "the second build-csr-res", () => answers().length === 2);
169
+ } finally {
170
+ proc.kill();
171
+ await proc.exited;
172
+ }
173
+ expect(answers()).toEqual([
174
+ { type: "build-csr-res", id: 7, ok: true },
175
+ { type: "build-csr-res", id: 8, ok: true },
176
+ ]);
177
+ const batches = (await Bun.file(ledger).text()).trim().split("\n");
178
+ expect(batches.filter((needs) => needs === "shell")).toHaveLength(1);
179
+ }, 30_000);
180
+
85
181
  test("a killed boot build is tried again after the crash window, before boot-armed; a route build's check waits likewise", async () => {
86
182
  // Inside the checkout so the route's client bundle resolves the framework from its source.
87
183
  const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
@@ -63,6 +63,7 @@ class IncrementalBuilder {
63
63
  #autoImportSync: AutoImportSync;
64
64
  #watcher: HmrWatcher | null = null;
65
65
  #generation = 0;
66
+ #shellGeneration: number | null = null;
66
67
  #discoveredThrough = 0;
67
68
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
68
69
  #csrBundler: CsrDevBundler;
@@ -798,7 +799,7 @@ class IncrementalBuilder {
798
799
  if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
799
800
  // `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
800
801
  for (const need of crashedNeeds)
801
- if (need !== "base")
802
+ if (need !== "base" && need !== "shell")
802
803
  this.#sendBuildStatus(need, {
803
804
  generation,
804
805
  ok: false,
@@ -874,6 +875,10 @@ class IncrementalBuilder {
874
875
 
875
876
  // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
876
877
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
878
+ if (msg.shell) {
879
+ await this.#buildShell(msg);
880
+ return;
881
+ }
877
882
  //? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
878
883
  this.#beginCsrArm();
879
884
  this.#csrActive = true;
@@ -903,6 +908,30 @@ class IncrementalBuilder {
903
908
  await armed;
904
909
  }
905
910
 
911
+ // Asked on every view read and rebuilt only when a save moved the generation, instead of armed per save: a restarted
912
+ // builder has no last build to trust, and so rebuilds once on the next read.
913
+ async #buildShell(msg: BuilderCsrReq): Promise<void> {
914
+ await this.#workQueue.enqueue("build-shell", async (): Promise<void> => {
915
+ const generation = this.#generation;
916
+ let error: string | undefined;
917
+ if (this.#shellGeneration !== generation)
918
+ try {
919
+ const result = await this.#batchRunner.run(
920
+ await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }),
921
+ );
922
+ error = result.errors.shell;
923
+ if (!error) this.#shellGeneration = generation;
924
+ } catch (err) {
925
+ error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
926
+ }
927
+ await BuilderChannel.send(
928
+ error
929
+ ? { type: "build-csr-res", id: msg.id, ok: false, error }
930
+ : { type: "build-csr-res", id: msg.id, ok: true },
931
+ );
932
+ });
933
+ }
934
+
906
935
  //? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
907
936
  //? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
908
937
  //? patch CSR first while the second still builds.
@@ -0,0 +1,6 @@
1
+ import dayjs from "dayjs"; // @flag
2
+ import { z } from "zod"; // @flag
3
+ import fs from "node:fs"; // @ok
4
+ import { Database } from "bun:sqlite"; // @ok
5
+ import { describe } from "bun:test"; // @ok
6
+ import { cnst } from "../cnst"; // @ok
@@ -0,0 +1,6 @@
1
+ import { mkdtemp } from "node:fs/promises"; // @ok
2
+ import { createServer } from "node:net"; // @ok
3
+ import { tmpdir } from "node:os"; // @ok
4
+ import { Database } from "bun:sqlite"; // @ok
5
+ import { describe, expect, test } from "bun:test"; // @ok
6
+ import { fetch } from "../sig"; // @ok
@@ -0,0 +1,24 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // A module test still reaches vendors through a lib, but it may use the runtime's own primitives — a temp dir, a
5
+ // socket, sqlite — that a module file leaves to srvkit/.
6
+ JsModuleSource() as $source where {
7
+ $source <: within JsImport(),
8
+ not $source <: or {
9
+ r"\"\..*",
10
+ r"\"akanjs.*",
11
+ r"\"@akanjs.*",
12
+ r"\"@playwright.*",
13
+ r"\"@apps/.*",
14
+ r"\"@libs/.*",
15
+ r"\"@pkgs/.*",
16
+ r"\"bun:.*",
17
+ r"\"node:.*",
18
+ r"\"react.*"
19
+ },
20
+ register_diagnostic(
21
+ span = $source,
22
+ message = "External libraries should not be imported in a module test. Only internal packages and the runtime's node: and bun: modules are permitted."
23
+ )
24
+ }
@@ -46,6 +46,12 @@ describe("DesktopServerStage", () => {
46
46
  });
47
47
  expect(env.PORT).toBeUndefined();
48
48
  expect(env.AKAN_LOG_TO_FILE).toBeUndefined();
49
+ expect(env.AKAN_MODULES).toBeUndefined();
50
+ expect(DesktopServerStage.env(appConfig(), "main", { modules: ["@edge", "edgeAgent"] }).AKAN_MODULES).toBe(
51
+ "@edge,edgeAgent",
52
+ );
53
+ const withEnv = DesktopServerStage.env(appConfig(), "main", { env: { CLOUD_ORIGIN: "https://cloud.example" } });
54
+ expect(withEnv).toMatchObject({ CLOUD_ORIGIN: "https://cloud.example", AKAN_PUBLIC_OPERATION_MODE: "edge" });
49
55
  });
50
56
 
51
57
  test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
@@ -10,6 +10,16 @@ export interface DesktopServerBundle {
10
10
  dir: string;
11
11
  entry: string;
12
12
  env: Record<string, string>;
13
+ restart?: "forever" | number;
14
+ watchdog?: { intervalMs: number; failures: number };
15
+ }
16
+
17
+ /** How the targets that carry one build's server have it run (`desktop.server`, normalized). */
18
+ export interface DesktopServerRun {
19
+ modules?: string[];
20
+ env?: Record<string, string>;
21
+ restart?: "forever" | number;
22
+ watchdog?: { intervalMs: number; failures: number };
13
23
  }
14
24
 
15
25
  //* A desktop app's carried server is the backend `akan build` wrote into dist, installed on its own as the image's
@@ -44,11 +54,16 @@ export class DesktopServerStage {
44
54
  );
45
55
  }
46
56
 
47
- static env(config: AkanAppConfig, environment: NativeEnv): Record<string, string> {
57
+ static env(
58
+ config: AkanAppConfig,
59
+ environment: NativeEnv,
60
+ { modules = [], env = {} }: Pick<DesktopServerRun, "modules" | "env"> = {},
61
+ ): Record<string, string> {
48
62
  //? The launcher picks the port, and the log file stays on: a user's computer has no log collector.
49
63
  const { PORT: _port, AKAN_LOG_TO_FILE: _fileLog, ...image } = config.getProductionEnv(environment);
50
64
  return {
51
65
  ...image,
66
+ ...env,
52
67
  //? Neither the developer's machine (local, which derives a forgeable JWT secret) nor a deployment (cloud).
53
68
  AKAN_PUBLIC_OPERATION_MODE: "edge",
54
69
  AKAN_DATABASE_MODE: "single",
@@ -59,6 +74,7 @@ export class DesktopServerStage {
59
74
  AKAN_MCP: "false",
60
75
  //? Ahead of the launcher's 1.5 s grace before it signals, which sits inside the shell's 2 s quit budget.
61
76
  AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
77
+ ...(modules.length ? { AKAN_MODULES: modules.join(",") } : {}),
62
78
  };
63
79
  }
64
80
 
@@ -162,7 +178,7 @@ export class DesktopServerStage {
162
178
  }
163
179
 
164
180
  //? Copies the build beside what `install` put in the stage, so it runs after both.
165
- async prepare(environment: NativeEnv): Promise<DesktopServerBundle> {
181
+ async prepare(environment: NativeEnv, run: DesktopServerRun = {}): Promise<DesktopServerBundle> {
166
182
  const config = await this.app.getConfig();
167
183
  const dist = this.app.dist.cwdPath;
168
184
  await mkdir(this.dir, { recursive: true });
@@ -173,6 +189,12 @@ export class DesktopServerStage {
173
189
  const carried = [...DesktopBin.select(config, scanInfo.getLibs()).keys()];
174
190
  const notice = DesktopServerStage.imageStepsNotice(config, carried);
175
191
  if (notice) this.app.logger[notice.level](notice.message);
176
- return { dir: this.dir, entry: "main.js", env: DesktopServerStage.env(config, environment) };
192
+ return {
193
+ dir: this.dir,
194
+ entry: "main.js",
195
+ env: DesktopServerStage.env(config, environment, run),
196
+ ...(run.restart !== undefined ? { restart: run.restart } : {}),
197
+ ...(run.watchdog ? { watchdog: run.watchdog } : {}),
198
+ };
177
199
  }
178
200
  }
package/mobile/index.ts CHANGED
@@ -7,4 +7,5 @@ export * from "./nativeConfig";
7
7
  export * from "./nativePluginFolders";
8
8
  export * from "./nativeWebDir";
9
9
  export * from "./placeholderAppId";
10
+ export * from "./updateUploader";
10
11
  export * from "./usageDescriptions";
@@ -58,6 +58,7 @@ describe("NativeApp", () => {
58
58
  ({
59
59
  config: {},
60
60
  api: {
61
+ requestedDistribution: () => ({ notice: null, unused: [] }),
61
62
  build: async ({ env }: { env?: Record<string, string> }) => {
62
63
  seen = env;
63
64
  return { artifacts: [], warnings: [] };
@@ -231,3 +232,36 @@ describe("NativeApp", () => {
231
232
  );
232
233
  });
233
234
  });
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,3 +1,4 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import type { AkanNativeConfig } from "@akanjs/native/config";
3
4
  import type { NativeEnv } from "akanjs";
@@ -17,6 +18,7 @@ import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
17
18
  import { NativeConfig } from "./nativeConfig";
18
19
  import { NativePluginFolders } from "./nativePluginFolders";
19
20
  import { NativeWebDir } from "./nativeWebDir";
21
+ import { ServerBundleSecrets } from "./serverBundleSecrets";
20
22
 
21
23
  type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
22
24
  type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
@@ -42,6 +44,10 @@ export interface NativeBuildOptions {
42
44
  installer?: boolean;
43
45
  /** The CPU the desktop app runs on, of the same OS; a macOS app is arm64 only. Default this computer's. */
44
46
  arch?: DesktopArch;
47
+ /** Sign with the identity AKAN_NATIVE_MACOS_* / AKAN_NATIVE_WINDOWS_* names; the env alone signs nothing. */
48
+ sign?: boolean;
49
+ /** macOS: upload the signed app to Apple's notary service. */
50
+ notarize?: boolean;
45
51
  }
46
52
 
47
53
  export interface NativeDevOptions extends NativeRunOptions {
@@ -50,6 +56,25 @@ export interface NativeDevOptions extends NativeRunOptions {
50
56
  lang: string;
51
57
  }
52
58
 
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
+
53
78
  /** One native target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
54
79
  * what it ships, and the calls into the API. */
55
80
  export class NativeApp {
@@ -194,32 +219,19 @@ export class NativeApp {
194
219
 
195
220
  async build(
196
221
  platform: NativePlatform,
197
- { profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
222
+ { profile = "release", server, installer = false, arch, sign = false, notarize = false }: NativeBuildOptions = {},
198
223
  ) {
199
224
  NativeApp.#assertServerPlatform(platform, server);
200
225
  NativeApp.assertInstaller(platform, installer);
201
226
  await this.assembleWeb(platform);
202
227
  const { api, config } = await this.prepare(platform, server, arch);
228
+ const distribution = await this.#distribution(api, platform, profile, { sign, notarize }, server);
203
229
  return await api.build({
204
230
  ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
205
231
  profile,
206
232
  ...(arch ? { arch } : {}),
207
- ...(platform === "windows"
208
- ? {
209
- windows: {
210
- ...(installer ? { installer } : {}),
211
- ...(profile === "release" ? NativeApp.#windowsSigning(api) : {}),
212
- },
213
- }
214
- : {}),
215
- ...(platform === "macos"
216
- ? {
217
- macos: {
218
- ...(profile === "release" ? api.macosDistributionFromEnv(process.env) : {}),
219
- ...(installer ? { dmg: true } : {}),
220
- },
221
- }
222
- : {}),
233
+ ...(platform === "windows" ? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } } : {}),
234
+ ...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
223
235
  ...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
224
236
  });
225
237
  }
@@ -306,30 +318,64 @@ export class NativeApp {
306
318
  /** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
307
319
  async publishUpdate(
308
320
  platform: NativePlatform,
309
- { channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
321
+ {
322
+ channel,
323
+ server,
324
+ arch,
325
+ sign = false,
326
+ notarize = false,
327
+ }: { channel?: string; server?: DesktopServerBundle; arch?: DesktopArch; sign?: boolean; notarize?: boolean } = {},
310
328
  ) {
311
329
  NativeApp.#assertServerPlatform(platform, server);
312
330
  this.#assertUpdates();
313
331
  await this.assembleWeb(platform);
314
- const { api, config } = await this.prepare(platform, server);
332
+ const { api, config } = await this.prepare(platform, server, arch);
333
+ //? The updater keeps the installed app's signature, so a release is signed as the downloaded app was.
334
+ const distribution = await this.#distribution(api, platform, "release", { sign, notarize }, server);
315
335
  return await api.publishUpdate({
316
336
  ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
317
337
  platform,
318
338
  out: this.updatesDir,
319
339
  ...(channel ? { channel } : {}),
320
- //? The updater keeps the installed app's signature, so a release is signed as the downloaded app was.
321
- ...(platform === "macos" ? { macos: api.macosDistributionFromEnv(process.env) } : {}),
322
- ...(platform === "windows" ? { windows: NativeApp.#windowsSigning(api) } : {}),
340
+ ...(arch ? { arch } : {}),
341
+ ...(distribution.macos ? { macos: distribution.macos } : {}),
342
+ ...(distribution.windows ? { windows: distribution.windows } : {}),
323
343
  });
324
344
  }
325
345
 
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.
348
+ async #distribution(
349
+ api: NativeBuildApiModule,
350
+ platform: NativePlatform,
351
+ profile: "debug" | "release",
352
+ request: { sign: boolean; notarize: boolean },
353
+ server: DesktopServerBundle | undefined,
354
+ ) {
355
+ const distribution = api.requestedDistribution(platform, profile, request);
356
+ if (!distribution.notice) {
357
+ if (distribution.unused.length && (platform === "macos" || platform === "windows"))
358
+ this.app.logger.info(`${distribution.unused.join(", ")} set: this build is not signed without --sign.`);
359
+ return distribution;
360
+ }
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
+ this.app.logger.warn(distribution.notice);
369
+ return distribution;
370
+ }
371
+
326
372
  /**
327
373
  * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
328
374
  * another app's, and a desktop release whose server presence is not its channel's previous release's.
329
375
  */
330
376
  async assertPublishable(
331
377
  platform: NativePlatform,
332
- { channel, server = false }: { channel?: string; server?: boolean } = {},
378
+ { channel, server = false, arch }: { channel?: string; server?: boolean; arch?: DesktopArch } = {},
333
379
  ) {
334
380
  this.#assertUpdates();
335
381
  const [api, { config }] = await Promise.all([
@@ -345,6 +391,7 @@ export class NativeApp {
345
391
  out: this.updatesDir,
346
392
  server,
347
393
  ...(channel ? { channel } : {}),
394
+ ...(arch ? { arch } : {}),
348
395
  });
349
396
  } catch (error) {
350
397
  throw new Error(
@@ -384,7 +431,7 @@ export class NativeApp {
384
431
  });
385
432
  }
386
433
 
387
- /** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
434
+ /** An unsigned OTA update of this target's web bundle for `platform`, and the record `pack.json` keeps of it. */
388
435
  async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
389
436
  await this.assembleWeb(platform);
390
437
  const { api, config } = await this.prepare(platform);
@@ -392,12 +439,42 @@ export class NativeApp {
392
439
  throw new Error(
393
440
  `Native target '${this.target.name}' takes no updates: add native.updates: { url, publicKey } to akan.config.ts first.`,
394
441
  );
395
- const packed = await api.packUpdate({
396
- ...this.#task(platform, config),
442
+ 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 });
446
+ const channel = config.updates.channel ?? "production";
447
+ const bundle = (await Bun.file(path.join(packed.out, "bundle.json")).json()) as { web: { hash: string } };
448
+ const pack: NativeUpdatePack = {
449
+ schema: 1,
450
+ app: this.app.name,
451
+ target: this.target.name,
397
452
  platform,
398
- out: out ?? path.join(this.updatesDir, platform),
399
- });
400
- return { ...packed, channel: config.updates.channel ?? "production" };
453
+ env: this.env ?? "main",
454
+ channel,
455
+ appId: packed.manifest.app,
456
+ appVersion: packed.manifest.version,
457
+ nativeApi: packed.manifest.nativeApi ?? "",
458
+ webHash: bundle.web.hash,
459
+ files: packed.manifest.files.length,
460
+ size: packed.manifest.files.reduce((sum, file) => sum + file.size, 0),
461
+ out: packed.out,
462
+ };
463
+ return { ...packed, channel, pack };
464
+ }
465
+
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) };
401
478
  }
402
479
 
403
480
  static async compareBundles(appDir: string, shipped: string, bundle: string) {
@@ -437,11 +514,6 @@ export class NativeApp {
437
514
  return `/${home.join("/")}?${params}`;
438
515
  }
439
516
 
440
- static #windowsSigning(api: NativeBuildApiModule) {
441
- const signing = api.windowsSigningFromEnv(process.env);
442
- return signing ? { signing } : {};
443
- }
444
-
445
517
  //* `installer` is the file a person downloads: a setup program on Windows, a disk image on macOS, an AppImage on Linux.
446
518
  static assertInstaller(platform: NativePlatform, installer: boolean) {
447
519
  if (installer && (platform === "ios" || platform === "android"))