@akanjs/devkit 3.1.1-rc.1 → 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.
Files changed (37) hide show
  1. package/akanConfig/akanConfig.test.ts +13 -2
  2. package/akanConfig/akanConfig.ts +5 -5
  3. package/akanConfig/productionLockfile.test.ts +80 -0
  4. package/akanConfig/productionLockfile.ts +32 -0
  5. package/akanContext.ts +25 -0
  6. package/applicationBuildRunner.test.ts +41 -0
  7. package/applicationBuildRunner.ts +23 -3
  8. package/biomeBase.test.ts +2 -2
  9. package/cloud/cloudApi.ts +10 -4
  10. package/cloud/cloudHttpError.test.ts +8 -0
  11. package/cloud/cloudHttpError.ts +11 -1
  12. package/cloud/cloudUpdates.ts +48 -2
  13. package/cloud/updatePackUpload.test.ts +38 -2
  14. package/cloud/updatePackUpload.ts +5 -1
  15. package/commandDecorators/command.ts +3 -14
  16. package/commandDecorators/commandDecorators.test.ts +1 -1
  17. package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
  18. package/commandDecorators/workspaceVersionCheck.ts +50 -0
  19. package/executors.test.ts +21 -1
  20. package/lint/gritRules.test.ts +3 -3
  21. package/linter.test.ts +18 -1
  22. package/linter.ts +10 -6
  23. package/mobile/desktopRelease.test.ts +473 -0
  24. package/mobile/desktopRelease.ts +307 -0
  25. package/mobile/desktopServerStage.ts +4 -2
  26. package/mobile/index.ts +2 -0
  27. package/mobile/nativeApi.test.ts +4 -1
  28. package/mobile/nativeApi.ts +3 -3
  29. package/mobile/nativeApp.test.ts +2 -2
  30. package/mobile/nativeApp.ts +29 -5
  31. package/mobile/nativeConfig.test.ts +16 -8
  32. package/mobile/nativeConfig.ts +1 -1
  33. package/mobile/tarWriter.test.ts +89 -0
  34. package/mobile/tarWriter.ts +93 -0
  35. package/mobile/updateUploader.test.ts +34 -0
  36. package/mobile/updateUploader.ts +9 -6
  37. package/package.json +2 -2
@@ -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
+ }
@@ -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.1",
3
+ "version": "3.1.1-rc.10",
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.1",
51
+ "akanjs": "3.1.1-rc.10",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",