@akanjs/devkit 3.1.1-rc.1 → 3.1.1-rc.11

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 (45) hide show
  1. package/akanApp/akanApp.host.test.ts +12 -0
  2. package/akanApp/akanApp.host.ts +12 -0
  3. package/akanConfig/akanConfig.test.ts +43 -2
  4. package/akanConfig/akanConfig.ts +15 -5
  5. package/akanConfig/productionLockfile.test.ts +111 -0
  6. package/akanConfig/productionLockfile.ts +34 -0
  7. package/akanContext.ts +25 -0
  8. package/applicationBuildRunner.test.ts +41 -0
  9. package/applicationBuildRunner.ts +23 -3
  10. package/biomeBase.test.ts +2 -2
  11. package/cloud/cloudApi.ts +10 -4
  12. package/cloud/cloudHttpError.test.ts +8 -0
  13. package/cloud/cloudHttpError.ts +11 -1
  14. package/cloud/cloudUpdates.ts +48 -2
  15. package/cloud/updatePackUpload.test.ts +38 -2
  16. package/cloud/updatePackUpload.ts +5 -1
  17. package/commandDecorators/command.ts +3 -14
  18. package/commandDecorators/commandDecorators.test.ts +1 -1
  19. package/commandDecorators/workspaceVersionCheck.test.ts +80 -0
  20. package/commandDecorators/workspaceVersionCheck.ts +53 -0
  21. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  22. package/executors.test.ts +21 -1
  23. package/integration/devStability.integration.test.ts +9 -5
  24. package/lint/gritRules.test.ts +3 -3
  25. package/linter.test.ts +18 -1
  26. package/linter.ts +10 -6
  27. package/mobile/desktopRelease.test.ts +473 -0
  28. package/mobile/desktopRelease.ts +309 -0
  29. package/mobile/desktopServerStage.ts +4 -2
  30. package/mobile/index.ts +2 -0
  31. package/mobile/nativeApi.test.ts +4 -1
  32. package/mobile/nativeApi.ts +3 -3
  33. package/mobile/nativeApp.test.ts +2 -2
  34. package/mobile/nativeApp.ts +29 -5
  35. package/mobile/nativeConfig.test.ts +16 -8
  36. package/mobile/nativeConfig.ts +1 -1
  37. package/mobile/tarWriter.test.ts +89 -0
  38. package/mobile/tarWriter.ts +93 -0
  39. package/mobile/updateUploader.test.ts +34 -0
  40. package/mobile/updateUploader.ts +9 -6
  41. package/package.json +2 -2
  42. package/platformTest/LinuxDockerTarget.ts +3 -0
  43. package/signalMutationIndex.test.ts +15 -0
  44. package/signalMutationIndex.ts +1 -1
  45. package/types.ts +1 -0
@@ -0,0 +1,309 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { isRecord, toError } from "akanjs/common";
5
+ import type { UpdateManifest } from "akanjs/native/api";
6
+ import { AkanAppConfig } from "../akanConfig";
7
+ import type { DesktopArch } from "./desktopBin";
8
+ import type { DesktopPlatform } from "./mobileTarget";
9
+ import type { NativeBuildApiModule } from "./nativeApi";
10
+ import { TarWriter } from "./tarWriter";
11
+ import { UpdateUploader } from "./updateUploader";
12
+
13
+ export type DesktopSigner = { teamId: string } | { subject: string } | null;
14
+
15
+ /**
16
+ * `release.json`, the one file of a desktop release archive `publish-update` did not write. Schema 2: the archive holds
17
+ * the release's envelope, and its v1 pair only when that is the same release.
18
+ */
19
+ export interface DesktopReleaseRecord {
20
+ schema: 2;
21
+ cli: string;
22
+ platform: DesktopPlatform;
23
+ arch: DesktopArch;
24
+ channel: string;
25
+ signer: DesktopSigner;
26
+ builtAt: string;
27
+ }
28
+
29
+ export type SignatureTool = (
30
+ cmd: string[],
31
+ options?: { env?: Record<string, string> },
32
+ ) => Promise<{ code: number; output: string }>;
33
+
34
+ type DesktopManifest = Partial<UpdateManifest> &
35
+ Required<Pick<UpdateManifest, "archive" | "build" | "bundle" | "sequence">>;
36
+
37
+ interface DesktopReleasePayload {
38
+ name: string;
39
+ size: number;
40
+ sha256: string;
41
+ }
42
+
43
+ interface DesktopReleaseManifestFile {
44
+ name: string;
45
+ bytes: Uint8Array<ArrayBuffer>;
46
+ }
47
+
48
+ //* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed envelope, the v1
49
+ //* pair of the same release, and the files the manifest names, packed as they are, so the same folder uploaded anywhere
50
+ //* else stays valid.
51
+ export class DesktopRelease {
52
+ //? Constrained Language Mode (WDAC, AppLocker) refuses .NET type literals and method calls, so the script makes none.
53
+ //? Windows PowerShell writes its console in the OEM code page, which mangles a subject outside it; a file keeps UTF-8.
54
+ static readonly #authenticode = `$ErrorActionPreference = 'Stop'
55
+ $ProgressPreference = 'SilentlyContinue'
56
+ $signature = Get-AuthenticodeSignature -LiteralPath $env:AKAN_RELEASE_EXE
57
+ $subject = if ("$($signature.Status)" -ceq 'Valid') { "$($signature.SignerCertificate.Subject)" } else { '' }
58
+ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
59
+
60
+ private constructor(
61
+ readonly dir: string,
62
+ readonly manifest: DesktopManifest,
63
+ readonly payloads: DesktopReleasePayload[],
64
+ readonly platform: DesktopPlatform,
65
+ readonly arch: DesktopArch,
66
+ readonly channel: string,
67
+ readonly manifestFiles: DesktopReleaseManifestFile[],
68
+ ) {}
69
+
70
+ static async read(
71
+ updatesDir: string,
72
+ {
73
+ platform,
74
+ arch,
75
+ channel,
76
+ appId,
77
+ target,
78
+ publicKeys,
79
+ revokedKeys = [],
80
+ api,
81
+ }: {
82
+ platform: DesktopPlatform;
83
+ arch: DesktopArch;
84
+ channel: string;
85
+ appId: string;
86
+ target?: string;
87
+ publicKeys: readonly string[];
88
+ revokedKeys?: readonly string[];
89
+ api: Pick<NativeBuildApiModule, "openEnvelope" | "verifyManifest">;
90
+ },
91
+ ) {
92
+ const folder = `${platform}-${arch}`;
93
+ const dir = path.join(updatesDir, folder);
94
+ const envelopeName = `${channel}/app.json`;
95
+ const envelopePath = path.join(dir, channel, "app.json");
96
+ const envelope = await readFile(envelopePath).catch(() => null);
97
+ if (!envelope) throw new Error(`${envelopePath} is missing: run \`akan publish-update\` first.`);
98
+ const opened = DesktopRelease.#open(api, envelope, publicKeys, revokedKeys);
99
+ if (typeof opened === "string") throw new Error(`${envelopePath}: ${opened}; publish the release again.`);
100
+ const manifest = DesktopRelease.#parse(opened);
101
+ if (
102
+ manifest?.schema !== 2 ||
103
+ manifest.kind !== "app" ||
104
+ manifest.app !== appId ||
105
+ manifest.platform !== platform ||
106
+ manifest.arch !== arch ||
107
+ manifest.channel !== channel ||
108
+ manifest.target !== target ||
109
+ typeof manifest.bundle !== "string" ||
110
+ typeof manifest.sequence !== "number" ||
111
+ typeof manifest.build !== "number" ||
112
+ !manifest.archive
113
+ )
114
+ throw new Error(
115
+ `${envelopePath} is not a release of ${appId}${target ? ` target ${target}` : ""} for ${folder} on channel ${channel}.`,
116
+ );
117
+ const { archive, bundle, sequence, build } = manifest;
118
+ const legacy = await DesktopRelease.#legacyPair(api, dir, channel, publicKeys[0], manifest);
119
+ const keys = await UpdateUploader.releaseFiles(updatesDir, { dir, channel, bundle });
120
+ const expected = new Map(
121
+ [{ url: archive.url, size: archive.size, sha256: archive.gzSha256 }, ...(manifest.patches ?? [])].map(
122
+ ({ url, size, sha256 }) => [path.posix.normalize(url), { size, sha256 }],
123
+ ),
124
+ );
125
+ const payloads = keys.flatMap((key) => {
126
+ const name = key.slice(folder.length + 1);
127
+ const digest = expected.get(name);
128
+ return digest ? [{ name, ...digest }] : [];
129
+ });
130
+ return new DesktopRelease(
131
+ dir,
132
+ { ...manifest, archive, bundle, sequence, build },
133
+ payloads,
134
+ platform,
135
+ arch,
136
+ channel,
137
+ [{ name: envelopeName, bytes: envelope }, ...legacy],
138
+ );
139
+ }
140
+
141
+ //? An app built before protocol v2 reads the pair, signed with the first key only; a pair of an earlier release is
142
+ //? left out, since it would publish that release again to those apps.
143
+ static async #legacyPair(
144
+ api: Pick<NativeBuildApiModule, "verifyManifest">,
145
+ dir: string,
146
+ channel: string,
147
+ publicKey: string | undefined,
148
+ manifest: Partial<UpdateManifest>,
149
+ ): Promise<DesktopReleaseManifestFile[]> {
150
+ const [bytes, signature] = await Promise.all(
151
+ [`${channel}.json`, `${channel}.json.sig`].map((name) => readFile(path.join(dir, name)).catch(() => null)),
152
+ );
153
+ if (!bytes || !signature || !publicKey) return [];
154
+ const v1 = DesktopRelease.#parse(bytes);
155
+ if (v1?.schema !== 1 || v1.bundle !== manifest.bundle || v1.sequence !== manifest.sequence) return [];
156
+ if (!api.verifyManifest(bytes, new TextDecoder().decode(signature).trim(), publicKey)) return [];
157
+ return [
158
+ { name: `${channel}.json`, bytes },
159
+ { name: `${channel}.json.sig`, bytes: signature },
160
+ ];
161
+ }
162
+
163
+ record(signer: DesktopSigner): DesktopReleaseRecord {
164
+ const { platform, arch, channel } = this;
165
+ const builtAt = new Date(this.manifest.build * 1000).toISOString();
166
+ return { schema: 2, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
167
+ }
168
+
169
+ //* Akan Cloud refuses every push in any other shape: uncompressed, regular files under `./` names, no directory
170
+ //* entry, no ustar prefix, no GNU L/K, a pax 'x' holding `path` alone, and release.json, the envelope and the v1 pair
171
+ //* before any `app/` entry. What the manifest names is checked against it as it is copied.
172
+ async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
173
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
174
+ try {
175
+ const archive = path.join(tmp, "release.tar");
176
+ const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
177
+ try {
178
+ await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
179
+ for (const { name, bytes } of this.manifestFiles) await tar.add(`./${name}`, new Blob([bytes]));
180
+ for (const { name, size, sha256 } of this.payloads) {
181
+ const file = Bun.file(path.join(this.dir, name));
182
+ const what = `${path.join(this.dir, name)} is not the file ${this.channel}/app.json names`;
183
+ if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
184
+ if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
185
+ }
186
+ await tar.close();
187
+ } catch (error) {
188
+ await tar.discard();
189
+ throw error;
190
+ }
191
+ return await use(archive);
192
+ } finally {
193
+ await rm(tmp, { recursive: true, force: true });
194
+ }
195
+ }
196
+
197
+ async signer({
198
+ exeName,
199
+ host = process.platform,
200
+ run = DesktopRelease.#run,
201
+ }: {
202
+ exeName: string;
203
+ host?: NodeJS.Platform;
204
+ run?: SignatureTool;
205
+ }): Promise<{ signer: DesktopSigner; unread: string | null }> {
206
+ if (this.platform === "linux") return { signer: null, unread: null };
207
+ const app = this.manifest.files?.[0]?.path;
208
+ if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
209
+ return { signer: null, unread: `${this.channel}/app.json names no app folder` };
210
+ if (this.platform === "macos" && host !== "darwin")
211
+ return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
212
+ if (this.platform === "windows" && host !== "win32")
213
+ return { signer: null, unread: "PowerShell reads a Windows app's Authenticode signer on Windows only" };
214
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-signer-"));
215
+ try {
216
+ const archive = path.join(this.dir, this.manifest.archive.url);
217
+ return this.platform === "macos"
218
+ ? await DesktopRelease.#teamOf(run, archive, tmp, app)
219
+ : await DesktopRelease.#subjectOf(run, archive, tmp, path.posix.join(app, `${exeName}.exe`));
220
+ } finally {
221
+ await rm(tmp, { recursive: true, force: true });
222
+ }
223
+ }
224
+
225
+ //? The signature lives in the executable and _CodeSignature; Info.plist names the executable. An app signed without
226
+ //? resources has no _CodeSignature, and bsdtar then exits 1 for the unmatched --include though it wrote the rest.
227
+ static async #teamOf(run: SignatureTool, archive: string, tmp: string, app: string) {
228
+ const contents = `${app}/Contents`;
229
+ await run([
230
+ "/usr/bin/tar",
231
+ "-xzf",
232
+ archive,
233
+ "-C",
234
+ tmp,
235
+ ...["Info.plist", "MacOS/*", "_CodeSignature/*"].flatMap((entry) => ["--include", `${contents}/${entry}`]),
236
+ ]);
237
+ if (!(await Bun.file(path.join(tmp, contents, "Info.plist")).exists()))
238
+ return { signer: null, unread: `the archive holds no ${contents}/Info.plist` };
239
+ const { output } = await run(["/usr/bin/codesign", "--display", "--verbose=2", path.join(tmp, app)]);
240
+ const teamId = /^TeamIdentifier=([A-Z0-9]{10})$/m.exec(output)?.[1];
241
+ if (teamId) return { signer: { teamId }, unread: null };
242
+ if (/^TeamIdentifier=not set$/m.test(output) || /code object is not signed at all/.test(output))
243
+ return { signer: null, unread: null };
244
+ return { signer: null, unread: `codesign could not read ${app}: ${output.trim().split("\n")[0] ?? ""}` };
245
+ }
246
+
247
+ static async #subjectOf(run: SignatureTool, archive: string, tmp: string, exe: string) {
248
+ //? Windows' own bsdtar: a GNU tar ahead of it on PATH (Git's) takes `C:` for a remote host.
249
+ const tar = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
250
+ await run([tar, "-xzf", archive, "-C", tmp, exe]);
251
+ const extracted = path.join(tmp, exe);
252
+ if (!(await Bun.file(extracted).exists())) return { signer: null, unread: `the archive holds no ${exe}` };
253
+ const out = path.join(tmp, "signer.txt");
254
+ const { code } = await run(
255
+ [
256
+ path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
257
+ "-NoProfile",
258
+ "-NonInteractive",
259
+ "-EncodedCommand",
260
+ Buffer.from(DesktopRelease.#authenticode, "utf16le").toString("base64"),
261
+ ],
262
+ { env: { AKAN_RELEASE_EXE: extracted, AKAN_SIGNER_OUT: out } },
263
+ );
264
+ if (code !== 0) return { signer: null, unread: `PowerShell could not read the Authenticode signature of ${exe}` };
265
+ const subject = (await readFile(out, "utf8").catch(() => "")).replace(/^\uFEFF/, "").trim();
266
+ return { signer: subject ? { subject } : null, unread: null };
267
+ }
268
+
269
+ static #open(
270
+ api: Pick<NativeBuildApiModule, "openEnvelope">,
271
+ envelope: Uint8Array,
272
+ publicKeys: readonly string[],
273
+ revokedKeys: readonly string[],
274
+ ): Uint8Array | string {
275
+ try {
276
+ return api.openEnvelope(new TextDecoder().decode(envelope), publicKeys, revokedKeys).manifest;
277
+ } catch (error) {
278
+ return toError(error).message;
279
+ }
280
+ }
281
+
282
+ static #parse(bytes: Uint8Array): Partial<UpdateManifest> | null {
283
+ try {
284
+ const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
285
+ return isRecord(value) ? (value as Partial<UpdateManifest>) : null;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ static async #run(cmd: string[], { env }: { env?: Record<string, string> } = {}) {
292
+ try {
293
+ const proc = Bun.spawn(cmd, {
294
+ env: { ...process.env, ...env },
295
+ stdin: "ignore",
296
+ stdout: "pipe",
297
+ stderr: "pipe",
298
+ });
299
+ const [code, stdout, stderr] = await Promise.all([
300
+ proc.exited,
301
+ new Response(proc.stdout).text(),
302
+ new Response(proc.stderr).text(),
303
+ ]);
304
+ return { code, output: `${stdout}${stderr}` };
305
+ } catch (error) {
306
+ return { code: -1, output: toError(error).message };
307
+ }
308
+ }
309
+ }
@@ -24,11 +24,13 @@ export interface DesktopServerRun extends ServerPolicy {
24
24
  //* `bun install --production` does. It is staged outside dist: installing there would put host packages into the
25
25
  //* image's `COPY . .`.
26
26
  export class DesktopServerStage {
27
- //? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR artifact,
28
- //? and `install` wrote its own package.json. The rest of dist is the server's: its chunks, a `.node`, a `.wasm`.
27
+ //? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR
28
+ //? artifact, and `install` wrote its own package.json and bun.lock. The rest of dist is the server's: its chunks,
29
+ //? a `.node`, a `.wasm`.
29
30
  static readonly uncarried = new Set([
30
31
  "Dockerfile",
31
32
  "package.json",
33
+ "bun.lock",
32
34
  "rscWorker.js",
33
35
  "console.js",
34
36
  "console-runtime.js",
package/mobile/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./desktopBin";
2
+ export * from "./desktopRelease";
2
3
  export * from "./desktopServerStage";
3
4
  export * from "./mobileTarget";
4
5
  export * from "./nativeApi";
@@ -8,6 +9,7 @@ export * from "./nativePluginFolders";
8
9
  export * from "./nativePublicEnv";
9
10
  export * from "./nativeWebDir";
10
11
  export * from "./placeholderAppId";
12
+ export * from "./tarWriter";
11
13
  export * from "./updatePackFolder";
12
14
  export * from "./updateUploader";
13
15
  export * from "./usageDescriptions";
@@ -42,6 +42,9 @@ describe("NativeApi", () => {
42
42
  });
43
43
 
44
44
  test("refuses an API older than the one the build calls into, and another major, before anything builds", async () => {
45
+ await expect(NativeApi.load(await installVendoredApi("0.12.0"))).rejects.toThrow(
46
+ "install matching versions of akanjs and @akanjs/cli",
47
+ );
45
48
  await expect(NativeApi.load(await installVendoredApi("0.10.0"))).rejects.toThrow(
46
49
  "install matching versions of akanjs and @akanjs/cli",
47
50
  );
@@ -51,6 +54,6 @@ describe("NativeApi", () => {
51
54
  expect((await NativeApi.load(await installVendoredApi(NativeApi.minimumVersion))).API_VERSION).toBe(
52
55
  NativeApi.minimumVersion,
53
56
  );
54
- expect((await NativeApi.load(await installVendoredApi("0.12.0"))).API_VERSION).toBe("0.12.0");
57
+ expect((await NativeApi.load(await installVendoredApi("0.13.4"))).API_VERSION).toBe("0.13.4");
55
58
  });
56
59
  });
@@ -6,9 +6,9 @@ export type NativeBuildApiModule = typeof NativeBuildApi;
6
6
  export class NativeApi {
7
7
  //* The build API versions itself; a major this devkit was not written against is refused before anything builds.
8
8
  static readonly supportedMajor = 0;
9
- //? An update release's `revoke` and `resetSequence` arrived in 0.12.0, and an older API would publish without them;
10
- //? `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
11
- static readonly minimumVersion = "0.12.0";
9
+ //? A desktop release's envelope and `updates.target` arrived in 0.13.0, and an older API refuses the key; an update
10
+ //? release's `revoke` and `resetSequence` in 0.12.0; `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
11
+ static readonly minimumVersion = "0.13.0";
12
12
 
13
13
  /** The API of the akanjs the app installed: the workspace package inside this repo, akanjs's vendored copy elsewhere. */
14
14
  static entryOf(appDir: string): string {
@@ -341,8 +341,8 @@ describe("NativeApp", () => {
341
341
  (await new NativeApp(fakeApp(), target({ basePath: undefined, updates }), env).config({ platform: "android" }))
342
342
  .config.updates;
343
343
 
344
- expect(await built("main")).toEqual({ ...updates, channel: "main" });
345
- expect(await built("debug")).toEqual({ ...updates, channel: "debug" });
344
+ expect(await built("main")).toEqual({ ...updates, channel: "main", target: "default" });
345
+ expect(await built("debug")).toEqual({ ...updates, channel: "debug", target: "default" });
346
346
  });
347
347
 
348
348
  test("a desktop app is this computer's platform", () => {
@@ -5,6 +5,7 @@ import type { AkanNativeConfig } from "akanjs/native/config";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { Executor } from "../executors";
7
7
  import { type DesktopArch, DesktopBin } from "./desktopBin";
8
+ import { DesktopRelease } from "./desktopRelease";
8
9
  import type { DesktopServerBundle } from "./desktopServerStage";
9
10
  import {
10
11
  type DesktopPlatform,
@@ -26,7 +27,7 @@ type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
26
27
  type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
27
28
  type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
28
29
  type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
29
- type PhoneRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
30
+ type ReleaseRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
30
31
 
31
32
  export interface NativeRunOptions {
32
33
  device?: DeviceSelector;
@@ -311,6 +312,29 @@ export class NativeApp {
311
312
  return path.join(this.targetRoot, "updates");
312
313
  }
313
314
 
315
+ //? The channel falls back as publishUpdate's does, so a push with the flags of the publish finds what it wrote.
316
+ async publishedRelease(platform: DesktopPlatform, { arch, channel }: { arch: DesktopArch; channel?: string | null }) {
317
+ this.#assertUpdates();
318
+ const [api, { config }] = await Promise.all([
319
+ NativeApi.load(this.app.cwdPath),
320
+ this.config({ platform, stageBin: false }),
321
+ ]);
322
+ const { updates } = config;
323
+ const publicKeys = updates?.publicKeys ?? (updates?.publicKey ? [updates.publicKey] : []);
324
+ if (!updates || !publicKeys.length) throw new Error(`Native target '${this.target.name}' has no updates key.`);
325
+ const release = await DesktopRelease.read(this.updatesDir, {
326
+ platform,
327
+ arch,
328
+ channel: channel ?? updates.channel ?? "production",
329
+ appId: config.app.id,
330
+ ...(updates.target ? { target: updates.target } : {}),
331
+ publicKeys,
332
+ revokedKeys: updates.revokedKeys ?? [],
333
+ api,
334
+ });
335
+ return { release, url: updates.url, exeName: config.app.fileName ?? "" };
336
+ }
337
+
314
338
  /** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
315
339
  async publishUpdate(
316
340
  platform: NativePlatform,
@@ -328,7 +352,7 @@ export class NativeApp {
328
352
  arch?: DesktopArch;
329
353
  sign?: boolean;
330
354
  notarize?: boolean;
331
- } & PhoneRevocation = {},
355
+ } & ReleaseRevocation = {},
332
356
  ) {
333
357
  NativeApp.#assertServerPlatform(platform, server);
334
358
  this.#assertUpdates();
@@ -382,8 +406,8 @@ export class NativeApp {
382
406
 
383
407
  /**
384
408
  * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
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.
409
+ * another app's, a desktop release whose server presence is not its channel's previous release's, and a release whose
410
+ * revocations no app would take.
387
411
  */
388
412
  async assertPublishable(
389
413
  platform: NativePlatform,
@@ -393,7 +417,7 @@ export class NativeApp {
393
417
  arch,
394
418
  revoke,
395
419
  resetSequence,
396
- }: { channel?: string; server?: boolean; arch?: DesktopArch } & PhoneRevocation = {},
420
+ }: { channel?: string; server?: boolean; arch?: DesktopArch } & ReleaseRevocation = {},
397
421
  ) {
398
422
  this.#assertUpdates();
399
423
  const [api, { config }] = await Promise.all([
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { generateKeyPairSync } from "node:crypto";
2
3
  import { mkdir, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import type { AkanNativeTarget } from "../akanConfig";
@@ -42,7 +43,9 @@ const adminTarget: AkanNativeTarget = {
42
43
  },
43
44
  };
44
45
 
45
- const updatesKey = Buffer.alloc(32, 7).toString("base64");
46
+ const ed25519Key = () =>
47
+ generateKeyPairSync("ed25519").publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
48
+ const updatesKey = ed25519Key();
46
49
 
47
50
  describe("NativeConfig.build", () => {
48
51
  test("hands the dmg window over with its pictures resolved against the app folder", () => {
@@ -224,19 +227,22 @@ describe("NativeConfig.build", () => {
224
227
  url,
225
228
  publicKey: updatesKey,
226
229
  channel: "develop",
230
+ target: "admin",
227
231
  });
228
232
  expect(build({ url, publicKey: updatesKey, channel: "beta", readyTimeout: 15_000 }, "main").updates).toEqual({
229
233
  url,
230
234
  publicKey: updatesKey,
231
235
  channel: "beta",
232
236
  readyTimeout: 15_000,
237
+ target: "admin",
233
238
  });
234
- expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
235
- const spare = Buffer.alloc(32, 8).toString("base64");
239
+ expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey, target: "admin" });
240
+ const spare = ed25519Key();
236
241
  expect(build({ url, publicKeys: [updatesKey, spare] }, "main").updates).toEqual({
237
242
  url,
238
243
  publicKeys: [updatesKey, spare],
239
244
  channel: "main",
245
+ target: "admin",
240
246
  });
241
247
  const report = { url: "https://cloud.example.com/api/ota/report/abcdefghijklmnop" };
242
248
  expect(build({ url, publicKey: updatesKey, report }, "main").updates).toEqual({
@@ -244,6 +250,7 @@ describe("NativeConfig.build", () => {
244
250
  publicKey: updatesKey,
245
251
  channel: "main",
246
252
  report,
253
+ target: "admin",
247
254
  });
248
255
  expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
249
256
  expect(build(undefined, "main").plugins).not.toContain("updates");
@@ -457,7 +464,7 @@ describe("NativeConfig.build", () => {
457
464
  });
458
465
 
459
466
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "updates"]);
460
- expect(config.updates).toEqual(updates);
467
+ expect(config.updates).toEqual({ ...updates, target: minimalTarget.name });
461
468
  });
462
469
 
463
470
  test("an updates channel left unnamed is the backend env the binary is built for", () => {
@@ -479,10 +486,11 @@ describe("NativeConfig.build", () => {
479
486
  ...(env ? { env } : {}),
480
487
  }).config.updates;
481
488
 
482
- expect(build("debug")).toEqual({ ...updates, channel: "debug" });
483
- expect(build("main")).toEqual({ ...updates, channel: "main" });
484
- expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot" });
485
- expect(build()).toEqual(updates);
489
+ const target = minimalTarget.name;
490
+ expect(build("debug")).toEqual({ ...updates, channel: "debug", target });
491
+ expect(build("main")).toEqual({ ...updates, channel: "main", target });
492
+ expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot", target });
493
+ expect(build()).toEqual({ ...updates, target });
486
494
  });
487
495
 
488
496
  test("keeps a file name the runtime accepts", () => {
@@ -267,7 +267,7 @@ export class NativeConfig {
267
267
  static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
268
268
  const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
269
269
  const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
270
- return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}) } : undefined;
270
+ return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}), target: target.name } : undefined;
271
271
  }
272
272
 
273
273
  /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { TarWriter } from "./tarWriter";
7
+
8
+ const makeDir = tempDirs("akan-tar-writer-");
9
+ const delta = `./app/${"a".repeat(64)}-${"b".repeat(64)}.delta.gz`;
10
+ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
11
+
12
+ const write = async (file: string, entries: [string, string][], mtime = 1_761_700_000) => {
13
+ const tar = await TarWriter.create(file, { mtime });
14
+ const digests: string[] = [];
15
+ for (const [name, body] of entries) digests.push(await tar.add(name, new Blob([body])));
16
+ await tar.close();
17
+ return digests;
18
+ };
19
+
20
+ describe("TarWriter", () => {
21
+ test("a system tar reads back every file under its name, a 144-byte one too, and lists nothing else", async () => {
22
+ const dir = await makeDir();
23
+ const file = path.join(dir, "release.tar");
24
+ const entries: [string, string][] = [
25
+ ["./release.json", '{"schema":1}\n'],
26
+ [delta, "x".repeat(1_000)],
27
+ ];
28
+
29
+ const digests = await write(file, entries);
30
+
31
+ expect(digests).toEqual(entries.map(([, body]) => sha256(body)));
32
+ const listed = Bun.spawnSync(["tar", "-tf", file]).stdout.toString().split(/\r?\n/).filter(Boolean);
33
+ expect(listed).toEqual(entries.map(([name]) => name));
34
+ const out = path.join(dir, "out");
35
+ await mkdir(out);
36
+ expect(Bun.spawnSync(["tar", "-xf", file, "-C", out]).exitCode).toBe(0);
37
+ for (const [name, body] of entries) expect(await readFile(path.join(out, name), "utf8")).toBe(body);
38
+ });
39
+
40
+ test("writes ustar headers of mode 0644, owner 0 and the given mtime, padded to 512-byte blocks", async () => {
41
+ const dir = await makeDir();
42
+ const file = path.join(dir, "release.tar");
43
+
44
+ await write(file, [["./a.json", "{}"]], 1_761_700_000);
45
+
46
+ const bytes = await readFile(file);
47
+ const field = (at: number, width: number) => bytes.subarray(at, at + width).toString("latin1");
48
+ expect(field(0, 9)).toBe("./a.json\0");
49
+ expect(field(100, 8)).toBe("0000644\0");
50
+ expect(field(108, 16)).toBe("0000000\x000000000\0");
51
+ expect(Number.parseInt(field(136, 11), 8)).toBe(1_761_700_000);
52
+ expect(field(156, 1)).toBe("0");
53
+ expect(field(257, 8)).toBe("ustar\x0000");
54
+ expect(bytes.length).toBe(512 + 512 + 1024);
55
+ expect(bytes.subarray(1024).every((byte) => byte === 0)).toBe(true);
56
+ });
57
+
58
+ test("the same files and mtime make the same archive", async () => {
59
+ const dir = await makeDir();
60
+ const entries: [string, string][] = [
61
+ ["./main.json", "{}\n"],
62
+ [delta, "patch"],
63
+ ];
64
+
65
+ await write(path.join(dir, "one.tar"), entries);
66
+ await write(path.join(dir, "two.tar"), entries);
67
+
68
+ expect(sha256(await readFile(path.join(dir, "one.tar")))).toBe(sha256(await readFile(path.join(dir, "two.tar"))));
69
+ });
70
+
71
+ test("refuses a file whose length is not the one it was announced with", async () => {
72
+ const dir = await makeDir();
73
+ const tar = await TarWriter.create(path.join(dir, "release.tar"));
74
+ const grown = { size: 3, stream: () => new Blob(["abcdef"]).stream() } as unknown as Blob;
75
+
76
+ await expect(tar.add("./app/x.tar.gz", grown)).rejects.toThrow(
77
+ "./app/x.tar.gz changed while it was archived: 6 bytes, not 3.",
78
+ );
79
+ await tar.discard();
80
+ });
81
+
82
+ test("never overwrites an existing file", async () => {
83
+ const file = path.join(await makeDir(), "release.tar");
84
+ await Bun.write(file, "kept");
85
+
86
+ await expect(TarWriter.create(file)).rejects.toThrow();
87
+ expect(await readFile(file, "utf8")).toBe("kept");
88
+ });
89
+ });