@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.2
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 +46 -0
- package/executors.ts +26 -23
- 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
|
@@ -518,10 +518,14 @@ describe("AkanAppConfig", () => {
|
|
|
518
518
|
watchdog: { intervalMs: 10_000, failures: 5 },
|
|
519
519
|
});
|
|
520
520
|
expect(() => resolve({ env: { SLACK_WEBHOOK_URL: "https://hooks" } })).toThrow("SLACK_WEBHOOK_URL");
|
|
521
|
-
|
|
521
|
+
for (const key of ["AKAN_MODULES", "AKAN_MCP", "AKAN_SHUTDOWN_TIMEOUT_MS"])
|
|
522
|
+
expect(() => resolve({ env: { [key]: "1" } })).toThrow(`sets ${key}, which the carried server's stage owns`);
|
|
522
523
|
expect(() => resolve({ env: { PORT: 1 } })).toThrow("must be an object of strings");
|
|
523
524
|
expect(() => resolve({ restart: 0 })).toThrow('must be "forever" or a count');
|
|
524
|
-
|
|
525
|
+
for (const watchdog of [{ intervalMs: -1 }, null, 5, [], { interval: 5_000 }])
|
|
526
|
+
expect(() => resolve({ watchdog })).toThrow(
|
|
527
|
+
"native.desktop.server.watchdog in apps/portal/akan.config.ts must be",
|
|
528
|
+
);
|
|
525
529
|
});
|
|
526
530
|
|
|
527
531
|
test("an app without basePaths has one target, default, with no basePath and the section's settings", () => {
|
|
@@ -630,6 +634,30 @@ describe("AkanAppConfig", () => {
|
|
|
630
634
|
).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKeys.");
|
|
631
635
|
});
|
|
632
636
|
|
|
637
|
+
test("refuses updates holding both publicKey and publicKeys, a target's own key beside the section's list included", () => {
|
|
638
|
+
const url = "https://releases.example.com/portal";
|
|
639
|
+
const resolve = (native: object) =>
|
|
640
|
+
new AkanAppConfig(app, [], packageJson, { native } as never, baseDevEnv).native.targets;
|
|
641
|
+
|
|
642
|
+
expect(() =>
|
|
643
|
+
resolve({
|
|
644
|
+
updates: { url, publicKeys: ["a="] },
|
|
645
|
+
targets: { store: {}, pilot: { updates: { publicKey: "p=" } } },
|
|
646
|
+
}),
|
|
647
|
+
).toThrow(
|
|
648
|
+
"native.targets.pilot.updates in apps/portal/akan.config.ts has both publicKey and publicKeys, native.updates merged in",
|
|
649
|
+
);
|
|
650
|
+
expect(() => resolve({ updates: { url, publicKey: "a=", publicKeys: ["b="] } })).toThrow(
|
|
651
|
+
"native.updates in apps/portal/akan.config.ts has both publicKey and publicKeys: give one.",
|
|
652
|
+
);
|
|
653
|
+
expect(
|
|
654
|
+
resolve({
|
|
655
|
+
updates: { url, publicKeys: ["a="] },
|
|
656
|
+
targets: { store: {}, pilot: { updates: { publicKeys: ["p="] } } },
|
|
657
|
+
}).pilot.updates,
|
|
658
|
+
).toEqual({ url, publicKeys: ["p="] });
|
|
659
|
+
});
|
|
660
|
+
|
|
633
661
|
test("merges the desktop settings field by field, the target winning", () => {
|
|
634
662
|
const config = new AkanAppConfig(
|
|
635
663
|
app,
|
|
@@ -949,7 +977,7 @@ describe("pipeline and workspace keys in app and lib configs", () => {
|
|
|
949
977
|
"export default { subspace: { subspaces: [] } };\n",
|
|
950
978
|
"export default {};\n",
|
|
951
979
|
);
|
|
952
|
-
expect(loading).rejects.toThrow("a workspace setting");
|
|
980
|
+
await expect(loading).rejects.toThrow("a workspace setting");
|
|
953
981
|
});
|
|
954
982
|
|
|
955
983
|
test("a lib takes neither a pipeline nor a subspace", async () => {
|
|
@@ -957,11 +985,29 @@ describe("pipeline and workspace keys in app and lib configs", () => {
|
|
|
957
985
|
try {
|
|
958
986
|
const lib = { name: "extlib", cwdPath: root } as never;
|
|
959
987
|
fs.writeFileSync(path.join(root, "akan.config.ts"), "export default { pipeline: { stages: [] } };\n");
|
|
960
|
-
expect(AkanLibConfig.from(lib)).rejects.toThrow("which a lib does not take");
|
|
988
|
+
await expect(AkanLibConfig.from(lib)).rejects.toThrow("which a lib does not take");
|
|
961
989
|
fs.writeFileSync(path.join(root, "akan.config.ts"), "export default { subspace: { subspaces: [] } };\n");
|
|
962
|
-
expect(AkanLibConfig.from(lib, { bustImportCache: true })).rejects.toThrow("a workspace setting");
|
|
990
|
+
await expect(AkanLibConfig.from(lib, { bustImportCache: true })).rejects.toThrow("a workspace setting");
|
|
963
991
|
} finally {
|
|
964
992
|
fs.rmSync(root, { recursive: true, force: true });
|
|
965
993
|
}
|
|
966
994
|
});
|
|
995
|
+
|
|
996
|
+
test("a lib's refused key fails the app's load instead of skipping the lib and its docker steps", async () => {
|
|
997
|
+
const loading = loadExtAppConfig(
|
|
998
|
+
"akan-config-lib-refused-",
|
|
999
|
+
"export default {};\n",
|
|
1000
|
+
"export default { docker: { preRuns: ['apt-get install -y ffmpeg'] }, pipeline: { stages: [] } };\n",
|
|
1001
|
+
);
|
|
1002
|
+
await expect(loading).rejects.toThrow("libs/extlib/akan.config.ts declares `pipeline`, which a lib does not take");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("a lib config that fails to load is still skipped with a warning, the app loading without it", async () => {
|
|
1006
|
+
const config = await loadExtAppConfig(
|
|
1007
|
+
"akan-config-lib-broken-",
|
|
1008
|
+
"export default { externalLibs: ['shiki'] };\n",
|
|
1009
|
+
'throw new Error("half-written");\nexport default {};\n',
|
|
1010
|
+
);
|
|
1011
|
+
expect(config.externalLibs).toEqual(["shiki"]);
|
|
1012
|
+
});
|
|
967
1013
|
});
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ServerPolicy } from "@akanjs/native/api";
|
|
4
5
|
import type { AkanPlugin } from "akanjs";
|
|
5
6
|
import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
|
|
6
7
|
import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
|
|
@@ -152,6 +153,8 @@ const dedupeDockerRuns = (runs: DockerRun[]): DockerRun[] => {
|
|
|
152
153
|
return [...byKey.values()];
|
|
153
154
|
};
|
|
154
155
|
|
|
156
|
+
class LibConfigRefusal extends Error {}
|
|
157
|
+
|
|
155
158
|
export class AkanAppConfig implements AppConfigResult {
|
|
156
159
|
app: App;
|
|
157
160
|
rootPackageJson: PackageJson;
|
|
@@ -285,6 +288,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
285
288
|
throw new Error(
|
|
286
289
|
`${where}.updates in ${configPath} has no url or publicKeys${named ? "; give them in native.updates or the target's own" : ""}.`,
|
|
287
290
|
);
|
|
291
|
+
//? NativeConfig prefers publicKeys to publicKey, so a target's own key beside the section's list would be lost.
|
|
292
|
+
if (updates?.publicKey !== undefined && updates.publicKeys !== undefined)
|
|
293
|
+
throw new Error(
|
|
294
|
+
`${where}.updates in ${configPath} has both publicKey and publicKeys${named ? ", native.updates merged in: give one, in native.updates' own form when overriding its keys" : ": give one"}.`,
|
|
295
|
+
);
|
|
288
296
|
AkanAppConfig.#assertFiles(own ?? {}, where, configPath);
|
|
289
297
|
const {
|
|
290
298
|
basePath: _basePath,
|
|
@@ -370,13 +378,16 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
370
378
|
},
|
|
371
379
|
};
|
|
372
380
|
}
|
|
373
|
-
//? The stage owns these: the operation mode, the database mode
|
|
381
|
+
//? The stage owns these: the operation mode, the database mode, what is served and the shutdown budget the shell's
|
|
382
|
+
//? quit waits on are what make it the carried server.
|
|
374
383
|
static readonly #stagedServerEnv = new Set([
|
|
375
384
|
"AKAN_PUBLIC_OPERATION_MODE",
|
|
376
385
|
"AKAN_DATABASE_MODE",
|
|
377
386
|
"AKAN_DATABASE_MODES",
|
|
378
387
|
"AKAN_SSR",
|
|
379
388
|
"AKAN_CSR",
|
|
389
|
+
"AKAN_MCP",
|
|
390
|
+
"AKAN_SHUTDOWN_TIMEOUT_MS",
|
|
380
391
|
"AKAN_MODULES",
|
|
381
392
|
]);
|
|
382
393
|
static #desktopServerEnv(env: unknown, where: string, configPath: string): Record<string, string> {
|
|
@@ -395,19 +406,25 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
395
406
|
);
|
|
396
407
|
return env as Record<string, string>;
|
|
397
408
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
configPath: string,
|
|
402
|
-
): { intervalMs: number; failures: number } | null {
|
|
409
|
+
//? The akan.config.ts shape, which takes `true` and fills in what is left out; the launcher reads what this returns
|
|
410
|
+
//? with serverPolicyOf in @akanjs/native, which devkit cannot load while it reads a config.
|
|
411
|
+
static #desktopServerWatchdog(watchdog: unknown, where: string, configPath: string): ServerPolicy["watchdog"] | null {
|
|
403
412
|
if (watchdog === undefined || watchdog === false) return null;
|
|
404
|
-
const
|
|
405
|
-
const whole = (value: unknown) => Number.isInteger(value) && (value as number) > 0;
|
|
406
|
-
if (
|
|
413
|
+
const options = watchdog === true ? {} : watchdog;
|
|
414
|
+
const whole = (value: unknown) => value === undefined || (Number.isInteger(value) && (value as number) > 0);
|
|
415
|
+
if (
|
|
416
|
+
!AkanAppConfig.#isPlainObject(options) ||
|
|
417
|
+
Object.keys(options).some((key) => key !== "intervalMs" && key !== "failures") ||
|
|
418
|
+
!whole(options.intervalMs) ||
|
|
419
|
+
!whole(options.failures)
|
|
420
|
+
)
|
|
407
421
|
throw new Error(
|
|
408
422
|
`${where}.watchdog in ${configPath} must be true or { intervalMs, failures }, whole numbers from 1.`,
|
|
409
423
|
);
|
|
410
|
-
return {
|
|
424
|
+
return {
|
|
425
|
+
intervalMs: (options.intervalMs as number | undefined) ?? 10_000,
|
|
426
|
+
failures: (options.failures as number | undefined) ?? 3,
|
|
427
|
+
};
|
|
411
428
|
}
|
|
412
429
|
static #platformIndexPath<T extends { indexPath?: string }>(section: T): T {
|
|
413
430
|
const { indexPath: rawIndexPath, ...rest } = section;
|
|
@@ -685,6 +702,8 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
685
702
|
LibExecutor.from(app, libName)
|
|
686
703
|
.getConfig({ refresh: bustImportCache })
|
|
687
704
|
.catch((error: unknown) => {
|
|
705
|
+
//? Skipping a refused lib config would build the image without its docker steps, and the build would pass.
|
|
706
|
+
if (error instanceof LibConfigRefusal) throw error;
|
|
688
707
|
app.logger.warn(`Skipped libs/${libName}/akan.config.ts contributions: ${String(error)}`);
|
|
689
708
|
return null;
|
|
690
709
|
}),
|
|
@@ -843,11 +862,11 @@ export class AkanLibConfig implements LibConfigResult {
|
|
|
843
862
|
const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
|
|
844
863
|
const { plugins, ...config } = (resolved ?? {}) as LibConfigDeclaration & { plugins?: AkanPlugin[] };
|
|
845
864
|
if ("pipeline" in config)
|
|
846
|
-
throw new
|
|
865
|
+
throw new LibConfigRefusal(
|
|
847
866
|
`libs/${lib.name}/akan.config.ts declares \`pipeline\`, which a lib does not take: pipelines belong to apps and the workspace root, and a lib's build-time work goes in an akan build plugin.`,
|
|
848
867
|
);
|
|
849
868
|
if ("subspace" in config)
|
|
850
|
-
throw new
|
|
869
|
+
throw new LibConfigRefusal(
|
|
851
870
|
`libs/${lib.name}/akan.config.ts declares \`subspace\`, a workspace setting: it belongs in the workspace root akan.config.ts.`,
|
|
852
871
|
);
|
|
853
872
|
return new AkanLibConfig(lib, config, plugins ?? []);
|
|
@@ -51,7 +51,7 @@ describe("AkanWorkspaceConfig", () => {
|
|
|
51
51
|
expect(hostOf(undefined)).toBeNull();
|
|
52
52
|
expect(hostOf({})).toBeNull();
|
|
53
53
|
expect(hostOf({ host: "https://cloud.example.com/" })).toBe("https://cloud.example.com");
|
|
54
|
-
expect(hostOf({ host: "https://example.com/
|
|
54
|
+
expect(hostOf({ host: "https://example.com/control//" })).toBe("https://example.com/control");
|
|
55
55
|
expect(hostOf({ host: "http://localhost:8283" })).toBe("http://localhost:8283");
|
|
56
56
|
expect(() => hostOf({ host: "http://cloud.example.com" })).toThrow("https only");
|
|
57
57
|
expect(() => hostOf({ host: "cloud.example.com" })).toThrow("is not a URL");
|
|
@@ -59,21 +59,21 @@ describe("AkanWorkspaceConfig", () => {
|
|
|
59
59
|
expect(() => hostOf({ host: "https://cloud.example.com", token: "x" })).toThrow("keys it does not take: token");
|
|
60
60
|
|
|
61
61
|
const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
|
|
62
|
-
expect(await GlobalConfig.hostFor(workspace("https://
|
|
62
|
+
expect(await GlobalConfig.hostFor(workspace("https://control.example.com"), "https://given.example.com")).toBe(
|
|
63
63
|
"https://given.example.com",
|
|
64
64
|
);
|
|
65
|
-
expect(await GlobalConfig.hostFor(workspace("https://
|
|
66
|
-
"https://
|
|
65
|
+
expect(await GlobalConfig.hostFor(workspace("https://control.example.com"), null)).toBe(
|
|
66
|
+
"https://control.example.com",
|
|
67
67
|
);
|
|
68
68
|
expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
test("refuses app settings and a default export that is not an object", async () => {
|
|
72
72
|
await withWorkspace({ "akan.config.ts": 'export default { docker: { image: "x" } };\n' }, async (workspace) => {
|
|
73
|
-
expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("declares `docker`, which it does not take");
|
|
73
|
+
await expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("declares `docker`, which it does not take");
|
|
74
74
|
});
|
|
75
75
|
await withWorkspace({ "akan.config.ts": "export default () => ({});\n" }, async (workspace) => {
|
|
76
|
-
expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("must default-export a WorkspaceConfig object");
|
|
76
|
+
await expect(AkanWorkspaceConfig.from(workspace)).rejects.toThrow("must default-export a WorkspaceConfig object");
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
});
|
|
@@ -4,7 +4,7 @@ import type { WorkspaceExecutor } from "../executors";
|
|
|
4
4
|
import { FileSys } from "../fileSys";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
6
6
|
|
|
7
|
-
/** The workspace root's own `akan.config.ts`: settings that belong to no app.
|
|
7
|
+
/** The workspace root's own `akan.config.ts`: settings that belong to no app. */
|
|
8
8
|
export class AkanWorkspaceConfig {
|
|
9
9
|
static readonly fileName = "akan.config.ts";
|
|
10
10
|
static readonly #keys = ["subspace", "pipeline", "cloud"];
|
|
@@ -5,7 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
6
6
|
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
7
7
|
import type { App } from "./commandDecorators";
|
|
8
|
-
import { Executor } from "./executors";
|
|
8
|
+
import { AppExecutor, Executor } from "./executors";
|
|
9
|
+
import { bundleDefine } from "./frontendBuild/bundleDefine";
|
|
10
|
+
import { createTempApp } from "./testHelpers";
|
|
9
11
|
|
|
10
12
|
describe("ApplicationBuildRunner", () => {
|
|
11
13
|
test("externalizes Akan optional backend dependencies", () => {
|
|
@@ -78,4 +80,34 @@ console.info(new SampleService().name);
|
|
|
78
80
|
await rm(root, { recursive: true, force: true });
|
|
79
81
|
}
|
|
80
82
|
});
|
|
83
|
+
|
|
84
|
+
test("bakes into the route bundles the env their server env is, not the shell's that the root .env outranks", async () => {
|
|
85
|
+
const { root, workspace } = await createTempApp("portal");
|
|
86
|
+
const shellEnv = process.env.AKAN_PUBLIC_ENV;
|
|
87
|
+
const stoppedBeforePhases = (app: AppExecutor) => {
|
|
88
|
+
app.prepareCommand = async () => {
|
|
89
|
+
throw new Error("stopped before the phases");
|
|
90
|
+
};
|
|
91
|
+
return app;
|
|
92
|
+
};
|
|
93
|
+
const bakedEnv = (app: AppExecutor) =>
|
|
94
|
+
(bundleDefine(app, "build", "ssr") as { [key: string]: string })["process.env.AKAN_PUBLIC_ENV"];
|
|
95
|
+
try {
|
|
96
|
+
process.env.AKAN_PUBLIC_ENV = "main";
|
|
97
|
+
const app = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
98
|
+
await expect(new ApplicationBuildRunner(app).build()).rejects.toThrow("stopped before the phases");
|
|
99
|
+
expect(bakedEnv(app)).toBe(JSON.stringify("local"));
|
|
100
|
+
|
|
101
|
+
process.env.AKAN_PUBLIC_ENV = "main";
|
|
102
|
+
const desktop = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
103
|
+
await expect(new ApplicationBuildRunner(desktop, { environment: "debug" }).build()).rejects.toThrow(
|
|
104
|
+
"stopped before the phases",
|
|
105
|
+
);
|
|
106
|
+
expect(bakedEnv(desktop)).toBe(JSON.stringify("debug"));
|
|
107
|
+
} finally {
|
|
108
|
+
if (shellEnv === undefined) delete process.env.AKAN_PUBLIC_ENV;
|
|
109
|
+
else process.env.AKAN_PUBLIC_ENV = shellEnv;
|
|
110
|
+
await rm(root, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
81
113
|
});
|
|
@@ -100,13 +100,12 @@ export class ApplicationBuildRunner {
|
|
|
100
100
|
// serial build is needed because of Bun.build is unstable for parallel build
|
|
101
101
|
this.#spinner = spinner;
|
|
102
102
|
const { web, assets, baseDevEnv } = await this.#app.getConfig();
|
|
103
|
+
const environment = this.#environment ?? baseDevEnv.env;
|
|
104
|
+
//? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
|
|
105
|
+
process.env.AKAN_PUBLIC_ENV = environment;
|
|
103
106
|
//? Before any phase: a profile that would ship a secret stops the build, not the image after it.
|
|
104
107
|
if (this.#profile)
|
|
105
|
-
await ServerEnvProfile.assertShippable({
|
|
106
|
-
envDir: this.#envDir,
|
|
107
|
-
environment: this.#environment ?? baseDevEnv.env,
|
|
108
|
-
profile: this.#profile,
|
|
109
|
-
});
|
|
108
|
+
await ServerEnvProfile.assertShippable({ envDir: this.#envDir, environment, profile: this.#profile });
|
|
110
109
|
await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"));
|
|
111
110
|
if (!this.#fast) await this.#runPhase("typecheck", "Typechecking", () => this.typecheck());
|
|
112
111
|
await this.#runPhase(
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Workspace } from "../commandDecorators";
|
|
2
|
+
import { CloudHttpError } from "./cloudHttpError";
|
|
2
3
|
import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
|
|
3
4
|
import { GlobalConfig } from "./globalConfig";
|
|
4
5
|
|
|
@@ -25,7 +26,7 @@ class HttpClient {
|
|
|
25
26
|
const response = await fetch(`${this.baseUrl}${url}`, {
|
|
26
27
|
headers: { ...this.headers, ...headers },
|
|
27
28
|
});
|
|
28
|
-
if (!response.ok) throw
|
|
29
|
+
if (!response.ok) throw await CloudHttpError.of(response, url);
|
|
29
30
|
await Bun.write(localPath, response);
|
|
30
31
|
}
|
|
31
32
|
async post<T>(url: string, data: unknown, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
|
|
@@ -42,9 +43,8 @@ class HttpClient {
|
|
|
42
43
|
}
|
|
43
44
|
// A missing route answers an HTML error page, so the status is read before the body is parsed as JSON.
|
|
44
45
|
static async #body<T>(response: Response, url: string): Promise<T> {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return JSON.parse(text) as T;
|
|
46
|
+
if (!response.ok) throw await CloudHttpError.of(response, url);
|
|
47
|
+
return JSON.parse(await response.text()) as T;
|
|
48
48
|
}
|
|
49
49
|
setHeaders(headers: Record<string, string>) {
|
|
50
50
|
Object.assign(this.headers, headers);
|
|
@@ -71,10 +71,10 @@ export interface PipelineSecretSummary {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface PipelineSecretListing extends PipelineSecretSummary {
|
|
74
|
-
updatedBy: string;
|
|
74
|
+
updatedBy: string | null;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** What
|
|
77
|
+
/** What Akan Cloud answers for an uploaded pack or a registered binary; a pack's release also says how it was taken. */
|
|
78
78
|
export interface CloudRecord {
|
|
79
79
|
id: string;
|
|
80
80
|
status?: string;
|
|
@@ -113,10 +113,19 @@ export interface TunnelSummary {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export class CloudApi {
|
|
116
|
-
|
|
117
|
-
static
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
//* The project's guards answer by status: 401 no sign-in it accepts, 403 not allowed, 400 a refused request.
|
|
117
|
+
static refusal(error: unknown, who: string, { unavailable }: { unavailable?: string } = {}) {
|
|
118
|
+
if (!(error instanceof CloudHttpError)) return error;
|
|
119
|
+
if (error.status === 401)
|
|
120
|
+
return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
|
|
121
|
+
if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
|
|
122
|
+
if (error.status === 400)
|
|
123
|
+
return new Error(`The project refused the request${error.error ? `: ${error.error}` : ""}.`, { cause: error });
|
|
124
|
+
//? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
|
|
125
|
+
const { errKey } = error;
|
|
126
|
+
if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
|
|
127
|
+
return new Error(`${unavailable}: ${errKey}.`, { cause: error });
|
|
128
|
+
return error;
|
|
120
129
|
}
|
|
121
130
|
readonly #api: HttpClient;
|
|
122
131
|
#accessToken: AccessToken | null = null;
|
|
@@ -184,9 +193,8 @@ export class CloudApi {
|
|
|
184
193
|
target: string;
|
|
185
194
|
distribution: AppBinaryDistribution;
|
|
186
195
|
bundle: Blob;
|
|
187
|
-
/** The pack staged with UpdatePackUpload.stage
|
|
188
|
-
packUploadId
|
|
189
|
-
pack?: Blob;
|
|
196
|
+
/** The pack staged with UpdatePackUpload.stage. */
|
|
197
|
+
packUploadId: string;
|
|
190
198
|
channel?: string;
|
|
191
199
|
},
|
|
192
200
|
) {
|
|
@@ -194,8 +202,7 @@ export class CloudApi {
|
|
|
194
202
|
for (const key of ["appName", "target", "distribution"] as const) formData.append(key, binary[key]);
|
|
195
203
|
if (binary.channel) formData.append("channel", binary.channel);
|
|
196
204
|
formData.append("bundle", binary.bundle, "bundle.json");
|
|
197
|
-
|
|
198
|
-
if (binary.pack) formData.append("pack", binary.pack, "pack.tgz");
|
|
205
|
+
formData.append("packUploadId", binary.packUploadId);
|
|
199
206
|
return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData, {
|
|
200
207
|
signal: AbortSignal.timeout(11 * 60_000),
|
|
201
208
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CloudHttpError } from "./cloudHttpError";
|
|
3
|
+
|
|
4
|
+
describe("CloudHttpError", () => {
|
|
5
|
+
test("reads the server's Err key from the whole body, while the message keeps 200 characters of it", () => {
|
|
6
|
+
const body = JSON.stringify({ details: "x".repeat(300), error: "release.error.uploadEmbedded", statusCode: 409 });
|
|
7
|
+
const error = new CloudHttpError(409, "Conflict", "/commitUpdateUpload/p", body);
|
|
8
|
+
|
|
9
|
+
expect(error.status).toBe(409);
|
|
10
|
+
expect(error.errKey).toBe("release.error.uploadEmbedded");
|
|
11
|
+
expect(error.message).toBe(`409 Conflict from /commitUpdateUpload/p: ${body.slice(0, 200)}`);
|
|
12
|
+
expect(CloudHttpError.statusOf(error)).toBe(409);
|
|
13
|
+
expect(CloudHttpError.statusOf(new Error("409 Conflict from /x: {}"))).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("names no Err key for a proxy's page or an error that is not one", () => {
|
|
17
|
+
expect(new CloudHttpError(503, "Service Unavailable", "/x", "<html>503</html>").errKey).toBeNull();
|
|
18
|
+
const plain = new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request"}');
|
|
19
|
+
expect(plain.error).toBe("Bad Request");
|
|
20
|
+
expect(plain.errKey).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("takes Retry-After as seconds or as a date, and nothing else", () => {
|
|
24
|
+
expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
|
|
25
|
+
expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
|
|
26
|
+
expect(CloudHttpError.retryAfterMs(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
|
|
27
|
+
expect(CloudHttpError.retryAfterMs("soon")).toBeNull();
|
|
28
|
+
expect(CloudHttpError.retryAfterMs(null)).toBeNull();
|
|
29
|
+
const headers = new Headers({ "Retry-After": "3" });
|
|
30
|
+
expect(new CloudHttpError(429, "Too Many Requests", "/x", "", headers).retryAfterMs).toBe(3000);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isRecord } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
//? The body is kept whole for reading the server's answer; only the message is cut, as it lands in a terminal.
|
|
4
|
+
export class CloudHttpError extends Error {
|
|
5
|
+
static readonly #errKey = /^[\w$-]+\.error\.[\w$-]+$/;
|
|
6
|
+
|
|
7
|
+
override readonly name = "CloudHttpError";
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
readonly status: number,
|
|
11
|
+
readonly statusText: string,
|
|
12
|
+
readonly url: string,
|
|
13
|
+
readonly body: string,
|
|
14
|
+
readonly headers: Headers = new Headers(),
|
|
15
|
+
) {
|
|
16
|
+
super(`${status} ${statusText} from ${url}: ${body.slice(0, 200)}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async of(response: Response, url: string) {
|
|
20
|
+
return new CloudHttpError(response.status, response.statusText, url, await response.text(), response.headers);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static statusOf(error: unknown) {
|
|
24
|
+
return error instanceof CloudHttpError ? error.status : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
|
|
28
|
+
get error(): string | null {
|
|
29
|
+
try {
|
|
30
|
+
const parsed: unknown = JSON.parse(this.body);
|
|
31
|
+
return isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `error` when it is an Err key (`release.error.uploadCommitInProgress`), which only the server's own answer names. */
|
|
38
|
+
get errKey(): string | null {
|
|
39
|
+
const { error } = this;
|
|
40
|
+
return error && CloudHttpError.#errKey.test(error) ? error : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get retryAfterMs(): number | null {
|
|
44
|
+
return CloudHttpError.retryAfterMs(this.headers.get("retry-after"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Retry-After in milliseconds from now: whole seconds, or an HTTP date. */
|
|
48
|
+
static retryAfterMs(header: string | null): number | null {
|
|
49
|
+
const value = header?.trim() ?? "";
|
|
50
|
+
if (!value) return null;
|
|
51
|
+
const ms = /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - Date.now();
|
|
52
|
+
return Number.isFinite(ms) ? Math.max(0, ms) : null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
import type { NativeEnv } from "../akanConfig";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { ExitCodeError } from "../commandDecorators/exitCodeError";
|
|
6
|
+
import { type MobilePlatform, resolveMobileTargets } from "../mobile/mobileTarget";
|
|
7
|
+
import { NativeApp } from "../mobile/nativeApp";
|
|
8
|
+
import { NativePublicEnv } from "../mobile/nativePublicEnv";
|
|
9
|
+
import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
|
|
10
|
+
import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
|
|
11
|
+
import { UpdatePackUpload } from "./updatePackUpload";
|
|
12
|
+
|
|
13
|
+
//* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
|
|
14
|
+
//* it to sign and publish, and the store build a pack of the same commit is embedded in.
|
|
15
|
+
export class CloudUpdates {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly cloudApi: CloudApi,
|
|
18
|
+
readonly devProjectId: string,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/** The bundle.json the target's last `akan release-<platform>` wrote. */
|
|
22
|
+
static async storeBundlePath(app: App, target: string | undefined, platform: MobilePlatform) {
|
|
23
|
+
const [mobileTarget] = await resolveMobileTargets(app, target);
|
|
24
|
+
if (!mobileTarget) throw new Error(`Native target '${target}' was not found.`);
|
|
25
|
+
return path.join(new NativeApp(app, mobileTarget).outDir(platform), UpdatePackFolder.bundleFile);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A pack `akan pack-update` wrote, refused unless it is this app's and agrees with every flag given beside it. */
|
|
29
|
+
static async readPack(
|
|
30
|
+
app: App,
|
|
31
|
+
dir: string,
|
|
32
|
+
{
|
|
33
|
+
platform,
|
|
34
|
+
target,
|
|
35
|
+
env,
|
|
36
|
+
allowLocalRelease,
|
|
37
|
+
}: { platform: MobilePlatform | null; target: string | null; env: NativeEnv | null; allowLocalRelease: boolean },
|
|
38
|
+
) {
|
|
39
|
+
const pack = await UpdatePackFolder.read(dir);
|
|
40
|
+
const what = `The pack in ${pack.out} is ${pack.app} ${pack.target} ${pack.platform} --env ${pack.env}`;
|
|
41
|
+
if (pack.app !== app.name) throw new Error(`${what}, not ${app.name}'s.`);
|
|
42
|
+
if (platform && platform !== pack.platform) throw new Error(`${what}, not ${platform}.`);
|
|
43
|
+
if (env && env !== pack.env) throw new Error(`${what}, not --env ${env}.`);
|
|
44
|
+
if (target) {
|
|
45
|
+
const targets = (await resolveMobileTargets(app, target)).map(({ name }) => name);
|
|
46
|
+
if (targets.length !== 1 || targets[0] !== pack.target)
|
|
47
|
+
throw new Error(`${what}, not --target ${target} (${targets.join(", ")}).`);
|
|
48
|
+
}
|
|
49
|
+
if (pack.env === "local" && !allowLocalRelease)
|
|
50
|
+
throw new Error(
|
|
51
|
+
`${what}, and a local pack is blocked. Pass --allow-local-release only for explicit local release testing.`,
|
|
52
|
+
);
|
|
53
|
+
return pack;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async push(pack: NativeUpdatePack, { channel, notes }: { channel: string | null; notes: string | null }) {
|
|
57
|
+
const target = channel ?? pack.channel;
|
|
58
|
+
return await UpdatePackFolder.withArchive(pack, async (archive) => {
|
|
59
|
+
const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
|
|
60
|
+
.send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
|
|
61
|
+
.catch((error: unknown) => {
|
|
62
|
+
throw CloudApi.refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
|
|
63
|
+
});
|
|
64
|
+
const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
|
|
65
|
+
if (release.status === "rejected")
|
|
66
|
+
throw new ExitCodeError(
|
|
67
|
+
`The project rejected ${label}:\n${(release.problems ?? []).map((problem) => ` - ${problem}`).join("\n")}`,
|
|
68
|
+
2,
|
|
69
|
+
);
|
|
70
|
+
if (release.status === "published")
|
|
71
|
+
Logger.rawLog(`Published ${label} (${pack.files} files, native API ${pack.nativeApi}).`);
|
|
72
|
+
else
|
|
73
|
+
Logger.rawLog(
|
|
74
|
+
`Staged ${label} (${pack.files} files, native API ${pack.nativeApi}). Publish it from the project's release screen.`,
|
|
75
|
+
);
|
|
76
|
+
return release;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
|
|
81
|
+
async registerBinary(
|
|
82
|
+
app: App,
|
|
83
|
+
pack: NativeUpdatePack,
|
|
84
|
+
{ distribution, env }: { distribution: AppBinaryDistribution; env: NativeEnv },
|
|
85
|
+
) {
|
|
86
|
+
const { cloudApi, devProjectId } = this;
|
|
87
|
+
const bundlePath = await CloudUpdates.storeBundlePath(app, pack.target, pack.platform);
|
|
88
|
+
const bundleFile = Bun.file(bundlePath);
|
|
89
|
+
const rebuild = `akan release-${pack.platform} ${app.name} --target ${pack.target} --env ${env}`;
|
|
90
|
+
if (!(await bundleFile.exists())) throw new Error(`No store build at ${bundlePath}: run \`${rebuild}\` first.`);
|
|
91
|
+
const bundle = await UpdatePackFolder.readBundle(bundlePath);
|
|
92
|
+
if (!bundle.updates)
|
|
93
|
+
throw new Error(`The store build at ${bundlePath} takes no updates: give the target native.updates.`);
|
|
94
|
+
const packBundle = path.join(pack.out, UpdatePackFolder.bundleFile);
|
|
95
|
+
const envProblem = await NativePublicEnv.problemAgainst(bundlePath, packBundle);
|
|
96
|
+
if (envProblem) throw new ExitCodeError(`The pack in ${pack.out} cannot run in the store build: ${envProblem}.`, 2);
|
|
97
|
+
//? Every native build of the target rewrites this file, so a debug or another env's build replaces the release's.
|
|
98
|
+
if (bundle.web.hash !== pack.webHash)
|
|
99
|
+
throw new Error(
|
|
100
|
+
`The store build at ${bundlePath} is not this commit's --env ${env} build: its web bundle differs from the pack's. Run \`${rebuild}\`, then register that build.`,
|
|
101
|
+
);
|
|
102
|
+
const compat = await NativeApp.compareBundles(app.cwdPath, bundlePath, packBundle);
|
|
103
|
+
const verdict = UpdatePackFolder.storeBuildVerdict(
|
|
104
|
+
{ out: pack.out, compat: { against: bundlePath, ...compat } },
|
|
105
|
+
`Run \`${rebuild}\`, then register that build.`,
|
|
106
|
+
);
|
|
107
|
+
if (verdict) throw verdict;
|
|
108
|
+
if (bundle.updates.channel !== pack.channel)
|
|
109
|
+
throw new Error(
|
|
110
|
+
`The store build at ${bundlePath} reads updates channel ${bundle.updates.channel}, and this commit's --env ${env} pack is for ${pack.channel}. Run \`${rebuild}\`, then register that build.`,
|
|
111
|
+
);
|
|
112
|
+
const { channel } = pack;
|
|
113
|
+
const upload = new UpdatePackUpload(cloudApi, devProjectId);
|
|
114
|
+
return await UpdatePackFolder.withArchive(pack, async (archive) => {
|
|
115
|
+
const binary = await upload
|
|
116
|
+
.stage(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel, notes: null })
|
|
117
|
+
.then((packUploadId) =>
|
|
118
|
+
upload.settle(() =>
|
|
119
|
+
cloudApi.registerAppBinary(devProjectId, {
|
|
120
|
+
appName: pack.app,
|
|
121
|
+
target: pack.target,
|
|
122
|
+
distribution,
|
|
123
|
+
bundle: bundleFile,
|
|
124
|
+
packUploadId,
|
|
125
|
+
channel,
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
.catch((error: unknown) => {
|
|
130
|
+
throw CloudApi.refusal(error, "a binary is registered by a project member signed in with `akan login`");
|
|
131
|
+
});
|
|
132
|
+
Logger.rawLog(
|
|
133
|
+
`Registered ${pack.app} ${pack.target} ${pack.platform} (${distribution}): binary ${binary.id}, native API ${pack.nativeApi}.`,
|
|
134
|
+
);
|
|
135
|
+
return binary;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { GlobalConfig } from "./globalConfig";
|
|
3
|
+
|
|
4
|
+
const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
|
|
5
|
+
|
|
6
|
+
describe("GlobalConfig", () => {
|
|
7
|
+
test("hostFor spells --host the way the workspace's cloud.host is spelled, so one login serves both", async () => {
|
|
8
|
+
for (const given of ["https://cloud.example.com/", "https://Cloud.Example.com:443", "https://cloud.example.com//"])
|
|
9
|
+
expect(await GlobalConfig.hostFor(workspace(null), given)).toBe("https://cloud.example.com");
|
|
10
|
+
expect(await GlobalConfig.hostFor(workspace("https://cloud.example.com"), "https://cloud.example.com/")).toBe(
|
|
11
|
+
await GlobalConfig.hostFor(workspace("https://cloud.example.com"), null),
|
|
12
|
+
);
|
|
13
|
+
expect(await GlobalConfig.hostFor(workspace(null), "https://example.com/cloud/")).toBe("https://example.com/cloud");
|
|
14
|
+
expect(await GlobalConfig.hostFor(workspace(null), "http://localhost:8283/")).toBe("http://localhost:8283");
|
|
15
|
+
expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
|
|
16
|
+
});
|
|
17
|
+
});
|