@akanjs/devkit 3.1.1-rc.8 → 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/cloudUpdates.ts +3 -2
- package/mobile/desktopRelease.test.ts +93 -22
- package/mobile/desktopRelease.ts +80 -44
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +18 -11
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +2 -2
package/cloud/cloudUpdates.ts
CHANGED
|
@@ -102,8 +102,9 @@ export class CloudUpdates {
|
|
|
102
102
|
const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
|
|
103
103
|
if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
|
|
104
104
|
const manifestUrl =
|
|
105
|
-
typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}.json`;
|
|
106
|
-
const
|
|
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)`;
|
|
107
108
|
Logger.rawLog(
|
|
108
109
|
record.status === "published"
|
|
109
110
|
? `Published ${what}. Installed apps read ${manifestUrl}.`
|
|
@@ -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:
|
|
18
|
-
|
|
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
|
-
|
|
72
|
-
await writeFile(
|
|
73
|
-
|
|
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,10 +101,30 @@ const read = (
|
|
|
80
101
|
platform = "macos",
|
|
81
102
|
arch = "arm64",
|
|
82
103
|
channel = "main",
|
|
83
|
-
|
|
104
|
+
publicKeys = [key.publicKey, spare.publicKey],
|
|
105
|
+
revokedKeys = [],
|
|
106
|
+
target = "default",
|
|
84
107
|
id = appId,
|
|
85
|
-
}: {
|
|
86
|
-
|
|
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);
|
|
@@ -122,7 +163,8 @@ describe("DesktopRelease", () => {
|
|
|
122
163
|
const published = await publish();
|
|
123
164
|
const { dir, tarSha, delta } = published;
|
|
124
165
|
await publish({ channel: "develop" }).then(async (other) => {
|
|
125
|
-
|
|
166
|
+
await mkdir(path.join(dir, "develop"), { recursive: true });
|
|
167
|
+
for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
|
|
126
168
|
await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
|
|
127
169
|
});
|
|
128
170
|
await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
|
|
@@ -133,6 +175,7 @@ describe("DesktopRelease", () => {
|
|
|
133
175
|
|
|
134
176
|
expect(entries).toEqual([
|
|
135
177
|
"./release.json",
|
|
178
|
+
"./main/app.json",
|
|
136
179
|
"./main.json",
|
|
137
180
|
"./main.json.sig",
|
|
138
181
|
`./app/${tarSha}.tar.gz`,
|
|
@@ -152,24 +195,24 @@ describe("DesktopRelease", () => {
|
|
|
152
195
|
expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
|
|
153
196
|
expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
|
|
154
197
|
const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
|
|
155
|
-
expect(files.slice(0,
|
|
156
|
-
expect(files.slice(
|
|
157
|
-
expect(files.slice(
|
|
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}`]);
|
|
158
201
|
expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
|
|
159
202
|
});
|
|
160
203
|
|
|
161
|
-
test("the
|
|
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 () => {
|
|
162
205
|
const published = await publish();
|
|
163
206
|
const release = await read(published);
|
|
164
207
|
const record = release.record({ teamId: "ABCDE12345" });
|
|
165
208
|
|
|
166
209
|
const out = await release.withArchive(record, extract);
|
|
167
210
|
|
|
168
|
-
for (const name of ["main.json", "main.json.sig"])
|
|
211
|
+
for (const name of ["main/app.json", "main.json", "main.json.sig"])
|
|
169
212
|
expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
|
|
170
213
|
expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
|
|
171
214
|
expect(record).toEqual({
|
|
172
|
-
schema:
|
|
215
|
+
schema: 2,
|
|
173
216
|
cli: AkanAppConfig.akanVersion,
|
|
174
217
|
platform: "macos",
|
|
175
218
|
arch: "arm64",
|
|
@@ -192,22 +235,50 @@ describe("DesktopRelease", () => {
|
|
|
192
235
|
expect(await Bun.file(kept).exists()).toBe(false);
|
|
193
236
|
});
|
|
194
237
|
|
|
195
|
-
test("refuses
|
|
238
|
+
test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
|
|
196
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.`;
|
|
197
242
|
|
|
198
|
-
await expect(read(published, {
|
|
199
|
-
|
|
243
|
+
await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
|
|
244
|
+
await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
|
|
245
|
+
});
|
|
246
|
+
|
|
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")),
|
|
200
267
|
);
|
|
268
|
+
expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
|
|
201
269
|
});
|
|
202
270
|
|
|
203
271
|
test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
|
|
204
272
|
const published = await publish();
|
|
205
273
|
|
|
206
274
|
await expect(read(published, { id: "com.other" })).rejects.toThrow(
|
|
207
|
-
"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.`,
|
|
208
279
|
);
|
|
209
280
|
await expect(read(published, { channel: "develop" })).rejects.toThrow(
|
|
210
|
-
`${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.`,
|
|
211
282
|
);
|
|
212
283
|
await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
|
|
213
284
|
});
|
|
@@ -219,9 +290,9 @@ describe("DesktopRelease", () => {
|
|
|
219
290
|
const send = () => release.withArchive(release.record(null), async () => "sent");
|
|
220
291
|
|
|
221
292
|
await writeFile(archive, "the release's gzip tar!");
|
|
222
|
-
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.`);
|
|
223
294
|
await writeFile(archive, "the release's gzip taR");
|
|
224
|
-
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).`);
|
|
225
296
|
});
|
|
226
297
|
});
|
|
227
298
|
|
package/mobile/desktopRelease.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
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 !==
|
|
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(
|
|
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,20 +134,41 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
118
134
|
platform,
|
|
119
135
|
arch,
|
|
120
136
|
channel,
|
|
121
|
-
|
|
122
|
-
|
|
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:
|
|
166
|
+
return { schema: 2, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
|
|
130
167
|
}
|
|
131
168
|
|
|
132
169
|
//* Akan Cloud refuses every push in any other shape: uncompressed, regular files under `./` names, no directory
|
|
133
|
-
//* entry, no ustar prefix, no GNU L/K, a pax 'x' holding `path` alone, and release.json and the
|
|
134
|
-
//* any `app/` entry. What the manifest names is checked against it as it is copied.
|
|
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.
|
|
135
172
|
async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
|
|
136
173
|
const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
|
|
137
174
|
try {
|
|
@@ -139,11 +176,10 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
139
176
|
const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
|
|
140
177
|
try {
|
|
141
178
|
await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
|
|
142
|
-
await tar.add(`./${
|
|
143
|
-
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]));
|
|
144
180
|
for (const { name, size, sha256 } of this.payloads) {
|
|
145
181
|
const file = Bun.file(path.join(this.dir, name));
|
|
146
|
-
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`;
|
|
147
183
|
if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
|
|
148
184
|
if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
|
|
149
185
|
}
|
|
@@ -170,7 +206,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
170
206
|
if (this.platform === "linux") return { signer: null, unread: null };
|
|
171
207
|
const app = this.manifest.files?.[0]?.path;
|
|
172
208
|
if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
|
|
173
|
-
return { signer: null, unread: `${this.channel}.json names no app folder` };
|
|
209
|
+
return { signer: null, unread: `${this.channel}/app.json names no app folder` };
|
|
174
210
|
if (this.platform === "macos" && host !== "darwin")
|
|
175
211
|
return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
|
|
176
212
|
if (this.platform === "windows" && host !== "win32")
|
|
@@ -228,16 +264,16 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
228
264
|
return { signer: subject ? { subject } : null, unread: null };
|
|
229
265
|
}
|
|
230
266
|
|
|
231
|
-
static #
|
|
267
|
+
static #open(
|
|
268
|
+
api: Pick<NativeBuildApiModule, "openEnvelope">,
|
|
269
|
+
envelope: Uint8Array,
|
|
270
|
+
publicKeys: readonly string[],
|
|
271
|
+
revokedKeys: readonly string[],
|
|
272
|
+
): Uint8Array | string {
|
|
232
273
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
type: "spki",
|
|
237
|
-
});
|
|
238
|
-
return verify(null, manifest, key, Buffer.from(new TextDecoder().decode(signature).trim(), "base64"));
|
|
239
|
-
} catch {
|
|
240
|
-
return false;
|
|
274
|
+
return api.openEnvelope(new TextDecoder().decode(envelope), publicKeys, revokedKeys).manifest;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return toError(error).message;
|
|
241
277
|
}
|
|
242
278
|
}
|
|
243
279
|
|
package/mobile/nativeApi.test.ts
CHANGED
|
@@ -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.
|
|
57
|
+
expect((await NativeApi.load(await installVendoredApi("0.13.4"))).API_VERSION).toBe("0.13.4");
|
|
55
58
|
});
|
|
56
59
|
});
|
package/mobile/nativeApi.ts
CHANGED
|
@@ -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
|
-
//?
|
|
10
|
-
//? `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
|
|
11
|
-
static readonly minimumVersion = "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 {
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -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", () => {
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -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
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
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 ??
|
|
328
|
+
channel: channel ?? updates.channel ?? "production",
|
|
325
329
|
appId: config.app.id,
|
|
326
|
-
|
|
330
|
+
...(updates.target ? { target: updates.target } : {}),
|
|
331
|
+
publicKeys,
|
|
332
|
+
revokedKeys: updates.revokedKeys ?? [],
|
|
333
|
+
api,
|
|
327
334
|
});
|
|
328
|
-
return { release, url:
|
|
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
|
-
} &
|
|
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
|
|
403
|
-
*
|
|
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 } &
|
|
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
|
|
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 =
|
|
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
|
-
|
|
483
|
-
expect(build("
|
|
484
|
-
expect(build("
|
|
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", () => {
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -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
|
{
|
package/mobile/updateUploader.ts
CHANGED
|
@@ -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
|
|
55
|
-
|
|
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.
|
|
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.
|
|
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",
|