@akanjs/devkit 3.1.1-rc.0 → 3.1.1-rc.10
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/akanConfig/akanConfig.test.ts +13 -2
- package/akanConfig/akanConfig.ts +5 -5
- package/akanConfig/productionLockfile.test.ts +80 -0
- package/akanConfig/productionLockfile.ts +32 -0
- package/akanContext.ts +25 -0
- package/applicationBuildRunner.test.ts +41 -0
- package/applicationBuildRunner.ts +23 -3
- package/biomeBase.test.ts +2 -2
- package/cloud/cloudApi.test.ts +46 -0
- package/cloud/cloudApi.ts +23 -13
- package/cloud/cloudHttpError.test.ts +8 -0
- package/cloud/cloudHttpError.ts +11 -1
- package/cloud/cloudUpdates.ts +48 -2
- package/cloud/updatePackUpload.test.ts +38 -2
- package/cloud/updatePackUpload.ts +5 -1
- package/commandDecorators/command.ts +3 -14
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
- package/commandDecorators/workspaceVersionCheck.ts +50 -0
- package/executors.test.ts +21 -1
- package/index.ts +0 -1
- package/lint/gritRules.test.ts +3 -3
- package/linter.test.ts +18 -1
- package/linter.ts +10 -6
- package/mobile/desktopRelease.test.ts +473 -0
- package/mobile/desktopRelease.ts +307 -0
- package/mobile/desktopServerStage.ts +4 -2
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +29 -5
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/tarWriter.test.ts +89 -0
- package/mobile/tarWriter.ts +93 -0
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +2 -2
- package/renderMutationScanner.test.ts +223 -0
- package/renderMutationScanner.ts +209 -0
- package/signalMutationIndex.test.ts +120 -0
- package/signalMutationIndex.ts +127 -0
- package/ssrScanner.test.ts +31 -0
- package/ssrScanner.ts +31 -12
- package/uploadRelease.ts +0 -80
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
await run(["tar.exe", "-xzf", archive, "-C", tmp, exe]);
|
|
249
|
+
const extracted = path.join(tmp, exe);
|
|
250
|
+
if (!(await Bun.file(extracted).exists())) return { signer: null, unread: `the archive holds no ${exe}` };
|
|
251
|
+
const out = path.join(tmp, "signer.txt");
|
|
252
|
+
const { code } = await run(
|
|
253
|
+
[
|
|
254
|
+
path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
255
|
+
"-NoProfile",
|
|
256
|
+
"-NonInteractive",
|
|
257
|
+
"-EncodedCommand",
|
|
258
|
+
Buffer.from(DesktopRelease.#authenticode, "utf16le").toString("base64"),
|
|
259
|
+
],
|
|
260
|
+
{ env: { AKAN_RELEASE_EXE: extracted, AKAN_SIGNER_OUT: out } },
|
|
261
|
+
);
|
|
262
|
+
if (code !== 0) return { signer: null, unread: `PowerShell could not read the Authenticode signature of ${exe}` };
|
|
263
|
+
const subject = (await readFile(out, "utf8").catch(() => "")).replace(/^\uFEFF/, "").trim();
|
|
264
|
+
return { signer: subject ? { subject } : null, unread: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
static #open(
|
|
268
|
+
api: Pick<NativeBuildApiModule, "openEnvelope">,
|
|
269
|
+
envelope: Uint8Array,
|
|
270
|
+
publicKeys: readonly string[],
|
|
271
|
+
revokedKeys: readonly string[],
|
|
272
|
+
): Uint8Array | string {
|
|
273
|
+
try {
|
|
274
|
+
return api.openEnvelope(new TextDecoder().decode(envelope), publicKeys, revokedKeys).manifest;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return toError(error).message;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
static #parse(bytes: Uint8Array): Partial<UpdateManifest> | null {
|
|
281
|
+
try {
|
|
282
|
+
const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
283
|
+
return isRecord(value) ? (value as Partial<UpdateManifest>) : null;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
static async #run(cmd: string[], { env }: { env?: Record<string, string> } = {}) {
|
|
290
|
+
try {
|
|
291
|
+
const proc = Bun.spawn(cmd, {
|
|
292
|
+
env: { ...process.env, ...env },
|
|
293
|
+
stdin: "ignore",
|
|
294
|
+
stdout: "pipe",
|
|
295
|
+
stderr: "pipe",
|
|
296
|
+
});
|
|
297
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
298
|
+
proc.exited,
|
|
299
|
+
new Response(proc.stdout).text(),
|
|
300
|
+
new Response(proc.stderr).text(),
|
|
301
|
+
]);
|
|
302
|
+
return { code, output: `${stdout}${stderr}` };
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return { code: -1, output: toError(error).message };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -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
|
|
28
|
-
//? and `install` wrote its own package.json. The rest of dist is the server's: its chunks,
|
|
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";
|
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
|
@@ -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
|
|
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
|
-
} &
|
|
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
|
|
386
|
-
*
|
|
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 } &
|
|
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
|
|
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. */
|
|
@@ -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
|
+
});
|