@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.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +69 -0
  24. package/executors.ts +30 -27
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. 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
- expect(() => resolve({ env: { AKAN_MODULES: "@edge" } })).toThrow("the carried server's stage owns");
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
- expect(() => resolve({ watchdog: { intervalMs: -1 } })).toThrow("watchdog in apps/");
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
  });
@@ -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 and what is served are what make it the carried server.
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
- static #desktopServerWatchdog(
399
- watchdog: unknown,
400
- where: string,
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 { intervalMs = 10_000, failures = 3 } = (watchdog === true ? {} : watchdog) as Record<string, unknown>;
405
- const whole = (value: unknown) => Number.isInteger(value) && (value as number) > 0;
406
- if ((watchdog !== true && !AkanAppConfig.#isPlainObject(watchdog)) || !whole(intervalMs) || !whole(failures))
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 { intervalMs: intervalMs as number, failures: failures as number };
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 Error(
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 Error(
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/akasys//" })).toBe("https://example.com/akasys");
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://akasys.example.com"), "https://given.example.com")).toBe(
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://akasys.example.com"), null)).toBe(
66
- "https://akasys.example.com",
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. Absent, it is an empty one. */
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 new Error(`Failed to download file: ${response.status} ${response.statusText}`);
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
- const text = await response.text();
46
- if (!response.ok) throw new Error(`${response.status} ${response.statusText} from ${url}: ${text.slice(0, 200)}`);
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 akasys answers for an uploaded pack or a registered binary; a pack's release also says how it was taken. */
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
- /** The HTTP status an error from this client carries, or null for one without (a dropped connection, a timeout). */
117
- static statusOf(error: unknown) {
118
- const status = Number(/(?:^|: )(\d{3}) /.exec(error instanceof Error ? error.message : String(error))?.[1]);
119
- return Number.isNaN(status) ? null : status;
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; the multipart `pack` is for a server older than staging. */
188
- packUploadId?: string;
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
- if (binary.packUploadId) formData.append("packUploadId", binary.packUploadId);
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
+ });