@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
|
-
|
|
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);
|
package/cloud/cloudHttpError.ts
CHANGED
|
@@ -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)
|
|
41
|
+
return isRecord(parsed) ? parsed : null;
|
|
32
42
|
} catch {
|
|
33
43
|
return null;
|
|
34
44
|
}
|
package/cloud/cloudUpdates.ts
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/mobile/desktopRelease.ts
CHANGED
|
@@ -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
|
-
//*
|
|
133
|
-
//*
|
|
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.
|
|
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.
|
|
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",
|