@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3
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 +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +69 -0
- package/executors.ts +30 -27
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/typecheck/projectTypecheck.ts +6 -5
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { App } from "../commandDecorators";
|
|
3
|
+
import { WorkspaceExecutor } from "../executors";
|
|
4
|
+
import { CsrArtifactBuilder } from "../frontendBuild/csrArtifactBuilder";
|
|
5
|
+
|
|
6
|
+
export type PublicEnv = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
//* The `AKAN_PUBLIC_*` a native web build inlines: an update has to repeat them, because the API host and the env an
|
|
9
|
+
//* installed binary talks to live there and nowhere else. Keys akan.config.ts derives are left out, as Akan Cloud does.
|
|
10
|
+
export class NativePublicEnv {
|
|
11
|
+
static readonly derivedKeys = [
|
|
12
|
+
"AKAN_PUBLIC_APP_NAME",
|
|
13
|
+
"AKAN_PUBLIC_BASE_PATHS",
|
|
14
|
+
"AKAN_PUBLIC_API_PREFIX",
|
|
15
|
+
"AKAN_PUBLIC_WS_PREFIX",
|
|
16
|
+
"AKAN_PUBLIC_RENDER_ENV",
|
|
17
|
+
"AKAN_PUBLIC_DEFAULT_LOCALE",
|
|
18
|
+
"AKAN_PUBLIC_LOCALES",
|
|
19
|
+
];
|
|
20
|
+
//? Akan Cloud's own limits on a recorded env: a build it would refuse fails here, before anything is signed.
|
|
21
|
+
static readonly #keyPattern = /^AKAN_PUBLIC_[A-Z0-9_]{1,128}$/;
|
|
22
|
+
static readonly #secretWords = ["SECRET", "TOKEN", "PASSWORD", "PRIVATE", "CREDENTIAL"];
|
|
23
|
+
static readonly #maxKeys = 256;
|
|
24
|
+
static readonly #maxBytes = 64 * 1024;
|
|
25
|
+
|
|
26
|
+
static of(env: Record<string, unknown>): PublicEnv {
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.keys(env)
|
|
29
|
+
.filter((key) => key.startsWith("AKAN_PUBLIC_") && !NativePublicEnv.derivedKeys.includes(key))
|
|
30
|
+
.sort()
|
|
31
|
+
.map((key) => [key, String(env[key])]),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static hash(publicEnv: PublicEnv) {
|
|
36
|
+
return createHash("sha256")
|
|
37
|
+
.update(JSON.stringify(NativePublicEnv.of(publicEnv)))
|
|
38
|
+
.digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static problems(publicEnv: Record<string, unknown>) {
|
|
42
|
+
const keys = Object.keys(publicEnv);
|
|
43
|
+
const problems = keys.flatMap((key) => {
|
|
44
|
+
const value = publicEnv[key];
|
|
45
|
+
if (!NativePublicEnv.#keyPattern.test(key)) return [`${key} is not an AKAN_PUBLIC_[A-Z0-9_] name`];
|
|
46
|
+
const word = NativePublicEnv.#secretWords.find((secret) => key.includes(secret));
|
|
47
|
+
if (word) return [`${key} names a ${word.toLowerCase()}, which a page bundle must not carry`];
|
|
48
|
+
if (typeof value !== "string") return [`${key} is not a string`];
|
|
49
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is refused
|
|
50
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) return [`${key} holds a control character`];
|
|
51
|
+
return [];
|
|
52
|
+
});
|
|
53
|
+
if (keys.length > NativePublicEnv.#maxKeys)
|
|
54
|
+
problems.push(`${keys.length} keys, more than ${NativePublicEnv.#maxKeys}`);
|
|
55
|
+
const bytes = Buffer.byteLength(JSON.stringify(publicEnv));
|
|
56
|
+
if (bytes > NativePublicEnv.#maxBytes) problems.push(`${bytes} bytes, more than ${NativePublicEnv.#maxBytes}`);
|
|
57
|
+
return problems;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The keys whose values differ, or that only one side has. */
|
|
61
|
+
static diff(a: PublicEnv, b: PublicEnv) {
|
|
62
|
+
return [...new Set([...Object.keys(a), ...Object.keys(b)])]
|
|
63
|
+
.filter((key) => a[key] !== b[key])
|
|
64
|
+
.sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the app's last `akan build` inlined, or null when it built no CSR bundle. */
|
|
68
|
+
static async built(app: App): Promise<PublicEnv | null> {
|
|
69
|
+
const file = Bun.file(CsrArtifactBuilder.publicEnvPath(app));
|
|
70
|
+
if (!(await file.exists())) return null;
|
|
71
|
+
return NativePublicEnv.of((await file.json()) as Record<string, unknown>);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A bundle.json's record, or null when it has none (a build from before the record, or no file at all). */
|
|
75
|
+
static async recordedIn(bundleFile: string): Promise<PublicEnv | null> {
|
|
76
|
+
const file = Bun.file(bundleFile);
|
|
77
|
+
if (!(await file.exists())) return null;
|
|
78
|
+
const bundle = (await file.json()) as { web?: { publicEnv?: PublicEnv } };
|
|
79
|
+
return bundle.web?.publicEnv ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `--public-env`: a JSON map of the values, or the bundle.json of the store build that recorded them. */
|
|
83
|
+
static async load(file: string): Promise<PublicEnv> {
|
|
84
|
+
const source = Bun.file(file);
|
|
85
|
+
if (!(await source.exists())) throw new Error(`--public-env ${file} does not exist.`);
|
|
86
|
+
const json = (await source.json().catch(() => null)) as Record<string, unknown> | null;
|
|
87
|
+
if (!json || typeof json !== "object" || Array.isArray(json))
|
|
88
|
+
throw new Error(`--public-env ${file} is not a JSON object of AKAN_PUBLIC_* values or a bundle.json.`);
|
|
89
|
+
const isBundle = json.schema === 1 && typeof json.web === "object";
|
|
90
|
+
const map = isBundle ? (json.web as { publicEnv?: Record<string, unknown> }).publicEnv : json;
|
|
91
|
+
if (!map) throw new Error(`--public-env ${file} is a bundle.json from a build that recorded no public env.`);
|
|
92
|
+
const derived = Object.keys(map).filter((key) => NativePublicEnv.derivedKeys.includes(key));
|
|
93
|
+
if (derived.length)
|
|
94
|
+
throw new Error(`--public-env ${file} sets ${derived.join(", ")}, which akan.config.ts decides.`);
|
|
95
|
+
const problems = NativePublicEnv.problems(map);
|
|
96
|
+
if (problems.length) throw new Error(`--public-env ${file} is refused:\n- ${problems.join("\n- ")}`);
|
|
97
|
+
return NativePublicEnv.of(map);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//* Exactly these values and the ones akan.config.ts derives: the shell's and `.env`'s other AKAN_PUBLIC_* are dropped.
|
|
101
|
+
static pin(publicEnv: PublicEnv) {
|
|
102
|
+
for (const key of Object.keys(process.env))
|
|
103
|
+
if (key.startsWith("AKAN_PUBLIC_") && !NativePublicEnv.derivedKeys.includes(key)) delete process.env[key];
|
|
104
|
+
Object.assign(process.env, publicEnv);
|
|
105
|
+
WorkspaceExecutor.publicEnvPin = publicEnv;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Why a pack's bundle cannot ship to the store build `shipped`, as far as the public env goes. */
|
|
109
|
+
static async problemAgainst(shipped: string, bundle: string) {
|
|
110
|
+
const [before, now] = await Promise.all([NativePublicEnv.recordedIn(shipped), NativePublicEnv.recordedIn(bundle)]);
|
|
111
|
+
if (!before) return null;
|
|
112
|
+
const differs = NativePublicEnv.diff(before, now ?? {});
|
|
113
|
+
if (!differs.length) return null;
|
|
114
|
+
return `public env ${differs.join(", ")} differs from the store build's (pass --public-env ${shipped} to repeat it)`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -47,6 +47,85 @@ describe("ServerBundleSecrets", () => {
|
|
|
47
47
|
for (const secret of ["main-mail-pass", "0f9e8d7c", "MIIEvQ"]) expect(message).not.toContain(secret);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("passes a server whose vendor and lib code hold a token endpoint and a default password the env files hold too", async () => {
|
|
51
|
+
const { root, appDir, bundle } = await workspace();
|
|
52
|
+
await write(
|
|
53
|
+
path.join(root, "libs/shared/env/env.server.testing.ts"),
|
|
54
|
+
'export const env = { rootAdminInfo: { accountId: "admin@example.com", password: "admin1234" }, iapVerify: { google: { token_uri: "https://oauth2.googleapis.com/token" } } };\n',
|
|
55
|
+
);
|
|
56
|
+
await write(
|
|
57
|
+
path.join(root, "libs/shared/lib/option.ts"),
|
|
58
|
+
'export const rootAdmin = { accountId: "admin@mydomain.com", password: "admin1234" };\n',
|
|
59
|
+
);
|
|
60
|
+
await write(
|
|
61
|
+
path.join(root, "libs/shared/env/env.server.example.ts"),
|
|
62
|
+
'export const env = { pass: "main-mail-pass" };\n',
|
|
63
|
+
);
|
|
64
|
+
await write(path.join(bundle, "chunk-vendor.js"), 'const tokenUrl = "https://oauth2.googleapis.com/token";\n');
|
|
65
|
+
await write(path.join(bundle, "chunk-shared.js"), 'const fallback = { password: "admin1234" };\n');
|
|
66
|
+
expect(await new ServerBundleSecrets(root, appDir).assertClean(bundle)).toEqual([]);
|
|
67
|
+
|
|
68
|
+
await write(path.join(bundle, "chunk-mail.js"), 'const pass = "main-mail-pass";\n');
|
|
69
|
+
await expect(new ServerBundleSecrets(root, appDir).assertClean(bundle)).rejects.toThrow(
|
|
70
|
+
"chunk-mail.js holds apps/demo/env/env.server.main.ts mailer.auth.pass",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("still refuses a long secret the code holds as well, a webhook and a URL's password", async () => {
|
|
75
|
+
const { root, appDir, bundle } = await workspace();
|
|
76
|
+
const hook = "https://hooks.slack.com/services/T0/B0/AbCdEf0123456789XyZ";
|
|
77
|
+
await write(
|
|
78
|
+
path.join(appDir, "env/env.server.develop.ts"),
|
|
79
|
+
`export const env = { slack: { webhookUrl: "${hook}" }, database: { postgres: { url: "postgres://app:pg-pass-0123@db/app" } } };\n`,
|
|
80
|
+
);
|
|
81
|
+
await write(
|
|
82
|
+
path.join(root, "libs/util/srvkit/fallback.ts"),
|
|
83
|
+
`export const jwt = "lib-jwt-0123456789";\nexport const hook = "${hook}";\n`,
|
|
84
|
+
);
|
|
85
|
+
await write(
|
|
86
|
+
path.join(bundle, "server.js"),
|
|
87
|
+
`const jwt = "lib-jwt-0123456789"; const hook = "${hook}"; const pg = "postgres://app:" + (process.env.PG ?? "pg-pass-0123") + "@db/app";\n`,
|
|
88
|
+
);
|
|
89
|
+
const message = await new ServerBundleSecrets(root, appDir)
|
|
90
|
+
.assertClean(bundle)
|
|
91
|
+
.then(() => "")
|
|
92
|
+
.catch((error: Error) => error.message);
|
|
93
|
+
expect(message).toContain("server.js holds libs/util/env/env.server.testing.ts security.jwtSecret");
|
|
94
|
+
expect(message).toContain("server.js holds apps/demo/env/env.server.develop.ts slack.webhookUrl");
|
|
95
|
+
expect(message).toContain("server.js holds apps/demo/env/env.server.develop.ts database.postgres.url");
|
|
96
|
+
expect(message).toContain(
|
|
97
|
+
"A value a profile build still holds sits in code or a file the server carries: take it out there.",
|
|
98
|
+
);
|
|
99
|
+
for (const secret of ["lib-jwt-0123456789", "hooks.slack.com", "pg-pass-0123"])
|
|
100
|
+
expect(message).not.toContain(secret);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("finds a secret with non-ASCII characters in the bundle Bun printed, and in a file copied as UTF-8", async () => {
|
|
104
|
+
const { root, appDir, bundle } = await workspace();
|
|
105
|
+
const secrets = { pass: "pässwörd-ünïcode-1234", apiKey: `비밀 "it's" ${"😀"}-5678` };
|
|
106
|
+
await write(
|
|
107
|
+
path.join(appDir, "env/env.server.debug.ts"),
|
|
108
|
+
`export const env = { mailer: ${JSON.stringify(secrets)} };\n`,
|
|
109
|
+
);
|
|
110
|
+
const source = path.join(root, "carried.ts");
|
|
111
|
+
await write(source, `export const mailer = ${JSON.stringify(secrets)};\n`);
|
|
112
|
+
const built = await Bun.build({ entrypoints: [source], outdir: bundle, target: "bun", minify: true });
|
|
113
|
+
expect(built.success).toBe(true);
|
|
114
|
+
expect(await Bun.file(path.join(bundle, "carried.js")).text()).not.toContain("ä");
|
|
115
|
+
const message = await new ServerBundleSecrets(root, appDir)
|
|
116
|
+
.assertClean(bundle)
|
|
117
|
+
.then(() => "")
|
|
118
|
+
.catch((error: Error) => error.message);
|
|
119
|
+
expect(message).toContain("carried.js holds apps/demo/env/env.server.debug.ts mailer.pass");
|
|
120
|
+
expect(message).toContain("carried.js holds apps/demo/env/env.server.debug.ts mailer.apiKey");
|
|
121
|
+
|
|
122
|
+
await Bun.write(path.join(bundle, "carried.js"), "\n");
|
|
123
|
+
await Bun.write(path.join(bundle, "settings.json"), JSON.stringify({ pass: secrets.pass }));
|
|
124
|
+
await expect(new ServerBundleSecrets(root, appDir).assertClean(bundle)).rejects.toThrow(
|
|
125
|
+
"settings.json holds apps/demo/env/env.server.debug.ts mailer.pass",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
50
129
|
test("names an env file it could not evaluate instead of passing it silently", async () => {
|
|
51
130
|
const { root, appDir, bundle } = await workspace();
|
|
52
131
|
await write(
|
|
@@ -8,21 +8,20 @@ interface SecretValue {
|
|
|
8
8
|
value: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//* A
|
|
12
|
-
//* workspace's server env files hold is looked for in the server the app carries, whatever put it there.
|
|
11
|
+
//* A release leaves the building, signed or not, and an update goes to a feed anyone reads: before either, every secret
|
|
12
|
+
//* value the workspace's server env files hold is looked for in the server the app carries, whatever put it there.
|
|
13
13
|
export class ServerBundleSecrets {
|
|
14
14
|
static readonly #largestFile = 64 * 1024 * 1024;
|
|
15
|
+
static readonly #printer = new Bun.Transpiler({ loader: "js", target: "bun" });
|
|
16
|
+
static readonly #notSource = new Set(["node_modules", "env", "private", "public", "dist"]);
|
|
15
17
|
|
|
16
18
|
constructor(
|
|
17
19
|
readonly workspaceRoot: string,
|
|
18
20
|
readonly appDir: string,
|
|
19
21
|
) {}
|
|
20
22
|
|
|
21
|
-
/** The app's `env/env.server.<name>.ts` and every lib's, but the type and example files. */
|
|
22
23
|
async envFiles(): Promise<string[]> {
|
|
23
|
-
const
|
|
24
|
-
const libs = await readdir(libsDir).catch(() => [] as string[]);
|
|
25
|
-
const dirs = [path.join(this.appDir, "env"), ...libs.map((lib) => path.join(libsDir, lib, "env"))];
|
|
24
|
+
const dirs = [path.join(this.appDir, "env"), ...(await this.#libDirs()).map((lib) => path.join(lib, "env"))];
|
|
26
25
|
const files = await Promise.all(
|
|
27
26
|
dirs.map(async (dir) =>
|
|
28
27
|
(await readdir(dir).catch(() => [] as string[]))
|
|
@@ -45,27 +44,59 @@ export class ServerBundleSecrets {
|
|
|
45
44
|
unread.push(ServerBundleSecrets.#relative(this.workspaceRoot, file));
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
|
-
|
|
47
|
+
if (!secrets.length) return unread;
|
|
48
|
+
const sought = secrets.map((secret) => ({ secret, needles: ServerBundleSecrets.#needles(secret.value) }));
|
|
49
|
+
const found: { bundled: string; secret: SecretValue }[] = [];
|
|
49
50
|
for (const bundled of await ServerBundleSecrets.#files(bundleDir)) {
|
|
50
51
|
const text = await readFile(bundled, "latin1");
|
|
51
|
-
for (const secret of
|
|
52
|
-
if (
|
|
53
|
-
found.push(
|
|
54
|
-
`${ServerBundleSecrets.#relative(bundleDir, bundled)} holds ${ServerBundleSecrets.#relative(this.workspaceRoot, secret.file)} ${secret.path}`,
|
|
55
|
-
);
|
|
52
|
+
for (const { secret, needles } of sought)
|
|
53
|
+
if (needles.some((needle) => text.includes(needle))) found.push({ bundled, secret });
|
|
56
54
|
}
|
|
57
|
-
|
|
55
|
+
const coded = found.length ? await this.#coded(found.map(({ secret }) => secret.value)) : new Set<string>();
|
|
56
|
+
const leaks = found
|
|
57
|
+
.filter(({ secret }) => !coded.has(secret.value))
|
|
58
|
+
.map(
|
|
59
|
+
({ bundled, secret }) =>
|
|
60
|
+
`${ServerBundleSecrets.#relative(bundleDir, bundled)} holds ${ServerBundleSecrets.#relative(this.workspaceRoot, secret.file)} ${secret.path}`,
|
|
61
|
+
);
|
|
62
|
+
if (leaks.length)
|
|
58
63
|
throw new Error(
|
|
59
|
-
`The server this app carries holds secret values
|
|
64
|
+
`The server this app carries holds secret values of the workspace's env files, so the release stops here:\n ${[...new Set(leaks)].join("\n ")}\nBuild its server with a profile (native.desktop.server.profile), whose bundle leaves the env files' secrets out. A value a profile build still holds sits in code or a file the server carries: take it out there.`,
|
|
60
65
|
);
|
|
61
66
|
return unread;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
//? A
|
|
69
|
+
//? A short or plain value the app's or a lib's own code holds as well (a default password) is that code's, which
|
|
70
|
+
//? the bundle carries with or without the env file; a long mixed one is a secret copied into the code.
|
|
71
|
+
async #coded(values: string[]): Promise<Set<string>> {
|
|
72
|
+
const plain = [...new Set(values)].filter((value) => !ServerEnvProfile.distinctive(value));
|
|
73
|
+
const coded = new Set<string>();
|
|
74
|
+
if (!plain.length) return coded;
|
|
75
|
+
for (const dir of [this.appDir, ...(await this.#libDirs())])
|
|
76
|
+
for (const file of await ServerBundleSecrets.#sources(dir)) {
|
|
77
|
+
const text = await readFile(file, "utf8");
|
|
78
|
+
for (const value of plain) if (text.includes(value)) coded.add(value);
|
|
79
|
+
}
|
|
80
|
+
return coded;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async #libDirs() {
|
|
84
|
+
const libsDir = path.join(this.workspaceRoot, "libs");
|
|
85
|
+
return (await readdir(libsDir).catch(() => [] as string[])).map((lib) => path.join(libsDir, lib));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//? A multi-line key sits in a bundle as a JS string, escaped; one long line of it is enough to recognise. Bun prints
|
|
89
|
+
//? a string's non-ASCII characters as `\xNN` / `\uNNNN`, and the bundle is read byte for byte, so as UTF-8.
|
|
65
90
|
static #needles(value: string): string[] {
|
|
66
91
|
const escaped = JSON.stringify(value).slice(1, -1);
|
|
67
92
|
const line = value.split(/\r?\n/).find((part) => part.length >= 32);
|
|
68
|
-
|
|
93
|
+
const forms = [value, escaped, ServerBundleSecrets.#printed(value), ...(line ? [line] : [])];
|
|
94
|
+
return [...new Set(forms.map((form) => Buffer.from(form, "utf8").toString("latin1")))];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static #printed(value: string) {
|
|
98
|
+
const code = ServerBundleSecrets.#printer.transformSync(`export default ${JSON.stringify(value)};`);
|
|
99
|
+
return /^export default (["'`])([\s\S]*)\1;?\s*$/.exec(code)?.[2] ?? value;
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
static #relative(from: string, to: string) {
|
|
@@ -82,4 +113,17 @@ export class ServerBundleSecrets {
|
|
|
82
113
|
}
|
|
83
114
|
return files;
|
|
84
115
|
}
|
|
116
|
+
|
|
117
|
+
static async #sources(dir: string): Promise<string[]> {
|
|
118
|
+
const files: string[] = [];
|
|
119
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
120
|
+
if (entry.name.startsWith(".")) continue;
|
|
121
|
+
const file = path.join(dir, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (!ServerBundleSecrets.#notSource.has(entry.name)) files.push(...(await ServerBundleSecrets.#sources(file)));
|
|
124
|
+
} else if (/\.(?:[cm]?[jt]sx?|json)$/.test(entry.name) && !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name))
|
|
125
|
+
files.push(file);
|
|
126
|
+
}
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
85
129
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
4
|
+
import { UpdatePackFolder } from "./updatePackFolder";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-update-pack-");
|
|
7
|
+
|
|
8
|
+
describe("a pack read back from its folder", () => {
|
|
9
|
+
test("takes the folder as the pack's place, and refuses what is not a takeable pack", async () => {
|
|
10
|
+
const root = await makeTempRoot();
|
|
11
|
+
const pack = {
|
|
12
|
+
schema: 1,
|
|
13
|
+
app: "lu",
|
|
14
|
+
target: "default",
|
|
15
|
+
platform: "android",
|
|
16
|
+
env: "main",
|
|
17
|
+
channel: "main",
|
|
18
|
+
appId: "com.lu",
|
|
19
|
+
appVersion: "1.0.0",
|
|
20
|
+
nativeApi: "0123456789abcdef",
|
|
21
|
+
webHash: "f".repeat(64),
|
|
22
|
+
files: 1,
|
|
23
|
+
size: 13,
|
|
24
|
+
out: "/elsewhere/updates/android",
|
|
25
|
+
};
|
|
26
|
+
const moved = path.join(root, "moved");
|
|
27
|
+
await writeText(path.join(moved, "pack.json"), JSON.stringify(pack));
|
|
28
|
+
expect(await UpdatePackFolder.read(moved)).toEqual({ ...pack, out: moved } as never);
|
|
29
|
+
|
|
30
|
+
await expect(UpdatePackFolder.read(path.join(root, "empty"))).rejects.toThrow("holds no pack.json");
|
|
31
|
+
const later = path.join(root, "later");
|
|
32
|
+
await writeText(path.join(later, "pack.json"), JSON.stringify({ ...pack, schema: 2 }));
|
|
33
|
+
await expect(UpdatePackFolder.read(later)).rejects.toThrow("schema 2");
|
|
34
|
+
const refused = path.join(root, "refused");
|
|
35
|
+
const compat = { against: "store/bundle.json", compatible: false, problems: ["deepLinks differ"] };
|
|
36
|
+
await writeText(path.join(refused, "pack.json"), JSON.stringify({ ...pack, compat }));
|
|
37
|
+
expect((await UpdatePackFolder.read(refused)).compat).toEqual(compat);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("is written without the folder it was packed to, which holds this machine's paths", async () => {
|
|
41
|
+
const out = path.join(await makeTempRoot(), "updates", "android");
|
|
42
|
+
const pack = { schema: 1, app: "lu", target: "default", platform: "android", files: 1, out } as const;
|
|
43
|
+
|
|
44
|
+
await UpdatePackFolder.write(pack as never);
|
|
45
|
+
|
|
46
|
+
const written = await Bun.file(path.join(out, "pack.json")).text();
|
|
47
|
+
expect(written).not.toContain(out);
|
|
48
|
+
expect(JSON.parse(written)).toEqual({ schema: 1, app: "lu", target: "default", platform: "android", files: 1 });
|
|
49
|
+
expect((await UpdatePackFolder.read(out)).out).toBe(out);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("a pack's verdict on the store build it was compared with", () => {
|
|
54
|
+
test("is none without a comparison or for one it runs in, and exit 2 with what differs otherwise", () => {
|
|
55
|
+
const out = "/work/updates/android";
|
|
56
|
+
expect(UpdatePackFolder.storeBuildVerdict({ out })).toBeNull();
|
|
57
|
+
const against = "store/bundle.json";
|
|
58
|
+
expect(UpdatePackFolder.storeBuildVerdict({ out, compat: { against, compatible: true, problems: [] } })).toBeNull();
|
|
59
|
+
|
|
60
|
+
const verdict = UpdatePackFolder.storeBuildVerdict(
|
|
61
|
+
{ out, compat: { against, compatible: false, problems: ["deepLinks differ", "camera added"] } },
|
|
62
|
+
"Run `akan release-android lu`, then register that build.",
|
|
63
|
+
);
|
|
64
|
+
expect(verdict?.exitCode).toBe(2);
|
|
65
|
+
expect(verdict?.message).toBe(
|
|
66
|
+
`The pack in ${out} needs a new store build; it differs from the one ${against} came from:\n- deepLinks differ\n- camera added\nRun \`akan release-android lu\`, then register that build.`,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("a pack's archive", () => {
|
|
72
|
+
test("holds the files its template lists and the records there, never another pack's files", async () => {
|
|
73
|
+
const out = path.join(await makeTempRoot(), "updates", "android");
|
|
74
|
+
const listed = "a".repeat(64);
|
|
75
|
+
await writeText(path.join(out, "manifest.template.json"), JSON.stringify({ files: [{ sha256: listed }] }));
|
|
76
|
+
await writeText(path.join(out, "bundle.json"), "{}");
|
|
77
|
+
await writeText(path.join(out, "pack.json"), "{}");
|
|
78
|
+
await writeText(path.join(out, "files", listed), "listed");
|
|
79
|
+
await writeText(path.join(out, "files", "b".repeat(64)), "an earlier pack's");
|
|
80
|
+
|
|
81
|
+
const entries = await UpdatePackFolder.withArchive({ out } as never, async (archive) =>
|
|
82
|
+
Bun.spawnSync(["tar", "-tzf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean).sort(),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(entries).toEqual(["./bundle.json", `./files/${listed}`, "./manifest.template.json", "./pack.json"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("refuses a template that names a file by anything but its sha256", async () => {
|
|
89
|
+
const out = path.join(await makeTempRoot(), "pack");
|
|
90
|
+
await writeText(path.join(out, "manifest.template.json"), JSON.stringify({ files: [{ sha256: "../pack.json" }] }));
|
|
91
|
+
await expect(UpdatePackFolder.withArchive({ out } as never, async () => null)).rejects.toThrow(
|
|
92
|
+
"lists a file whose sha256 is not 64 hex digits",
|
|
93
|
+
);
|
|
94
|
+
await expect(
|
|
95
|
+
UpdatePackFolder.withArchive({ out: path.join(out, "none") } as never, async () => null),
|
|
96
|
+
).rejects.toThrow("holds no manifest.template.json");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { BundleInfo, UpdateManifest } from "@akanjs/native/api";
|
|
5
|
+
import type { NativeEnv } from "akanjs";
|
|
6
|
+
import { ExitCodeError } from "../commandDecorators/exitCodeError";
|
|
7
|
+
import type { MobilePlatform } from "./mobileTarget";
|
|
8
|
+
|
|
9
|
+
export interface NativeUpdatePackCompat {
|
|
10
|
+
/** The store build's bundle.json the pack was compared with. */
|
|
11
|
+
against: string;
|
|
12
|
+
compatible: boolean;
|
|
13
|
+
problems: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `<out>/pack.json`: what a signer needs of a pack, read instead of the log. */
|
|
17
|
+
export interface NativeUpdatePack {
|
|
18
|
+
schema: 1;
|
|
19
|
+
app: string;
|
|
20
|
+
target: string;
|
|
21
|
+
platform: MobilePlatform;
|
|
22
|
+
env: NativeEnv;
|
|
23
|
+
channel: string;
|
|
24
|
+
appId: string;
|
|
25
|
+
appVersion: string;
|
|
26
|
+
nativeApi: string;
|
|
27
|
+
/** bundle.json `web.hash`: the web files as served, the part of a bundle id that names its content. */
|
|
28
|
+
webHash: string;
|
|
29
|
+
/** bundle.json `web.publicEnvHash`: the AKAN_PUBLIC_* the page inlined, which the store build must share. */
|
|
30
|
+
publicEnvHash?: string;
|
|
31
|
+
files: number;
|
|
32
|
+
size: number;
|
|
33
|
+
/** The folder it was read from or packed to; pack.json leaves it out. */
|
|
34
|
+
out: string;
|
|
35
|
+
compat?: NativeUpdatePackCompat;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//* The folder `akan pack-update` writes for a signer or Akan Cloud: the native build API writes `files/<sha256>`,
|
|
39
|
+
//* bundle.json and manifest.template.json, and akan writes pack.json and compat.json beside them.
|
|
40
|
+
export class UpdatePackFolder {
|
|
41
|
+
static readonly packFile = "pack.json";
|
|
42
|
+
static readonly compatFile = "compat.json";
|
|
43
|
+
static readonly bundleFile = "bundle.json";
|
|
44
|
+
static readonly templateFile = "manifest.template.json";
|
|
45
|
+
static readonly filesDir = "files";
|
|
46
|
+
static readonly #records = [
|
|
47
|
+
UpdatePackFolder.templateFile,
|
|
48
|
+
UpdatePackFolder.bundleFile,
|
|
49
|
+
UpdatePackFolder.packFile,
|
|
50
|
+
UpdatePackFolder.compatFile,
|
|
51
|
+
];
|
|
52
|
+
static readonly #sha256 = /^[0-9a-f]{64}$/;
|
|
53
|
+
|
|
54
|
+
/** akan's own records: a pack that fails must not leave the previous pack's behind for whoever reads the exit code. */
|
|
55
|
+
static async clear(dir: string) {
|
|
56
|
+
await Promise.all(
|
|
57
|
+
[UpdatePackFolder.packFile, UpdatePackFolder.compatFile].map((name) => rm(path.join(dir, name), { force: true })),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A pack read back from its folder wherever it was moved. */
|
|
62
|
+
static async read(dir: string): Promise<NativeUpdatePack> {
|
|
63
|
+
const file = Bun.file(path.join(dir, UpdatePackFolder.packFile));
|
|
64
|
+
if (!(await file.exists()))
|
|
65
|
+
throw new Error(`${dir} holds no ${UpdatePackFolder.packFile}: give the folder \`akan pack-update\` wrote.`);
|
|
66
|
+
const pack = (await file.json()) as NativeUpdatePack;
|
|
67
|
+
if (pack.schema !== 1)
|
|
68
|
+
throw new Error(`${dir}/${UpdatePackFolder.packFile} is schema ${pack.schema}, and this akan reads schema 1.`);
|
|
69
|
+
return { ...pack, out: path.resolve(dir) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//? Without `out`: the record travels in the pack's archive, and the folder is wherever it is read from.
|
|
73
|
+
static async write({ out, ...record }: NativeUpdatePack) {
|
|
74
|
+
await Bun.write(path.join(out, UpdatePackFolder.packFile), `${JSON.stringify(record, null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static async writeCompat(pack: NativeUpdatePack, compat: NativeUpdatePackCompat) {
|
|
78
|
+
pack.compat = compat;
|
|
79
|
+
await Bun.write(path.join(pack.out, UpdatePackFolder.compatFile), `${JSON.stringify(compat, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bundle.json a store build and a pack both carry. */
|
|
83
|
+
static async readBundle(file: string): Promise<BundleInfo> {
|
|
84
|
+
return (await Bun.file(file).json()) as BundleInfo;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//* Exit 2, not a thrown 1: a CI tells "this needs a new store build" apart from a pack that broke.
|
|
88
|
+
static storeBuildVerdict({ out, compat }: Pick<NativeUpdatePack, "out" | "compat">, then?: string) {
|
|
89
|
+
if (!compat || compat.compatible) return null;
|
|
90
|
+
return new ExitCodeError(
|
|
91
|
+
`The pack in ${out} needs a new store build; it differs from the one ${compat.against} came from:\n- ${compat.problems.join("\n- ")}${then ? `\n${then}` : ""}`,
|
|
92
|
+
2,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The pack as the tgz Akan Cloud takes: the files its manifest template lists, and the records beside them. */
|
|
97
|
+
static async withArchive<T>(pack: NativeUpdatePack, use: (archive: string) => Promise<T>): Promise<T> {
|
|
98
|
+
const { templateFile, filesDir } = UpdatePackFolder;
|
|
99
|
+
const template = Bun.file(path.join(pack.out, templateFile));
|
|
100
|
+
if (!(await template.exists()))
|
|
101
|
+
throw new Error(`${pack.out} holds no ${templateFile}: give the folder \`akan pack-update\` wrote.`);
|
|
102
|
+
const { files = [] } = (await template.json()) as Partial<UpdateManifest>;
|
|
103
|
+
const hashes = [...new Set(files.map((file) => file.sha256 as unknown))];
|
|
104
|
+
if (!hashes.every((hash): hash is string => typeof hash === "string" && UpdatePackFolder.#sha256.test(hash)))
|
|
105
|
+
throw new Error(`${pack.out}/${templateFile} lists a file whose sha256 is not 64 hex digits.`);
|
|
106
|
+
const present = await Promise.all(
|
|
107
|
+
UpdatePackFolder.#records.map((name) => Bun.file(path.join(pack.out, name)).exists()),
|
|
108
|
+
);
|
|
109
|
+
const entries = [
|
|
110
|
+
...UpdatePackFolder.#records.filter((_, idx) => present[idx]),
|
|
111
|
+
...hashes.map((hash) => `${filesDir}/${hash}`),
|
|
112
|
+
];
|
|
113
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-pack-"));
|
|
114
|
+
try {
|
|
115
|
+
//? files/ keeps every earlier pack's files (content-addressed), so the folder itself is not the pack; the names
|
|
116
|
+
//? go on stdin, not argv, because a Windows command line holds 32 KiB.
|
|
117
|
+
// A relative archive name, so GNU tar never reads a Windows drive letter (`C:\…`) as a remote `host:file` spec.
|
|
118
|
+
const tar = Bun.spawn(["tar", "-czf", "pack.tgz", "-C", pack.out, "-T", "-"], {
|
|
119
|
+
cwd: dir,
|
|
120
|
+
stdin: new Blob([entries.map((entry) => `./${entry}\n`).join("")]),
|
|
121
|
+
stdout: "ignore",
|
|
122
|
+
stderr: "pipe",
|
|
123
|
+
});
|
|
124
|
+
const [code, stderr] = await Promise.all([tar.exited, new Response(tar.stderr).text()]);
|
|
125
|
+
if (code !== 0) throw new Error(`tar of ${pack.out} failed: ${stderr.trim()}`);
|
|
126
|
+
return await use(path.join(dir, "pack.tgz"));
|
|
127
|
+
} finally {
|
|
128
|
+
await rm(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|