@akanjs/devkit 3.1.1-rc.5 → 3.1.1-rc.7
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/cloud/cloudApi.ts +4 -1
- package/cloud/cloudUpdates.ts +37 -1
- 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/mobile/desktopRelease.test.ts +361 -0
- package/mobile/desktopRelease.ts +270 -0
- package/mobile/desktopServerStage.ts +4 -2
- package/mobile/index.ts +2 -0
- package/mobile/nativeApp.ts +17 -0
- package/mobile/tarWriter.test.ts +89 -0
- package/mobile/tarWriter.ts +93 -0
- package/package.json +2 -2
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,
|
|
@@ -311,6 +312,22 @@ 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 { 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.`);
|
|
321
|
+
const release = await DesktopRelease.read(this.updatesDir, {
|
|
322
|
+
platform,
|
|
323
|
+
arch,
|
|
324
|
+
channel: channel ?? config.updates.channel ?? "production",
|
|
325
|
+
appId: config.app.id,
|
|
326
|
+
publicKey,
|
|
327
|
+
});
|
|
328
|
+
return { release, url: config.updates.url, exeName: config.app.fileName ?? "" };
|
|
329
|
+
}
|
|
330
|
+
|
|
314
331
|
/** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
|
|
315
332
|
async publishUpdate(
|
|
316
333
|
platform: NativePlatform,
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { type FileHandle, open } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
//* An uncompressed POSIX tar of regular files, the same bytes on every OS. A system tar also writes what the host keeps
|
|
5
|
+
//* beside a file (macOS bsdtar an AppleDouble `._` entry for extended attributes), which a receiver refusing unlisted
|
|
6
|
+
//* entries rejects. A name past the ustar field's 100 bytes travels in a pax `path` record before its header.
|
|
7
|
+
export class TarWriter {
|
|
8
|
+
static readonly #block = 512;
|
|
9
|
+
static readonly #nameBytes = 100;
|
|
10
|
+
//? The size field holds 11 octal digits.
|
|
11
|
+
static readonly #maxSize = 8 ** 11 - 1;
|
|
12
|
+
readonly #handle: FileHandle;
|
|
13
|
+
readonly #mtime: number;
|
|
14
|
+
|
|
15
|
+
private constructor(handle: FileHandle, mtime: number) {
|
|
16
|
+
this.#handle = handle;
|
|
17
|
+
this.#mtime = mtime;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
//? `mtime` (seconds) goes on every entry, so the same files make the same archive.
|
|
21
|
+
static async create(file: string, { mtime = 0 }: { mtime?: number } = {}) {
|
|
22
|
+
return new TarWriter(await open(file, "wx"), mtime);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async add(name: string, source: Blob): Promise<string> {
|
|
26
|
+
const { size } = source;
|
|
27
|
+
if (size > TarWriter.#maxSize) throw new Error(`${name} is ${size} bytes, more than a tar entry holds.`);
|
|
28
|
+
if (Buffer.byteLength(name) > TarWriter.#nameBytes) {
|
|
29
|
+
const pax = Buffer.from(TarWriter.#paxRecord("path", name));
|
|
30
|
+
await this.#write(
|
|
31
|
+
TarWriter.#header(`./PaxHeaders/${name.split("/").at(-1) ?? ""}`, pax.length, this.#mtime, "x"),
|
|
32
|
+
);
|
|
33
|
+
await this.#write(pax);
|
|
34
|
+
await this.#pad(pax.length);
|
|
35
|
+
}
|
|
36
|
+
await this.#write(TarWriter.#header(name, size, this.#mtime, "0"));
|
|
37
|
+
const hasher = createHash("sha256");
|
|
38
|
+
let written = 0;
|
|
39
|
+
for await (const chunk of source.stream()) {
|
|
40
|
+
hasher.update(chunk);
|
|
41
|
+
await this.#write(chunk);
|
|
42
|
+
written += chunk.length;
|
|
43
|
+
}
|
|
44
|
+
if (written !== size) throw new Error(`${name} changed while it was archived: ${written} bytes, not ${size}.`);
|
|
45
|
+
await this.#pad(size);
|
|
46
|
+
return hasher.digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async close() {
|
|
50
|
+
await this.#write(new Uint8Array(TarWriter.#block * 2));
|
|
51
|
+
await this.#handle.close();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async discard() {
|
|
55
|
+
await this.#handle.close().catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async #write(bytes: Uint8Array) {
|
|
59
|
+
for (let at = 0; at < bytes.length; ) at += (await this.#handle.write(bytes, at)).bytesWritten;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #pad(size: number) {
|
|
63
|
+
const rest = size % TarWriter.#block;
|
|
64
|
+
if (rest) await this.#write(new Uint8Array(TarWriter.#block - rest));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//? A record's length counts its own digits: `<length> <key>=<value>\n`.
|
|
68
|
+
static #paxRecord(key: string, value: string) {
|
|
69
|
+
const body = Buffer.byteLength(` ${key}=${value}\n`);
|
|
70
|
+
let length = body + 1;
|
|
71
|
+
while (body + String(length).length !== length) length = body + String(length).length;
|
|
72
|
+
return `${length} ${key}=${value}\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static #header(name: string, size: number, mtime: number, type: "0" | "x") {
|
|
76
|
+
const header = Buffer.alloc(TarWriter.#block);
|
|
77
|
+
header.write(name, 0, TarWriter.#nameBytes, "utf8");
|
|
78
|
+
const octal = (value: number, at: number, width: number) =>
|
|
79
|
+
header.write(`${value.toString(8).padStart(width - 1, "0")}\0`, at, width, "latin1");
|
|
80
|
+
octal(0o644, 100, 8);
|
|
81
|
+
octal(0, 108, 8);
|
|
82
|
+
octal(0, 116, 8);
|
|
83
|
+
octal(size, 124, 12);
|
|
84
|
+
octal(mtime, 136, 12);
|
|
85
|
+
header.write(" ", 148, 8, "latin1");
|
|
86
|
+
header.write(type, 156, 1, "latin1");
|
|
87
|
+
header.write("ustar\0", 257, 6, "latin1");
|
|
88
|
+
header.write("00", 263, 2, "latin1");
|
|
89
|
+
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
|
90
|
+
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "latin1");
|
|
91
|
+
return header;
|
|
92
|
+
}
|
|
93
|
+
}
|
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.7",
|
|
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.7",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|