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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cloud/cloudApi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isRecord } from "akanjs/common";
1
+ import { isRecord, Logger } from "akanjs/common";
2
2
  import type { Workspace } from "../commandDecorators";
3
3
  import { CloudHttpError } from "./cloudHttpError";
4
4
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
@@ -123,8 +123,11 @@ export class CloudApi {
123
123
  if (error.status === 401)
124
124
  return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
125
125
  if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
126
- if (error.status === 400)
127
- return new Error(`The project refused the request${error.error ? `: ${error.error}` : ""}.`, { cause: error });
126
+ if (error.status === 400) {
127
+ const { data } = error;
128
+ const why = `${error.error ? `: ${error.error}` : ""}${data ? ` ${Logger.inlineData(data)}` : ""}`;
129
+ return new Error(`The project refused the request${why}.`, { cause: error });
130
+ }
128
131
  //? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
129
132
  const { errKey } = error;
130
133
  if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
@@ -20,6 +20,14 @@ describe("CloudHttpError", () => {
20
20
  expect(plain.errKey).toBeNull();
21
21
  });
22
22
 
23
+ test("reads the data a server Err carried, and none from a body without it", () => {
24
+ const body = JSON.stringify({ error: "release.error.desktopMalformed", data: { reason: "a directory entry" } });
25
+
26
+ expect(new CloudHttpError(400, "Bad Request", "/x", body).data).toEqual({ reason: "a directory entry" });
27
+ expect(new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request","data":"x"}').data).toBeNull();
28
+ expect(new CloudHttpError(502, "Bad Gateway", "/x", "<html>502</html>").data).toBeNull();
29
+ });
30
+
23
31
  test("takes Retry-After as seconds or as a date, and nothing else", () => {
24
32
  expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
25
33
  expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
@@ -26,9 +26,19 @@ export class CloudHttpError extends Error {
26
26
 
27
27
  /** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
28
28
  get error(): string | null {
29
+ const error = this.#payload?.error;
30
+ return typeof error === "string" ? error : null;
31
+ }
32
+
33
+ get data(): Record<string, unknown> | null {
34
+ const data = this.#payload?.data;
35
+ return isRecord(data) ? data : null;
36
+ }
37
+
38
+ get #payload(): Record<string, unknown> | null {
29
39
  try {
30
40
  const parsed: unknown = JSON.parse(this.body);
31
- return isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : null;
41
+ return isRecord(parsed) ? parsed : null;
32
42
  } catch {
33
43
  return null;
34
44
  }
@@ -9,6 +9,7 @@ import { NativeApp } from "../mobile/nativeApp";
9
9
  import { NativePublicEnv } from "../mobile/nativePublicEnv";
10
10
  import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
11
11
  import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
12
+ import { CloudHttpError } from "./cloudHttpError";
12
13
  import { UpdatePackUpload } from "./updatePackUpload";
13
14
 
14
15
  //* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
@@ -61,7 +62,7 @@ export class CloudUpdates {
61
62
  const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
62
63
  .send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
63
64
  .catch((error: unknown) => {
64
- throw CloudApi.refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
65
+ throw CloudUpdates.#refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
65
66
  });
66
67
  const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
67
68
  if (release.status === "rejected")
@@ -95,7 +96,7 @@ export class CloudUpdates {
95
96
  const record = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
96
97
  .send(archive, { appName, target, platform, kind: "app", arch, channel, notes })
97
98
  .catch((error: unknown) => {
98
- throw CloudApi.refusal(error, "a release is uploaded by a project member signed in with `akan login`");
99
+ throw CloudUpdates.#refusal(error, "a release is uploaded by a project member signed in with `akan login`");
99
100
  });
100
101
  const label = `${appName} ${target} ${platform}-${arch} on channel ${channel}: release ${record.id}`;
101
102
  const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
@@ -113,6 +114,14 @@ export class CloudUpdates {
113
114
  });
114
115
  }
115
116
 
117
+ //? A 400 naming a release Err key is the project's verdict on the release, as a rejected one is: a retry changes nothing.
118
+ static #refusal(error: unknown, who: string) {
119
+ const refusal = CloudApi.refusal(error, who);
120
+ const verdict =
121
+ error instanceof CloudHttpError && error.status === 400 && !!error.errKey?.startsWith("release.error.");
122
+ return verdict && refusal instanceof Error ? new ExitCodeError(refusal.message, 2) : refusal;
123
+ }
124
+
116
125
  //? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
117
126
  async registerBinary(
118
127
  app: App,
@@ -88,6 +88,29 @@ const read = (
88
88
  const entriesOf = (archive: string) =>
89
89
  Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
90
90
 
91
+ //? Every header, a pax one too: a file's name is the `path` of the pax header before it, else its own name field.
92
+ const headersOf = (bytes: Uint8Array) => {
93
+ const headers: { type: string; name: string; prefix: string; paxKeys: string[] }[] = [];
94
+ let paxPath: string | null = null;
95
+ for (let at = 0; at + 512 <= bytes.length; ) {
96
+ const block = Buffer.from(bytes.subarray(at, at + 512));
97
+ if (block.every((byte) => byte === 0)) break;
98
+ const field = (from: number, width: number) => block.toString("latin1", from, from + width).replace(/\0.*$/s, "");
99
+ const type = field(156, 1);
100
+ const size = Number.parseInt(field(124, 12), 8);
101
+ const records = Buffer.from(bytes.subarray(at + 512, at + 512 + size))
102
+ .toString("utf8")
103
+ .split("\n")
104
+ .filter(Boolean);
105
+ at += 512 + Math.ceil(size / 512) * 512;
106
+ const pax = type === "x" ? records.map((record) => record.slice(record.indexOf(" ") + 1)) : [];
107
+ const paxKeys = pax.map((record) => record.slice(0, record.indexOf("=")));
108
+ headers.push({ type, name: (type === "x" ? null : paxPath) ?? field(0, 100), prefix: field(345, 155), paxKeys });
109
+ paxPath = type === "x" ? (pax.find((record) => record.startsWith("path="))?.slice(5) ?? null) : null;
110
+ }
111
+ return headers;
112
+ };
113
+
91
114
  const extract = async (archive: string) => {
92
115
  const out = await makeDir();
93
116
  expect(Bun.spawnSync(["tar", "-xf", archive, "-C", out]).exitCode).toBe(0);
@@ -117,6 +140,24 @@ describe("DesktopRelease", () => {
117
140
  ]);
118
141
  });
119
142
 
143
+ test("keeps the one tar shape Akan Cloud accepts, a pax name of a long patch included", async () => {
144
+ const published = await publish();
145
+ const release = await read(published);
146
+
147
+ const headers = await release.withArchive(release.record(null), async (archive) =>
148
+ headersOf(await readFile(archive)),
149
+ );
150
+
151
+ expect(new Set(headers.map(({ type }) => type))).toEqual(new Set(["0", "x"]));
152
+ expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
153
+ expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
154
+ const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
155
+ expect(files.slice(0, 3)).toEqual(["./release.json", "./main.json", "./main.json.sig"]);
156
+ expect(files.slice(3).every((name) => name.startsWith("./app/"))).toBe(true);
157
+ expect(files.slice(3)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
158
+ expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
159
+ });
160
+
120
161
  test("the manifest and its signature go in byte for byte, beside a release.json of the CLI and the build time", async () => {
121
162
  const published = await publish();
122
163
  const release = await read(published);
@@ -129,8 +129,9 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
129
129
  return { schema: 1, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
130
130
  }
131
131
 
132
- //* Uncompressed, regular files only under `./` names, no directory entries; what the manifest names is checked
133
- //* against it while it is copied, so the archive holds the bytes the manifest signs for.
132
+ //* 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 manifest pair before
134
+ //* any `app/` entry. What the manifest names is checked against it as it is copied.
134
135
  async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
135
136
  const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
136
137
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.7",
3
+ "version": "3.1.1-rc.8",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.7",
51
+ "akanjs": "3.1.1-rc.8",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",