@akanjs/devkit 3.0.2 → 3.1.0-rc.1
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/CHANGELOG.md +46 -7
- package/akanApp/akanApp.host.ts +3 -2
- package/akanConfig/akanConfig.test.ts +76 -6
- package/akanConfig/akanConfig.ts +111 -13
- package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
- package/akanConfig/akanWorkspaceConfig.ts +63 -0
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +23 -1
- package/akanContext.ts +1 -1
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +73 -84
- package/artifact/implicitRootLayout.test.ts +6 -2
- package/artifact/implicitRootLayout.ts +12 -7
- package/biome.base.json +7 -2
- package/cloud/cloudApi.ts +87 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +250 -0
- package/cloud/updatePackUpload.ts +132 -0
- package/commandDecorators/command.ts +8 -2
- package/commandDecorators/targetMeta.ts +1 -0
- package/executors.test.ts +21 -0
- package/executors.ts +9 -2
- package/frontendBuild/csrArtifactBuilder.ts +10 -1
- package/frontendBuild/cssCandidateCache.test.ts +10 -0
- package/frontendBuild/cssCandidateCache.ts +4 -2
- package/frontendBuild/pagesBundleBuilder.ts +5 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
- package/incrementalBuilder/buildBatch.proc.ts +14 -2
- package/incrementalBuilder/buildBatchProtocol.ts +3 -2
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
- package/lint/no-import-external-library-in-test.grit +24 -0
- package/mobile/desktopServerStage.test.ts +6 -0
- package/mobile/desktopServerStage.ts +25 -3
- package/mobile/index.ts +1 -0
- package/mobile/nativeApp.test.ts +34 -0
- package/mobile/nativeApp.ts +106 -34
- package/mobile/nativeConfig.test.ts +59 -1
- package/mobile/nativeConfig.ts +13 -3
- package/mobile/serverBundleSecrets.test.ts +61 -0
- package/mobile/serverBundleSecrets.ts +85 -0
- package/mobile/updateUploader.test.ts +49 -0
- package/mobile/updateUploader.ts +71 -0
- package/package.json +3 -2
- package/pipeline/PipelineCompiler.test.ts +631 -0
- package/pipeline/PipelineCompiler.ts +646 -0
- package/pipeline/PipelineDefinition.test.ts +89 -0
- package/pipeline/PipelineDefinition.ts +117 -0
- package/pipeline/PipelineDryRun.test.ts +93 -0
- package/pipeline/PipelineDryRun.ts +89 -0
- package/pipeline/PipelineInputReader.ts +739 -0
- package/pipeline/index.ts +4 -0
- package/qualityScanner.test.ts +9 -3
- package/qualityScanner.ts +5 -2
- package/scanInfo.ts +13 -6
- package/subspace.test.ts +108 -4
- package/subspace.ts +162 -10
- package/subspaceConfig.ts +11 -14
- package/testHelpers.ts +4 -0
- package/transforms/externalizeFrameworkPlugin.ts +1 -7
- package/transforms/serverEnvPlugin.test.ts +93 -2
- package/transforms/serverEnvPlugin.ts +71 -18
- package/transforms/serverEnvProfile.test.ts +82 -0
- package/transforms/serverEnvProfile.ts +114 -0
- package/typecheck/projectTypecheck.test.ts +32 -0
- package/typecheck/projectTypecheck.ts +71 -0
- package/workspaceLayout.test.ts +26 -3
- package/workspaceLayout.ts +15 -4
|
@@ -67,6 +67,30 @@ describe("NativeConfig.build", () => {
|
|
|
67
67
|
expect(build(undefined)).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test("paints the window before its page with backgroundColor, and names the Windows publisher", () => {
|
|
71
|
+
const build = (target: Partial<AkanNativeTarget>) =>
|
|
72
|
+
NativeConfig.build({
|
|
73
|
+
appPath: "/repo/apps/minimal",
|
|
74
|
+
target: { ...minimalTarget, ...target },
|
|
75
|
+
webDir: "/repo/dist/native/minimal/default/web",
|
|
76
|
+
contributions: [],
|
|
77
|
+
locales: ["en"],
|
|
78
|
+
platform: "windows",
|
|
79
|
+
}).config;
|
|
80
|
+
|
|
81
|
+
expect(build({ backgroundColor: "#000000" }).shell).toEqual({ backgroundColor: "#000000" });
|
|
82
|
+
expect(build({ backgroundColor: { light: "#fafafa", dark: "#111111" } }).shell).toEqual({
|
|
83
|
+
backgroundColor: "#fafafa",
|
|
84
|
+
backgroundColorDark: "#111111",
|
|
85
|
+
});
|
|
86
|
+
expect(build({}).shell).toBeUndefined();
|
|
87
|
+
expect(build({ backgroundColor: "#000000", devtools: true }).shell).toEqual({
|
|
88
|
+
backgroundColor: "#000000",
|
|
89
|
+
devtools: true,
|
|
90
|
+
});
|
|
91
|
+
expect(build({ desktop: { publisher: "Leading Flight" } }).desktop?.publisher).toBe("Leading Flight");
|
|
92
|
+
});
|
|
93
|
+
|
|
70
94
|
test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
|
|
71
95
|
const { config, warnings } = NativeConfig.build({
|
|
72
96
|
appPath: "/repo/apps/minimal",
|
|
@@ -208,10 +232,38 @@ describe("NativeConfig.build", () => {
|
|
|
208
232
|
readyTimeout: 15_000,
|
|
209
233
|
});
|
|
210
234
|
expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
|
|
235
|
+
const spare = Buffer.alloc(32, 8).toString("base64");
|
|
236
|
+
expect(build({ url, publicKeys: [updatesKey, spare] }, "main").updates).toEqual({
|
|
237
|
+
url,
|
|
238
|
+
publicKeys: [updatesKey, spare],
|
|
239
|
+
channel: "main",
|
|
240
|
+
});
|
|
241
|
+
const report = { url: "https://cloud.example.com/api/ota/report/abcdefghijklmnop" };
|
|
242
|
+
expect(build({ url, publicKey: updatesKey, report }, "main").updates).toEqual({
|
|
243
|
+
url,
|
|
244
|
+
publicKey: updatesKey,
|
|
245
|
+
channel: "main",
|
|
246
|
+
report,
|
|
247
|
+
});
|
|
211
248
|
expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
|
|
212
249
|
expect(build(undefined, "main").plugins).not.toContain("updates");
|
|
213
250
|
});
|
|
214
251
|
|
|
252
|
+
test("devtools opens the web inspector of a release build only when a target asks for it", () => {
|
|
253
|
+
const build = (devtools?: boolean) =>
|
|
254
|
+
NativeConfig.build({
|
|
255
|
+
appPath: "/repo/apps/portal",
|
|
256
|
+
target: { ...adminTarget, ...(devtools === undefined ? {} : { devtools }) },
|
|
257
|
+
webDir: "/web",
|
|
258
|
+
contributions: [],
|
|
259
|
+
locales: ["en"],
|
|
260
|
+
platform: "android",
|
|
261
|
+
}).config.shell;
|
|
262
|
+
expect(build(true)).toEqual({ devtools: true });
|
|
263
|
+
expect(build(false)).toBeUndefined();
|
|
264
|
+
expect(build()).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
|
|
215
267
|
test("push, privacy and the icon and splash objects reach the runtime, paths from the app folder", () => {
|
|
216
268
|
const { config } = NativeConfig.build({
|
|
217
269
|
appPath: "/repo/apps/portal",
|
|
@@ -342,7 +394,12 @@ describe("NativeConfig.build", () => {
|
|
|
342
394
|
permissions: [],
|
|
343
395
|
deepLinks: undefined,
|
|
344
396
|
android: { autoplay: true },
|
|
345
|
-
desktop: {
|
|
397
|
+
desktop: {
|
|
398
|
+
recovery: "reload",
|
|
399
|
+
window: { fullscreen: true, skipTaskbar: true },
|
|
400
|
+
screenCapture: "auto",
|
|
401
|
+
webview2: "standalone",
|
|
402
|
+
},
|
|
346
403
|
},
|
|
347
404
|
webDir: path.join(root, "web"),
|
|
348
405
|
contributions: [],
|
|
@@ -355,6 +412,7 @@ describe("NativeConfig.build", () => {
|
|
|
355
412
|
recovery: "reload",
|
|
356
413
|
window: { fullscreen: true, skipTaskbar: true },
|
|
357
414
|
screenCapture: "auto",
|
|
415
|
+
webview2: "standalone",
|
|
358
416
|
bin: root,
|
|
359
417
|
});
|
|
360
418
|
expect(config.android).toEqual({ debugAppIdSuffix: ".debug", autoplay: true });
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -175,11 +175,19 @@ export class NativeConfig {
|
|
|
175
175
|
const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
|
|
176
176
|
recovery: target.desktop?.recovery,
|
|
177
177
|
screenCapture: target.desktop?.screenCapture,
|
|
178
|
+
webview2: target.desktop?.webview2,
|
|
179
|
+
publisher: target.desktop?.publisher,
|
|
178
180
|
window: NativeConfig.#compact({ ...target.desktop?.window }),
|
|
179
181
|
server: desktopServer,
|
|
180
182
|
bin: desktopBin,
|
|
181
183
|
dmg: NativeConfig.#dmg(target.desktop?.dmg, abs),
|
|
182
184
|
});
|
|
185
|
+
const background = target.backgroundColor;
|
|
186
|
+
const shell = NativeConfig.#compact<NonNullable<AkanNativeConfig["shell"]>>({
|
|
187
|
+
backgroundColor: typeof background === "string" ? background : background?.light,
|
|
188
|
+
backgroundColorDark: typeof background === "string" ? undefined : background?.dark,
|
|
189
|
+
devtools: target.devtools ? true : undefined,
|
|
190
|
+
});
|
|
183
191
|
const config: AkanNativeConfig = {
|
|
184
192
|
app: {
|
|
185
193
|
id: resolveAppId(target.appId, platform),
|
|
@@ -223,6 +231,7 @@ export class NativeConfig {
|
|
|
223
231
|
...(desktop ? { desktop } : {}),
|
|
224
232
|
...(target.icon ? { icon: NativeConfig.#icon(target.icon, abs) } : {}),
|
|
225
233
|
...(target.splash ? { splash: NativeConfig.#splash(target.splash, abs) } : {}),
|
|
234
|
+
...(shell ? { shell } : {}),
|
|
226
235
|
};
|
|
227
236
|
return { config, warnings };
|
|
228
237
|
}
|
|
@@ -252,10 +261,11 @@ export class NativeConfig {
|
|
|
252
261
|
};
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
//? akanConfig refuses a target whose merged updates lack url or
|
|
264
|
+
//? akanConfig refuses a target whose merged updates lack url or a key; the type still has them optional.
|
|
256
265
|
static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
257
|
-
const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
|
|
258
|
-
|
|
266
|
+
const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
|
|
267
|
+
const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
|
|
268
|
+
return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}) } : undefined;
|
|
259
269
|
}
|
|
260
270
|
|
|
261
271
|
/** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
4
|
+
import { ServerBundleSecrets } from "./serverBundleSecrets";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-devkit-server-bundle-secrets-");
|
|
7
|
+
|
|
8
|
+
const workspace = async () => {
|
|
9
|
+
const root = await makeTempRoot();
|
|
10
|
+
await write(
|
|
11
|
+
path.join(root, "libs/util/env/env.server.testing.ts"),
|
|
12
|
+
`export const env = { hostname: "lib-host", security: { jwtSecret: "lib-jwt-0123456789" }, iapVerify: { apple: "0f9e8d7c6b5a49382716abcdef012345", google: { type: "service_account", private_key: "-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\\n-----END PRIVATE KEY-----\\n" } } };\n`,
|
|
13
|
+
);
|
|
14
|
+
await write(path.join(root, "libs/util/env/env.server.type.ts"), "export type Env = object;\n");
|
|
15
|
+
await write(
|
|
16
|
+
path.join(root, "apps/demo/env/env.server.main.ts"),
|
|
17
|
+
'export const env = { mailer: { auth: { user: "ops", pass: "main-mail-pass" } } };\n',
|
|
18
|
+
);
|
|
19
|
+
const bundle = path.join(root, "bundle");
|
|
20
|
+
return { root, appDir: path.join(root, "apps/demo"), bundle };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("ServerBundleSecrets", () => {
|
|
24
|
+
test("passes a bundle that holds none of the workspace's secret values, constants of a credential block aside", async () => {
|
|
25
|
+
const { root, appDir, bundle } = await workspace();
|
|
26
|
+
await write(path.join(bundle, "server.js"), 'const t = "service_account"; const host = "lib-host";\n');
|
|
27
|
+
await write(path.join(bundle, "node_modules/leaky/index.js"), 'const k = "lib-jwt-0123456789";\n');
|
|
28
|
+
expect(await new ServerBundleSecrets(root, appDir).assertClean(bundle)).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("refuses one that does, naming the file, the env file and the key path and never the value", async () => {
|
|
32
|
+
const { root, appDir, bundle } = await workspace();
|
|
33
|
+
await write(
|
|
34
|
+
path.join(bundle, "chunk-a.js"),
|
|
35
|
+
'const e = { pass: "main-mail-pass", apple: "0f9e8d7c6b5a49382716abcdef012345" };\n',
|
|
36
|
+
);
|
|
37
|
+
await write(
|
|
38
|
+
path.join(bundle, "chunk-b.js"),
|
|
39
|
+
'const pem = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7";\n',
|
|
40
|
+
);
|
|
41
|
+
const error = await new ServerBundleSecrets(root, appDir).assertClean(bundle).catch((e: Error) => e);
|
|
42
|
+
expect(error).toBeInstanceOf(Error);
|
|
43
|
+
const message = (error as Error).message;
|
|
44
|
+
expect(message).toContain("chunk-a.js holds apps/demo/env/env.server.main.ts mailer.auth.pass");
|
|
45
|
+
expect(message).toContain("chunk-a.js holds libs/util/env/env.server.testing.ts iapVerify.apple");
|
|
46
|
+
expect(message).toContain("chunk-b.js holds libs/util/env/env.server.testing.ts iapVerify.google.private_key");
|
|
47
|
+
for (const secret of ["main-mail-pass", "0f9e8d7c", "MIIEvQ"]) expect(message).not.toContain(secret);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("names an env file it could not evaluate instead of passing it silently", async () => {
|
|
51
|
+
const { root, appDir, bundle } = await workspace();
|
|
52
|
+
await write(
|
|
53
|
+
path.join(appDir, "env/env.server.develop.ts"),
|
|
54
|
+
'import { gone } from "./missing";\nexport const env = gone;\n',
|
|
55
|
+
);
|
|
56
|
+
await write(path.join(bundle, "server.js"), "\n");
|
|
57
|
+
expect(await new ServerBundleSecrets(root, appDir).assertClean(bundle)).toEqual([
|
|
58
|
+
"apps/demo/env/env.server.develop.ts",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ServerEnvProfile } from "../transforms/serverEnvProfile";
|
|
4
|
+
|
|
5
|
+
interface SecretValue {
|
|
6
|
+
file: string;
|
|
7
|
+
path: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
//* A signed app leaves the building, and a notarized one goes to Apple first: before either, every secret value the
|
|
12
|
+
//* workspace's server env files hold is looked for in the server the app carries, whatever put it there.
|
|
13
|
+
export class ServerBundleSecrets {
|
|
14
|
+
static readonly #largestFile = 64 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
readonly workspaceRoot: string,
|
|
18
|
+
readonly appDir: string,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/** The app's `env/env.server.<name>.ts` and every lib's, but the type and example files. */
|
|
22
|
+
async envFiles(): Promise<string[]> {
|
|
23
|
+
const libsDir = path.join(this.workspaceRoot, "libs");
|
|
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"))];
|
|
26
|
+
const files = await Promise.all(
|
|
27
|
+
dirs.map(async (dir) =>
|
|
28
|
+
(await readdir(dir).catch(() => [] as string[]))
|
|
29
|
+
.filter((name) => /^env\.server\.[A-Za-z0-9.-]+\.ts$/.test(name) && !/\.(type|example)\.ts$/.test(name))
|
|
30
|
+
.map((name) => path.join(dir, name)),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
return files.flat();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Refuses, naming files and key paths and never a value, when the bundle holds one; returns the env files it could not read. */
|
|
37
|
+
async assertClean(bundleDir: string): Promise<string[]> {
|
|
38
|
+
const unread: string[] = [];
|
|
39
|
+
const secrets: SecretValue[] = [];
|
|
40
|
+
for (const file of await this.envFiles()) {
|
|
41
|
+
try {
|
|
42
|
+
const value = await ServerEnvProfile.evaluate(file, path.dirname(path.dirname(file)));
|
|
43
|
+
secrets.push(...ServerEnvProfile.secretValues(value).map((secret) => ({ file, ...secret })));
|
|
44
|
+
} catch {
|
|
45
|
+
unread.push(ServerBundleSecrets.#relative(this.workspaceRoot, file));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const found: string[] = [];
|
|
49
|
+
for (const bundled of await ServerBundleSecrets.#files(bundleDir)) {
|
|
50
|
+
const text = await readFile(bundled, "latin1");
|
|
51
|
+
for (const secret of secrets)
|
|
52
|
+
if (ServerBundleSecrets.#needles(secret.value).some((needle) => text.includes(needle)))
|
|
53
|
+
found.push(
|
|
54
|
+
`${ServerBundleSecrets.#relative(bundleDir, bundled)} holds ${ServerBundleSecrets.#relative(this.workspaceRoot, secret.file)} ${secret.path}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (found.length)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`The server this app carries holds secret values from the workspace's env files, so it is not signed or sent anywhere:\n ${[...new Set(found)].join("\n ")}\nBuild its server with a profile (native.desktop.server.profile), whose bundle carries no secret.`,
|
|
60
|
+
);
|
|
61
|
+
return unread;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//? A multi-line key sits in a bundle as a JS string, escaped; one long line of it is enough to recognise.
|
|
65
|
+
static #needles(value: string): string[] {
|
|
66
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
67
|
+
const line = value.split(/\r?\n/).find((part) => part.length >= 32);
|
|
68
|
+
return [...new Set([value, escaped, ...(line ? [line] : [])])];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static #relative(from: string, to: string) {
|
|
72
|
+
return path.relative(from, to).split(path.sep).join("/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static async #files(dir: string): Promise<string[]> {
|
|
76
|
+
const files: string[] = [];
|
|
77
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
78
|
+
const file = path.join(dir, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (entry.name !== "node_modules") files.push(...(await ServerBundleSecrets.#files(file)));
|
|
81
|
+
} else if (entry.isFile() && (await stat(file)).size <= ServerBundleSecrets.#largestFile) files.push(file);
|
|
82
|
+
}
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { type UpdateBucket, UpdateUploader } from "./updateUploader";
|
|
6
|
+
|
|
7
|
+
const fakeBucket = (held: string[] = []) => {
|
|
8
|
+
const writes: { key: string; type?: string }[] = [];
|
|
9
|
+
const bucket: UpdateBucket = {
|
|
10
|
+
exists: async (key) => held.includes(key),
|
|
11
|
+
write: async (key, _data, options) => {
|
|
12
|
+
writes.push({ key, ...(options?.type ? { type: options.type } : {}) });
|
|
13
|
+
return 0;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
return { bucket, writes };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("UpdateUploader", () => {
|
|
20
|
+
test("reads s3://bucket/prefix and refuses anything else", () => {
|
|
21
|
+
expect(UpdateUploader.parseTarget("s3://releases/dcms/kiosk/")).toEqual({
|
|
22
|
+
bucket: "releases",
|
|
23
|
+
prefix: "dcms/kiosk",
|
|
24
|
+
});
|
|
25
|
+
expect(UpdateUploader.parseTarget("s3://releases")).toEqual({ bucket: "releases", prefix: "" });
|
|
26
|
+
expect(() => UpdateUploader.parseTarget("https://releases.example/dcms")).toThrow("s3://<bucket>/<prefix>");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("uploads what the bucket lacks first, then each signature before its manifest", async () => {
|
|
30
|
+
const dir = await mkdtemp(path.join(tmpdir(), "akan-update-upload-"));
|
|
31
|
+
try {
|
|
32
|
+
await mkdir(path.join(dir, "windows-x64", "files"), { recursive: true });
|
|
33
|
+
for (const file of ["windows-x64/files/2000-aa.tar.gz", "windows-x64/files/1000-bb.tar.gz"])
|
|
34
|
+
await writeFile(path.join(dir, file), "tar");
|
|
35
|
+
for (const file of ["windows-x64/main.json", "windows-x64/main.json.sig", "windows-x64/main.json.tmp"])
|
|
36
|
+
await writeFile(path.join(dir, file), "{}");
|
|
37
|
+
const { bucket, writes } = fakeBucket(["dcms/windows-x64/files/1000-bb.tar.gz"]);
|
|
38
|
+
|
|
39
|
+
expect(await new UpdateUploader(bucket, "dcms").upload(dir)).toEqual({ uploaded: 3, skipped: 1 });
|
|
40
|
+
expect(writes).toEqual([
|
|
41
|
+
{ key: "dcms/windows-x64/files/2000-aa.tar.gz" },
|
|
42
|
+
{ key: "dcms/windows-x64/main.json.sig", type: "text/plain" },
|
|
43
|
+
{ key: "dcms/windows-x64/main.json", type: "application/json" },
|
|
44
|
+
]);
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/** What the uploader needs of an S3 client; Bun.S3Client is one. */
|
|
4
|
+
export interface UpdateBucket {
|
|
5
|
+
exists(key: string): Promise<boolean>;
|
|
6
|
+
write(key: string, data: Blob, options?: { type?: string }): Promise<number>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UpdateUploadResult {
|
|
10
|
+
uploaded: number;
|
|
11
|
+
skipped: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//* A manifest names files that must already be there, so they go first and the manifests last, each signature before
|
|
15
|
+
//* its manifest as `publish-update` writes them. A release file never changes once published (its name carries its
|
|
16
|
+
//* hash), so one the bucket holds is skipped; a manifest is always written.
|
|
17
|
+
export class UpdateUploader {
|
|
18
|
+
static readonly #manifest = /\.json(?:\.sig)?$/;
|
|
19
|
+
|
|
20
|
+
/** `s3://bucket/prefix`: the credentials and endpoint come from S3_* (or AWS_*) in the environment. */
|
|
21
|
+
static parseTarget(target: string): { bucket: string; prefix: string } {
|
|
22
|
+
const match = /^s3:\/\/([^/]+)\/?(.*)$/.exec(target.trim());
|
|
23
|
+
if (!match?.[1]) throw new Error(`--upload takes s3://<bucket>/<prefix>, not ${JSON.stringify(target)}.`);
|
|
24
|
+
return { bucket: match[1], prefix: (match[2] ?? "").replace(/\/+$/, "") };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static order(files: string[]): { releases: string[]; manifests: string[] } {
|
|
28
|
+
const manifests = files.filter((file) => UpdateUploader.#manifest.test(file));
|
|
29
|
+
return {
|
|
30
|
+
releases: files.filter((file) => !UpdateUploader.#manifest.test(file)).sort((a, b) => a.localeCompare(b)),
|
|
31
|
+
manifests: manifests.sort(
|
|
32
|
+
(a, b) => Number(a.endsWith(".json")) - Number(b.endsWith(".json")) || a.localeCompare(b),
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
readonly bucket: UpdateBucket,
|
|
39
|
+
readonly prefix: string,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
static forTarget(target: string): UpdateUploader {
|
|
43
|
+
const { bucket, prefix } = UpdateUploader.parseTarget(target);
|
|
44
|
+
return new UpdateUploader(new Bun.S3Client({ bucket }), prefix);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async upload(dir: string): Promise<UpdateUploadResult> {
|
|
48
|
+
const files: string[] = [];
|
|
49
|
+
for await (const file of new Bun.Glob("**/*").scan({ cwd: dir, onlyFiles: true })) {
|
|
50
|
+
if (!file.endsWith(".tmp")) files.push(file.split(path.sep).join("/"));
|
|
51
|
+
}
|
|
52
|
+
const { releases, manifests } = UpdateUploader.order(files);
|
|
53
|
+
const keyOf = (file: string) => (this.prefix ? `${this.prefix}/${file}` : file);
|
|
54
|
+
let uploaded = 0;
|
|
55
|
+
let skipped = 0;
|
|
56
|
+
for (const file of releases) {
|
|
57
|
+
if (await this.bucket.exists(keyOf(file))) {
|
|
58
|
+
skipped++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
await this.bucket.write(keyOf(file), Bun.file(path.join(dir, file)));
|
|
62
|
+
uploaded++;
|
|
63
|
+
}
|
|
64
|
+
for (const file of manifests) {
|
|
65
|
+
const type = file.endsWith(".json") ? "application/json" : "text/plain";
|
|
66
|
+
await this.bucket.write(keyOf(file), Bun.file(path.join(dir, file)), { type });
|
|
67
|
+
uploaded++;
|
|
68
|
+
}
|
|
69
|
+
return { uploaded, skipped };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.1.0-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./frontendBuild": "./frontendBuild/index.ts",
|
|
34
34
|
"./incrementalBuilder": "./incrementalBuilder/index.ts",
|
|
35
35
|
"./mobile": "./mobile/index.ts",
|
|
36
|
+
"./pipeline": "./pipeline/index.ts",
|
|
36
37
|
"./transforms": "./transforms/index.ts",
|
|
37
38
|
"./workflow": "./workflow/index.ts",
|
|
38
39
|
"./*.ts": "./*.ts",
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"@earendil-works/pi-tui": "0.86.1",
|
|
48
49
|
"@inquirer/prompts": "^8.4.3",
|
|
49
50
|
"@tailwindcss/node": "^4.3.0",
|
|
50
|
-
"akanjs": "3.0.
|
|
51
|
+
"akanjs": "3.1.0-rc.1",
|
|
51
52
|
"chalk": "^5.6.2",
|
|
52
53
|
"commander": "^14.0.3",
|
|
53
54
|
"dayjs": "^1.11.20",
|