@akanjs/devkit 3.1.1-rc.7 → 3.1.1-rc.9

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.
package/cloud/cloudApi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isRecord } from "akanjs/common";
1
+ import { isRecord, Logger } from "akanjs/common";
2
2
  import type { Workspace } from "../commandDecorators";
3
3
  import { CloudHttpError } from "./cloudHttpError";
4
4
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
@@ -123,8 +123,11 @@ export class CloudApi {
123
123
  if (error.status === 401)
124
124
  return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
125
125
  if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
126
- if (error.status === 400)
127
- return new Error(`The project refused the request${error.error ? `: ${error.error}` : ""}.`, { cause: error });
126
+ if (error.status === 400) {
127
+ const { data } = error;
128
+ const why = `${error.error ? `: ${error.error}` : ""}${data ? ` ${Logger.inlineData(data)}` : ""}`;
129
+ return new Error(`The project refused the request${why}.`, { cause: error });
130
+ }
128
131
  //? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
129
132
  const { errKey } = error;
130
133
  if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
@@ -20,6 +20,14 @@ describe("CloudHttpError", () => {
20
20
  expect(plain.errKey).toBeNull();
21
21
  });
22
22
 
23
+ test("reads the data a server Err carried, and none from a body without it", () => {
24
+ const body = JSON.stringify({ error: "release.error.desktopMalformed", data: { reason: "a directory entry" } });
25
+
26
+ expect(new CloudHttpError(400, "Bad Request", "/x", body).data).toEqual({ reason: "a directory entry" });
27
+ expect(new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request","data":"x"}').data).toBeNull();
28
+ expect(new CloudHttpError(502, "Bad Gateway", "/x", "<html>502</html>").data).toBeNull();
29
+ });
30
+
23
31
  test("takes Retry-After as seconds or as a date, and nothing else", () => {
24
32
  expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
25
33
  expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
@@ -26,9 +26,19 @@ export class CloudHttpError extends Error {
26
26
 
27
27
  /** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
28
28
  get error(): string | null {
29
+ const error = this.#payload?.error;
30
+ return typeof error === "string" ? error : null;
31
+ }
32
+
33
+ get data(): Record<string, unknown> | null {
34
+ const data = this.#payload?.data;
35
+ return isRecord(data) ? data : null;
36
+ }
37
+
38
+ get #payload(): Record<string, unknown> | null {
29
39
  try {
30
40
  const parsed: unknown = JSON.parse(this.body);
31
- return isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : null;
41
+ return isRecord(parsed) ? parsed : null;
32
42
  } catch {
33
43
  return null;
34
44
  }
@@ -9,6 +9,7 @@ import { NativeApp } from "../mobile/nativeApp";
9
9
  import { NativePublicEnv } from "../mobile/nativePublicEnv";
10
10
  import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
11
11
  import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
12
+ import { CloudHttpError } from "./cloudHttpError";
12
13
  import { UpdatePackUpload } from "./updatePackUpload";
13
14
 
14
15
  //* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
@@ -61,7 +62,7 @@ export class CloudUpdates {
61
62
  const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
62
63
  .send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
63
64
  .catch((error: unknown) => {
64
- throw CloudApi.refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
65
+ throw CloudUpdates.#refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
65
66
  });
66
67
  const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
67
68
  if (release.status === "rejected")
@@ -95,14 +96,15 @@ export class CloudUpdates {
95
96
  const record = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
96
97
  .send(archive, { appName, target, platform, kind: "app", arch, channel, notes })
97
98
  .catch((error: unknown) => {
98
- throw CloudApi.refusal(error, "a release is uploaded by a project member signed in with `akan login`");
99
+ throw CloudUpdates.#refusal(error, "a release is uploaded by a project member signed in with `akan login`");
99
100
  });
100
101
  const label = `${appName} ${target} ${platform}-${arch} on channel ${channel}: release ${record.id}`;
101
102
  const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
102
103
  if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
103
104
  const manifestUrl =
104
- typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}.json`;
105
- const what = `${label} (${release.manifest.bundle}, ${release.payloads.length + 3} files)`;
105
+ typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}/app.json`;
106
+ const files = 1 + release.manifestFiles.length + release.payloads.length;
107
+ const what = `${label} (${release.manifest.bundle}, ${files} files)`;
106
108
  Logger.rawLog(
107
109
  record.status === "published"
108
110
  ? `Published ${what}. Installed apps read ${manifestUrl}.`
@@ -113,6 +115,14 @@ export class CloudUpdates {
113
115
  });
114
116
  }
115
117
 
118
+ //? A 400 naming a release Err key is the project's verdict on the release, as a rejected one is: a retry changes nothing.
119
+ static #refusal(error: unknown, who: string) {
120
+ const refusal = CloudApi.refusal(error, who);
121
+ const verdict =
122
+ error instanceof CloudHttpError && error.status === 400 && !!error.errKey?.startsWith("release.error.");
123
+ return verdict && refusal instanceof Error ? new ExitCodeError(refusal.message, 2) : refusal;
124
+ }
125
+
116
126
  //? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
117
127
  async registerBinary(
118
128
  app: App,
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createHash, generateKeyPairSync, sign } from "node:crypto";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import * as nativeApi from "akanjs/native/api";
5
6
  import { AkanAppConfig } from "../akanConfig";
6
7
  import { tempDirs } from "../testHelpers";
7
8
  import type { DesktopArch } from "./desktopBin";
@@ -13,12 +14,18 @@ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes
13
14
 
14
15
  const keyOf = () => {
15
16
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
17
+ const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
18
+ const signature = (bytes: string) => sign(null, Buffer.from(bytes), privateKey).toString("base64");
16
19
  return {
17
- publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"),
18
- sign: (bytes: string) => `${sign(null, Buffer.from(bytes), privateKey).toString("base64")}\n`,
20
+ publicKey: raw,
21
+ keyId: nativeApi.keyIdOf(raw),
22
+ sign: (bytes: string) => `${signature(bytes)}\n`,
23
+ seal: (bytes: string) =>
24
+ `${JSON.stringify({ manifest: Buffer.from(bytes).toString("base64"), keyId: nativeApi.keyIdOf(raw), signature: signature(bytes) }, null, 2)}\n`,
19
25
  };
20
26
  };
21
27
  const key = keyOf();
28
+ const spare = keyOf();
22
29
  const appId = "com.akancad.app";
23
30
 
24
31
  interface Published {
@@ -35,12 +42,18 @@ const publish = async ({
35
42
  channel = "main",
36
43
  app = platform === "macos" ? "Akancad.app" : "akancad",
37
44
  gz = "the release's gzip tar",
45
+ target = "default",
46
+ signer = key,
47
+ pair = "same",
38
48
  }: {
39
49
  platform?: DesktopPlatform;
40
50
  arch?: DesktopArch;
41
51
  channel?: string;
42
52
  app?: string;
43
53
  gz?: string;
54
+ target?: string;
55
+ signer?: ReturnType<typeof keyOf>;
56
+ pair?: "same" | "earlier" | "none";
44
57
  } = {}) => {
45
58
  const updatesDir = await makeDir();
46
59
  const dir = path.join(updatesDir, `${platform}-${arch}`);
@@ -68,9 +81,17 @@ const publish = async ({
68
81
  patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
69
82
  server: true,
70
83
  };
71
- const bytes = `${JSON.stringify(manifest, null, 2)}\n`;
72
- await writeFile(path.join(dir, `${channel}.json`), bytes);
73
- await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
84
+ await mkdir(path.join(dir, channel), { recursive: true });
85
+ await writeFile(
86
+ path.join(dir, channel, "app.json"),
87
+ signer.seal(`${JSON.stringify({ ...manifest, schema: 2, target }, null, 2)}\n`),
88
+ );
89
+ if (pair !== "none") {
90
+ const earlier = { ...manifest, sequence: manifest.sequence - 100, bundle: "1.1.0-1761699900" };
91
+ const bytes = `${JSON.stringify(pair === "same" ? manifest : earlier, null, 2)}\n`;
92
+ await writeFile(path.join(dir, `${channel}.json`), bytes);
93
+ await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
94
+ }
74
95
  return { updatesDir, dir, tarSha, delta, build } satisfies Published;
75
96
  };
76
97
 
@@ -80,14 +101,57 @@ const read = (
80
101
  platform = "macos",
81
102
  arch = "arm64",
82
103
  channel = "main",
83
- publicKey = key.publicKey,
104
+ publicKeys = [key.publicKey, spare.publicKey],
105
+ revokedKeys = [],
106
+ target = "default",
84
107
  id = appId,
85
- }: { platform?: DesktopPlatform; arch?: DesktopArch; channel?: string; publicKey?: string; id?: string } = {},
86
- ) => DesktopRelease.read(updatesDir, { platform, arch, channel, appId: id, publicKey });
108
+ }: {
109
+ platform?: DesktopPlatform;
110
+ arch?: DesktopArch;
111
+ channel?: string;
112
+ publicKeys?: string[];
113
+ revokedKeys?: string[];
114
+ target?: string;
115
+ id?: string;
116
+ } = {},
117
+ ) =>
118
+ DesktopRelease.read(updatesDir, {
119
+ platform,
120
+ arch,
121
+ channel,
122
+ appId: id,
123
+ target,
124
+ publicKeys,
125
+ revokedKeys,
126
+ api: nativeApi,
127
+ });
87
128
 
88
129
  const entriesOf = (archive: string) =>
89
130
  Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
90
131
 
132
+ //? Every header, a pax one too: a file's name is the `path` of the pax header before it, else its own name field.
133
+ const headersOf = (bytes: Uint8Array) => {
134
+ const headers: { type: string; name: string; prefix: string; paxKeys: string[] }[] = [];
135
+ let paxPath: string | null = null;
136
+ for (let at = 0; at + 512 <= bytes.length; ) {
137
+ const block = Buffer.from(bytes.subarray(at, at + 512));
138
+ if (block.every((byte) => byte === 0)) break;
139
+ const field = (from: number, width: number) => block.toString("latin1", from, from + width).replace(/\0.*$/s, "");
140
+ const type = field(156, 1);
141
+ const size = Number.parseInt(field(124, 12), 8);
142
+ const records = Buffer.from(bytes.subarray(at + 512, at + 512 + size))
143
+ .toString("utf8")
144
+ .split("\n")
145
+ .filter(Boolean);
146
+ at += 512 + Math.ceil(size / 512) * 512;
147
+ const pax = type === "x" ? records.map((record) => record.slice(record.indexOf(" ") + 1)) : [];
148
+ const paxKeys = pax.map((record) => record.slice(0, record.indexOf("=")));
149
+ headers.push({ type, name: (type === "x" ? null : paxPath) ?? field(0, 100), prefix: field(345, 155), paxKeys });
150
+ paxPath = type === "x" ? (pax.find((record) => record.startsWith("path="))?.slice(5) ?? null) : null;
151
+ }
152
+ return headers;
153
+ };
154
+
91
155
  const extract = async (archive: string) => {
92
156
  const out = await makeDir();
93
157
  expect(Bun.spawnSync(["tar", "-xf", archive, "-C", out]).exitCode).toBe(0);
@@ -99,7 +163,8 @@ describe("DesktopRelease", () => {
99
163
  const published = await publish();
100
164
  const { dir, tarSha, delta } = published;
101
165
  await publish({ channel: "develop" }).then(async (other) => {
102
- for (const name of ["develop.json", "develop.json.sig"])
166
+ await mkdir(path.join(dir, "develop"), { recursive: true });
167
+ for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
103
168
  await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
104
169
  });
105
170
  await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
@@ -110,6 +175,7 @@ describe("DesktopRelease", () => {
110
175
 
111
176
  expect(entries).toEqual([
112
177
  "./release.json",
178
+ "./main/app.json",
113
179
  "./main.json",
114
180
  "./main.json.sig",
115
181
  `./app/${tarSha}.tar.gz`,
@@ -117,18 +183,36 @@ describe("DesktopRelease", () => {
117
183
  ]);
118
184
  });
119
185
 
120
- test("the manifest and its signature go in byte for byte, beside a release.json of the CLI and the build time", async () => {
186
+ test("keeps the one tar shape Akan Cloud accepts, a pax name of a long patch included", async () => {
187
+ const published = await publish();
188
+ const release = await read(published);
189
+
190
+ const headers = await release.withArchive(release.record(null), async (archive) =>
191
+ headersOf(await readFile(archive)),
192
+ );
193
+
194
+ expect(new Set(headers.map(({ type }) => type))).toEqual(new Set(["0", "x"]));
195
+ expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
196
+ expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
197
+ const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
198
+ expect(files.slice(0, 4)).toEqual(["./release.json", "./main/app.json", "./main.json", "./main.json.sig"]);
199
+ expect(files.slice(4).every((name) => name.startsWith("./app/"))).toBe(true);
200
+ expect(files.slice(4)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
201
+ expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
202
+ });
203
+
204
+ test("the envelope and the v1 pair go in byte for byte, beside a release.json of the CLI and the build time", async () => {
121
205
  const published = await publish();
122
206
  const release = await read(published);
123
207
  const record = release.record({ teamId: "ABCDE12345" });
124
208
 
125
209
  const out = await release.withArchive(record, extract);
126
210
 
127
- for (const name of ["main.json", "main.json.sig"])
211
+ for (const name of ["main/app.json", "main.json", "main.json.sig"])
128
212
  expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
129
213
  expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
130
214
  expect(record).toEqual({
131
- schema: 1,
215
+ schema: 2,
132
216
  cli: AkanAppConfig.akanVersion,
133
217
  platform: "macos",
134
218
  arch: "arm64",
@@ -151,22 +235,50 @@ describe("DesktopRelease", () => {
151
235
  expect(await Bun.file(kept).exists()).toBe(false);
152
236
  });
153
237
 
154
- test("refuses a manifest the target's first key did not sign, before reading anything it names", async () => {
238
+ test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
155
239
  const published = await publish();
240
+ const envelope = path.join(published.dir, "main", "app.json");
241
+ const untrusted = `${envelope}: the update envelope is signed by key ${key.keyId}, which is not trusted; publish the release again.`;
242
+
243
+ await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
244
+ await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
245
+ });
156
246
 
157
- await expect(read(published, { publicKey: keyOf().publicKey })).rejects.toThrow(
158
- "main.json.sig does not verify with updates.publicKeys[0], the one key installed apps trust",
247
+ test("takes a release a later key signed, and leaves out a v1 pair that is not that release", async () => {
248
+ const entriesFor = async (published: Published) => {
249
+ const release = await read(published);
250
+ return await release.withArchive(release.record(null), async (archive) => entriesOf(archive).slice(0, 4));
251
+ };
252
+ const tail = (published: Published) => `./app/${published.tarSha}.tar.gz`;
253
+
254
+ const bySpare = await publish({ signer: spare, pair: "earlier" });
255
+ expect(await entriesFor(bySpare)).toEqual([
256
+ "./release.json",
257
+ "./main/app.json",
258
+ tail(bySpare),
259
+ `./app/${bySpare.delta}`,
260
+ ]);
261
+ const unpaired = await publish({ pair: "none" });
262
+ expect((await entriesFor(unpaired)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(unpaired)]);
263
+ const forged = await publish();
264
+ await writeFile(
265
+ path.join(forged.dir, "main.json.sig"),
266
+ spare.sign(await readFile(path.join(forged.dir, "main.json"), "utf8")),
159
267
  );
268
+ expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
160
269
  });
161
270
 
162
271
  test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
163
272
  const published = await publish();
164
273
 
165
274
  await expect(read(published, { id: "com.other" })).rejects.toThrow(
166
- "is not a release of com.other for macos-arm64 on channel main.",
275
+ "is not a release of com.other target default for macos-arm64 on channel main.",
276
+ );
277
+ await expect(read(published, { target: "pilot" })).rejects.toThrow(
278
+ `is not a release of ${appId} target pilot for macos-arm64 on channel main.`,
167
279
  );
168
280
  await expect(read(published, { channel: "develop" })).rejects.toThrow(
169
- `${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
281
+ `${path.join(published.dir, "develop", "app.json")} is missing: run \`akan publish-update\` first.`,
170
282
  );
171
283
  await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
172
284
  });
@@ -178,9 +290,9 @@ describe("DesktopRelease", () => {
178
290
  const send = () => release.withArchive(release.record(null), async () => "sent");
179
291
 
180
292
  await writeFile(archive, "the release's gzip tar!");
181
- await expect(send()).rejects.toThrow(`${archive} is not the file main.json names: publish the release again.`);
293
+ await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names: publish the release again.`);
182
294
  await writeFile(archive, "the release's gzip taR");
183
- await expect(send()).rejects.toThrow(`${archive} is not the file main.json names (its sha256 differs).`);
295
+ await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names (its sha256 differs).`);
184
296
  });
185
297
  });
186
298
 
@@ -1,4 +1,3 @@
1
- import { createPublicKey, verify } from "node:crypto";
2
1
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
@@ -7,14 +6,18 @@ import type { UpdateManifest } from "akanjs/native/api";
7
6
  import { AkanAppConfig } from "../akanConfig";
8
7
  import type { DesktopArch } from "./desktopBin";
9
8
  import type { DesktopPlatform } from "./mobileTarget";
9
+ import type { NativeBuildApiModule } from "./nativeApi";
10
10
  import { TarWriter } from "./tarWriter";
11
11
  import { UpdateUploader } from "./updateUploader";
12
12
 
13
13
  export type DesktopSigner = { teamId: string } | { subject: string } | null;
14
14
 
15
- /** `release.json`, the one file of a desktop release archive `publish-update` did not write. */
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
+ */
16
19
  export interface DesktopReleaseRecord {
17
- schema: 1;
20
+ schema: 2;
18
21
  cli: string;
19
22
  platform: DesktopPlatform;
20
23
  arch: DesktopArch;
@@ -37,10 +40,15 @@ interface DesktopReleasePayload {
37
40
  sha256: string;
38
41
  }
39
42
 
40
- //* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed manifest and the
41
- //* files it names, packed as they are, so the same folder uploaded anywhere else stays valid.
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.
42
51
  export class DesktopRelease {
43
- static readonly #ed25519Spki = Buffer.from("302a300506032b6570032100", "hex");
44
52
  //? Constrained Language Mode (WDAC, AppLocker) refuses .NET type literals and method calls, so the script makes none.
45
53
  //? Windows PowerShell writes its console in the OEM code page, which mangles a subject outside it; a file keeps UTF-8.
46
54
  static readonly #authenticode = `$ErrorActionPreference = 'Stop'
@@ -56,8 +64,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
56
64
  readonly platform: DesktopPlatform,
57
65
  readonly arch: DesktopArch,
58
66
  readonly channel: string,
59
- readonly manifestBytes: Uint8Array<ArrayBuffer>,
60
- readonly signatureBytes: Uint8Array<ArrayBuffer>,
67
+ readonly manifestFiles: DesktopReleaseManifestFile[],
61
68
  ) {}
62
69
 
63
70
  static async read(
@@ -67,39 +74,48 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
67
74
  arch,
68
75
  channel,
69
76
  appId,
70
- publicKey,
71
- }: { platform: DesktopPlatform; arch: DesktopArch; channel: string; appId: string; publicKey: string },
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
+ },
72
91
  ) {
73
92
  const folder = `${platform}-${arch}`;
74
93
  const dir = path.join(updatesDir, folder);
75
- const manifestPath = path.join(dir, `${channel}.json`);
76
- const [manifestBytes, signatureBytes] = await Promise.all([
77
- readFile(manifestPath).catch(() => null),
78
- readFile(`${manifestPath}.sig`).catch(() => null),
79
- ]);
80
- if (!manifestBytes || !signatureBytes)
81
- throw new Error(
82
- `${manifestBytes ? `${manifestPath}.sig` : manifestPath} is missing: run \`akan publish-update\` first.`,
83
- );
84
- if (!DesktopRelease.#verifies(manifestBytes, signatureBytes, publicKey))
85
- throw new Error(
86
- `${manifestPath}.sig does not verify with updates.publicKeys[0], the one key installed apps trust: publish the release again.`,
87
- );
88
- const manifest = DesktopRelease.#parse(manifestBytes);
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);
89
101
  if (
90
- manifest?.schema !== 1 ||
102
+ manifest?.schema !== 2 ||
91
103
  manifest.kind !== "app" ||
92
104
  manifest.app !== appId ||
93
105
  manifest.platform !== platform ||
94
106
  manifest.arch !== arch ||
95
107
  manifest.channel !== channel ||
108
+ manifest.target !== target ||
96
109
  typeof manifest.bundle !== "string" ||
97
110
  typeof manifest.sequence !== "number" ||
98
111
  typeof manifest.build !== "number" ||
99
112
  !manifest.archive
100
113
  )
101
- throw new Error(`${manifestPath} is not a release of ${appId} for ${folder} on channel ${channel}.`);
114
+ throw new Error(
115
+ `${envelopePath} is not a release of ${appId}${target ? ` target ${target}` : ""} for ${folder} on channel ${channel}.`,
116
+ );
102
117
  const { archive, bundle, sequence, build } = manifest;
118
+ const legacy = await DesktopRelease.#legacyPair(api, dir, channel, publicKeys[0], manifest);
103
119
  const keys = await UpdateUploader.releaseFiles(updatesDir, { dir, channel, bundle });
104
120
  const expected = new Map(
105
121
  [{ url: archive.url, size: archive.size, sha256: archive.gzSha256 }, ...(manifest.patches ?? [])].map(
@@ -118,19 +134,41 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
118
134
  platform,
119
135
  arch,
120
136
  channel,
121
- manifestBytes,
122
- signatureBytes,
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)),
123
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
+ ];
124
161
  }
125
162
 
126
163
  record(signer: DesktopSigner): DesktopReleaseRecord {
127
164
  const { platform, arch, channel } = this;
128
165
  const builtAt = new Date(this.manifest.build * 1000).toISOString();
129
- return { schema: 1, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
166
+ return { schema: 2, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
130
167
  }
131
168
 
132
- //* Uncompressed, regular files only under `./` names, no directory entries; what the manifest names is checked
133
- //* against it while it is copied, so the archive holds the bytes the manifest signs for.
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.
134
172
  async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
135
173
  const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
136
174
  try {
@@ -138,11 +176,10 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
138
176
  const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
139
177
  try {
140
178
  await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
141
- await tar.add(`./${this.channel}.json`, new Blob([this.manifestBytes]));
142
- await tar.add(`./${this.channel}.json.sig`, new Blob([this.signatureBytes]));
179
+ for (const { name, bytes } of this.manifestFiles) await tar.add(`./${name}`, new Blob([bytes]));
143
180
  for (const { name, size, sha256 } of this.payloads) {
144
181
  const file = Bun.file(path.join(this.dir, name));
145
- const what = `${path.join(this.dir, name)} is not the file ${this.channel}.json names`;
182
+ const what = `${path.join(this.dir, name)} is not the file ${this.channel}/app.json names`;
146
183
  if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
147
184
  if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
148
185
  }
@@ -169,7 +206,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
169
206
  if (this.platform === "linux") return { signer: null, unread: null };
170
207
  const app = this.manifest.files?.[0]?.path;
171
208
  if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
172
- return { signer: null, unread: `${this.channel}.json names no app folder` };
209
+ return { signer: null, unread: `${this.channel}/app.json names no app folder` };
173
210
  if (this.platform === "macos" && host !== "darwin")
174
211
  return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
175
212
  if (this.platform === "windows" && host !== "win32")
@@ -227,16 +264,16 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
227
264
  return { signer: subject ? { subject } : null, unread: null };
228
265
  }
229
266
 
230
- static #verifies(manifest: Uint8Array, signature: Uint8Array, publicKey: string) {
267
+ static #open(
268
+ api: Pick<NativeBuildApiModule, "openEnvelope">,
269
+ envelope: Uint8Array,
270
+ publicKeys: readonly string[],
271
+ revokedKeys: readonly string[],
272
+ ): Uint8Array | string {
231
273
  try {
232
- const key = createPublicKey({
233
- key: Buffer.concat([DesktopRelease.#ed25519Spki, Buffer.from(publicKey, "base64")]),
234
- format: "der",
235
- type: "spki",
236
- });
237
- return verify(null, manifest, key, Buffer.from(new TextDecoder().decode(signature).trim(), "base64"));
238
- } catch {
239
- return false;
274
+ return api.openEnvelope(new TextDecoder().decode(envelope), publicKeys, revokedKeys).manifest;
275
+ } catch (error) {
276
+ return toError(error).message;
240
277
  }
241
278
  }
242
279
 
@@ -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", () => {
@@ -27,7 +27,7 @@ type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
27
27
  type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
28
28
  type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
29
29
  type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
30
- type PhoneRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
30
+ type ReleaseRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
31
31
 
32
32
  export interface NativeRunOptions {
33
33
  device?: DeviceSelector;
@@ -315,17 +315,24 @@ export class NativeApp {
315
315
  //? The channel falls back as publishUpdate's does, so a push with the flags of the publish finds what it wrote.
316
316
  async publishedRelease(platform: DesktopPlatform, { arch, channel }: { arch: DesktopArch; channel?: string | null }) {
317
317
  this.#assertUpdates();
318
- const { config } = await this.config({ platform, stageBin: false });
319
- const publicKey = config.updates?.publicKeys?.[0] ?? config.updates?.publicKey;
320
- if (!config.updates || !publicKey) throw new Error(`Native target '${this.target.name}' has no updates key.`);
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.`);
321
325
  const release = await DesktopRelease.read(this.updatesDir, {
322
326
  platform,
323
327
  arch,
324
- channel: channel ?? config.updates.channel ?? "production",
328
+ channel: channel ?? updates.channel ?? "production",
325
329
  appId: config.app.id,
326
- publicKey,
330
+ ...(updates.target ? { target: updates.target } : {}),
331
+ publicKeys,
332
+ revokedKeys: updates.revokedKeys ?? [],
333
+ api,
327
334
  });
328
- return { release, url: config.updates.url, exeName: config.app.fileName ?? "" };
335
+ return { release, url: updates.url, exeName: config.app.fileName ?? "" };
329
336
  }
330
337
 
331
338
  /** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
@@ -345,7 +352,7 @@ export class NativeApp {
345
352
  arch?: DesktopArch;
346
353
  sign?: boolean;
347
354
  notarize?: boolean;
348
- } & PhoneRevocation = {},
355
+ } & ReleaseRevocation = {},
349
356
  ) {
350
357
  NativeApp.#assertServerPlatform(platform, server);
351
358
  this.#assertUpdates();
@@ -399,8 +406,8 @@ export class NativeApp {
399
406
 
400
407
  /**
401
408
  * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
402
- * another app's, a desktop release whose server presence is not its channel's previous release's, and a phone release
403
- * 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.
404
411
  */
405
412
  async assertPublishable(
406
413
  platform: NativePlatform,
@@ -410,7 +417,7 @@ export class NativeApp {
410
417
  arch,
411
418
  revoke,
412
419
  resetSequence,
413
- }: { channel?: string; server?: boolean; arch?: DesktopArch } & PhoneRevocation = {},
420
+ }: { channel?: string; server?: boolean; arch?: DesktopArch } & ReleaseRevocation = {},
414
421
  ) {
415
422
  this.#assertUpdates();
416
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. */
@@ -83,6 +83,40 @@ describe("UpdateUploader", () => {
83
83
  );
84
84
  });
85
85
 
86
+ test("uploads a desktop release's envelope, with the v1 pair only when the pair is that release", async () => {
87
+ const manifest = { kind: "app", bundle: "1.3.0-3000", archive: { url: `app/${sha("c")}.tar.gz` }, patches: [] };
88
+ await inUpdatesDir(
89
+ {
90
+ [`macos-arm64/app/${sha("c")}.tar.gz`]: "tar",
91
+ "macos-arm64/main/app.json": envelope(manifest),
92
+ "macos-arm64/main.json": JSON.stringify({ ...manifest, bundle: "1.2.0-2000" }),
93
+ "macos-arm64/main.json.sig": "sig",
94
+ "macos-arm64/beta/app.json": envelope({ ...manifest, bundle: "1.4.0-4000" }),
95
+ "macos-arm64/beta.json": JSON.stringify({ ...manifest, bundle: "1.4.0-4000" }),
96
+ "macos-arm64/beta.json.sig": "sig",
97
+ },
98
+ async (updatesDir) => {
99
+ const dir = path.join(updatesDir, "macos-arm64");
100
+ const upload = async (channel: string, bundle: string) => {
101
+ const { bucket, writes } = fakeBucket();
102
+ await new UpdateUploader(bucket, "").upload(updatesDir, { dir, channel, bundle });
103
+ return writes.map(({ key }) => key);
104
+ };
105
+
106
+ expect(await upload("main", "1.3.0-3000")).toEqual([
107
+ `macos-arm64/app/${sha("c")}.tar.gz`,
108
+ "macos-arm64/main/app.json",
109
+ ]);
110
+ expect(await upload("beta", "1.4.0-4000")).toEqual([
111
+ `macos-arm64/app/${sha("c")}.tar.gz`,
112
+ "macos-arm64/beta.json.sig",
113
+ "macos-arm64/beta.json",
114
+ "macos-arm64/beta/app.json",
115
+ ]);
116
+ },
117
+ );
118
+ });
119
+
86
120
  test("uploads a phone release's own files and envelope, never pack records or another manifest beside them", async () => {
87
121
  await inUpdatesDir(
88
122
  {
@@ -51,12 +51,8 @@ export class UpdateUploader {
51
51
  //* APIs, which a later publish from another machine may have replaced.
52
52
  static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
53
53
  const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
54
- const desktop = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
- if (desktop?.bundle === bundle) {
56
- const archive = desktop.archive?.url;
57
- const patches = UpdateUploader.#field(desktop.patches, "url");
58
- return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
59
- }
54
+ const v1 = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
+ const pair = v1?.bundle === bundle ? [`${channel}.json.sig`, `${channel}.json`] : [];
60
56
  const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
61
57
  for (const name of names.filter((name) => name.endsWith(".json"))) {
62
58
  const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
@@ -65,6 +61,8 @@ export class UpdateUploader {
65
61
  ? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
66
62
  : null;
67
63
  if (manifest?.bundle !== bundle) continue;
64
+ if (manifest.kind === "app")
65
+ return [...UpdateUploader.#appFiles(manifest), ...pair, `${channel}/${name}`].map(keyOf);
68
66
  const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
69
67
  if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
70
68
  throw new Error(
@@ -72,9 +70,14 @@ export class UpdateUploader {
72
70
  );
73
71
  return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
74
72
  }
73
+ if (v1 && pair.length) return [...UpdateUploader.#appFiles(v1), ...pair].map(keyOf);
75
74
  throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
76
75
  }
77
76
 
77
+ static #appFiles(manifest: Partial<UpdateManifest>): unknown[] {
78
+ return [manifest.archive?.url, ...UpdateUploader.#field(manifest.patches, "url")];
79
+ }
80
+
78
81
  static #keyOf(updatesDir: string, dir: string, file: unknown) {
79
82
  const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
80
83
  const key = path.relative(updatesDir, resolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.7",
3
+ "version": "3.1.1-rc.9",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.7",
51
+ "akanjs": "3.1.1-rc.9",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",