@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.11

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 (138) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.test.ts +51 -5
  4. package/akanConfig/akanConfig.ts +33 -14
  5. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  6. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  7. package/akanContext.ts +4 -4
  8. package/akanMcpContract.ts +1 -1
  9. package/applicationBuildRunner.test.ts +57 -2
  10. package/applicationBuildRunner.ts +15 -6
  11. package/artifact/implicitRootLayout.test.ts +2 -0
  12. package/artifact/implicitRootLayout.ts +1 -0
  13. package/biome.base.json +1 -11
  14. package/biomeBase.ts +1 -1
  15. package/cloud/cloudApi.ts +22 -15
  16. package/cloud/cloudHttpError.test.ts +32 -0
  17. package/cloud/cloudHttpError.ts +54 -0
  18. package/cloud/cloudUpdates.ts +138 -0
  19. package/cloud/configLock.ts +2 -1
  20. package/cloud/globalConfig.test.ts +17 -0
  21. package/cloud/globalConfig.ts +9 -2
  22. package/cloud/index.ts +3 -0
  23. package/cloud/pipelineSecrets.ts +114 -0
  24. package/cloud/updatePackUpload.test.ts +100 -45
  25. package/cloud/updatePackUpload.ts +38 -18
  26. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  27. package/codeAgent/agent/akanCodeModel.ts +1 -1
  28. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  29. package/codeAgent/tools/WebToolPack.ts +4 -4
  30. package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
  31. package/commandDecorators/argMeta.ts +11 -2
  32. package/commandDecorators/command.ts +45 -10
  33. package/commandDecorators/commandBuilder.ts +6 -1
  34. package/commandDecorators/commandDecorators.test.ts +205 -4
  35. package/commandDecorators/exitCodeError.ts +15 -0
  36. package/commandDecorators/index.ts +1 -0
  37. package/createTunnel.test.ts +14 -0
  38. package/createTunnel.ts +3 -1
  39. package/dependencyScanner.ts +1 -1
  40. package/devkitUtils.test.ts +1 -1
  41. package/executors.test.ts +131 -3
  42. package/executors.ts +56 -36
  43. package/frontendBuild/autoImportSync.test.ts +3 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  45. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  46. package/frontendBuild/csrArtifactBuilder.ts +23 -4
  47. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  48. package/frontendBuild/cssBlockParser.test.ts +123 -0
  49. package/frontendBuild/cssBlockParser.ts +132 -0
  50. package/frontendBuild/cssCompiler.test.ts +53 -0
  51. package/frontendBuild/cssCompiler.ts +8 -4
  52. package/frontendBuild/fontOptimizer.ts +2 -1
  53. package/frontendBuild/frontendBuild.test.ts +14 -0
  54. package/frontendBuild/hmrWatcher.ts +1 -1
  55. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  56. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  57. package/frontendBuild/routeClientBuilder.ts +1 -1
  58. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  59. package/frontendBuild/themeValidator.test.ts +83 -2
  60. package/frontendBuild/themeValidator.ts +37 -15
  61. package/getRelatedCnsts.ts +2 -1
  62. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  63. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  65. package/integration/devStabilityHarness.ts +5 -3
  66. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  67. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  68. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  69. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  70. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  71. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  72. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  73. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  74. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  75. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  76. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  77. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  78. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  79. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  80. package/mobile/desktopServerStage.test.ts +13 -0
  81. package/mobile/desktopServerStage.ts +5 -7
  82. package/mobile/index.ts +2 -0
  83. package/mobile/mobileTarget.ts +1 -2
  84. package/mobile/nativeApi.test.ts +26 -0
  85. package/mobile/nativeApi.ts +5 -2
  86. package/mobile/nativeApp.test.ts +135 -33
  87. package/mobile/nativeApp.ts +109 -73
  88. package/mobile/nativeConfig.test.ts +3 -0
  89. package/mobile/nativeConfig.ts +5 -3
  90. package/mobile/nativePublicEnv.test.ts +131 -0
  91. package/mobile/nativePublicEnv.ts +116 -0
  92. package/mobile/nativeWebDir.ts +3 -2
  93. package/mobile/serverBundleSecrets.test.ts +79 -0
  94. package/mobile/serverBundleSecrets.ts +60 -16
  95. package/mobile/updatePackFolder.test.ts +98 -0
  96. package/mobile/updatePackFolder.ts +131 -0
  97. package/mobile/updateUploader.test.ts +115 -22
  98. package/mobile/updateUploader.ts +77 -10
  99. package/package.json +20 -20
  100. package/piDependencyPins.test.ts +18 -19
  101. package/pipeline/PipelineCompiler.test.ts +157 -1
  102. package/pipeline/PipelineCompiler.ts +80 -46
  103. package/pipeline/PipelineDefinition.test.ts +9 -0
  104. package/pipeline/PipelineDefinition.ts +4 -3
  105. package/pipeline/PipelineDryRun.test.ts +27 -0
  106. package/pipeline/PipelineDryRun.ts +2 -2
  107. package/pipeline/PipelineInputReader.ts +157 -253
  108. package/pipeline/PipelineRules.ts +92 -0
  109. package/pipeline/index.ts +1 -0
  110. package/platformTest/LinuxDockerTarget.ts +2 -1
  111. package/platformTest/PlatformTestTarget.ts +7 -5
  112. package/platformTest/WindowsSshTarget.ts +2 -1
  113. package/qualityScanner.test.ts +5 -3
  114. package/qualityScanner.ts +1 -1
  115. package/recipeScanner.test.ts +9 -3
  116. package/scanInfo.ts +1 -1
  117. package/slicePlanner.ts +5 -3
  118. package/subspace.test.ts +90 -19
  119. package/subspace.ts +52 -500
  120. package/subspaceClone.ts +362 -0
  121. package/subspaceConfig.ts +1 -2
  122. package/subspaceReport.ts +162 -0
  123. package/testHelpers.ts +2 -0
  124. package/transforms/barrelImportsPlugin.ts +1 -1
  125. package/transforms/browserDirnamePlugin.test.ts +88 -0
  126. package/transforms/browserDirnamePlugin.ts +25 -0
  127. package/transforms/rscUseClientTransform.ts +2 -2
  128. package/transforms/serverEnvPlugin.test.ts +76 -0
  129. package/transforms/serverEnvPlugin.ts +9 -25
  130. package/transforms/serverEnvProfile.test.ts +189 -0
  131. package/transforms/serverEnvProfile.ts +324 -60
  132. package/transforms/transforms.test.ts +18 -2
  133. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  134. package/typecheck/projectTypecheck.ts +6 -5
  135. package/types.ts +1 -0
  136. package/workflow/artifacts.ts +1 -1
  137. package/workflow/rolloutGate.test.ts +5 -1
  138. package/workflow/source.ts +2 -2
package/abstractDoc.ts CHANGED
@@ -26,7 +26,7 @@ export class AbstractDoc {
26
26
  static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
27
27
  const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`))
28
28
  .map((filePath) => filePath.split(path.sep).join("/"))
29
- .sort();
29
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
30
30
  const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
31
31
  if (!module) return docs;
32
32
  return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
@@ -86,6 +86,9 @@ describe("collectScopeRecipeSources", () => {
86
86
  await write("libs/shared/ui/Recipe/panel.ts", `export const panelRecipe = recipe(tv({ base: "y" }));`);
87
87
  await write("pkgs/akanjs/ui/recipe/buttonRecipe.ts", `export const buttonRecipe = recipe(tv({ base: "z" }));`);
88
88
  const sources = await collectScopeRecipeSources(root, { type: "app", name: "minimal" }, ["shared"]);
89
- expect(sources.map((source) => source.importFrom).sort()).toEqual(["@apps/minimal/ui", "@libs/shared/ui"]);
89
+ expect(sources.map((source) => source.importFrom).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
90
+ "@apps/minimal/ui",
91
+ "@libs/shared/ui",
92
+ ]);
90
93
  });
91
94
  });
@@ -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,9 +1,10 @@
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
- import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
7
+ import { type AkanI18nConfig, resolveAkanI18nConfig, trimSlashes } from "akanjs/common";
7
8
  import type { AkanImageConfig } from "akanjs/server";
8
9
  import type { App, Lib } from "../commandDecorators";
9
10
  import { LibExecutor, WorkspaceExecutor } from "../executors";
@@ -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;
@@ -550,7 +567,7 @@ export class AkanAppConfig implements AppConfigResult {
550
567
  }
551
568
  #applyRoutes(routes: AkanRouteConfig[] = []) {
552
569
  for (const route of routes) {
553
- const basePath = route.basePath ? route.basePath.replace(/^\/+|\/+$/g, "") : null;
570
+ const basePath = route.basePath ? trimSlashes(route.basePath) : null;
554
571
  if (basePath !== null) this.basePaths.add(basePath);
555
572
  const domains = basePath !== null ? this.subRoutes.getOrInsert(basePath, new Set()) : this.domains;
556
573
  for (const branch of Object.keys(route.domains)) this.branches.add(branch);
@@ -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"];
package/akanContext.ts CHANGED
@@ -209,7 +209,7 @@ export const upsertCodexMcpServerBlock = (
209
209
  const match = existing.match(codexAkanTablePattern);
210
210
  if (!match) {
211
211
  if (!existing.trim()) return nextBlock;
212
- return `${existing.replace(/\s*$/, "")}\n\n${nextBlock}`;
212
+ return `${existing.trimEnd()}\n\n${nextBlock}`;
213
213
  }
214
214
  if (match[0].trim() === nextBlock.trim()) return existing;
215
215
  if (!force)
@@ -847,8 +847,8 @@ export class AkanContextAnalyzer {
847
847
  const listed = listedByImport(sectionOf(agentsMd, "## Recipes")).get("akanjs/ui") ?? new Set<string>();
848
848
  pushIndexDiagnostic(
849
849
  "AGENTS.md",
850
- [...frameworkDeclared].filter((name) => !listed.has(name)).sort(),
851
- [...listed].filter((name) => !frameworkDeclared.has(name)).sort(),
850
+ [...frameworkDeclared].filter((name) => !listed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
851
+ [...listed].filter((name) => !frameworkDeclared.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
852
852
  "akan agent install agents-md",
853
853
  );
854
854
  }
@@ -859,7 +859,7 @@ export class AkanContextAnalyzer {
859
859
  .catch(() => "");
860
860
  const groups = listedByImport(sectionOf(scopeMd, "## Recipes In Scope"));
861
861
  const ownListed = groups.get(`@${sys.path}/ui`) ?? new Set<string>();
862
- const missing = [...own].filter((name) => !ownListed.has(name)).sort();
862
+ const missing = [...own].filter((name) => !ownListed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
863
863
  // Every listed name — the scope's own and its dependency libs' — must still exist at its owner.
864
864
  const stale = [...groups.entries()]
865
865
  .flatMap(([importFrom, names]) =>
@@ -113,7 +113,7 @@ const slugPart = (value: unknown) =>
113
113
  ? value
114
114
  .trim()
115
115
  .replace(/[^a-zA-Z0-9]+/g, "-")
116
- .replace(/^-+|-+$/g, "")
116
+ .replace(/^-|-$/g, "")
117
117
  .toLowerCase()
118
118
  : "";
119
119
 
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  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,57 @@ 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
+ });
113
+
114
+ test("a build for a real environment stops before any phase when the serve domain is only the local default", async () => {
115
+ const { root, workspace } = await createTempApp("portal");
116
+ await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
117
+ const shellDomain = process.env.AKAN_PUBLIC_SERVE_DOMAIN;
118
+ delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
119
+ const app = AppExecutor.from(workspace, "portal");
120
+ let prepared = false;
121
+ app.prepareCommand = async () => {
122
+ prepared = true;
123
+ throw new Error("prepared");
124
+ };
125
+ try {
126
+ await expect(new ApplicationBuildRunner(app, { environment: "main" }).build()).rejects.toThrow(
127
+ 'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
128
+ );
129
+ expect(prepared).toBe(false);
130
+ await expect(new ApplicationBuildRunner(app, { environment: "local" }).build()).rejects.toThrow("prepared");
131
+ } finally {
132
+ if (shellDomain !== undefined) process.env.AKAN_PUBLIC_SERVE_DOMAIN = shellDomain;
133
+ await rm(root, { recursive: true, force: true });
134
+ }
135
+ });
81
136
  });
@@ -6,6 +6,7 @@ import type {
6
6
  ApplicationBuildResult,
7
7
  } from "./applicationBuildReporter";
8
8
  import type { App } from "./commandDecorators";
9
+ import { WorkspaceExecutor } from "./executors";
9
10
  import {
10
11
  AllRoutesBuilder,
11
12
  CsrArtifactBuilder,
@@ -100,13 +101,13 @@ export class ApplicationBuildRunner {
100
101
  // serial build is needed because of Bun.build is unstable for parallel build
101
102
  this.#spinner = spinner;
102
103
  const { web, assets, baseDevEnv } = await this.#app.getConfig();
104
+ const environment = this.#environment ?? baseDevEnv.env;
105
+ WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
106
+ //? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
107
+ process.env.AKAN_PUBLIC_ENV = environment;
103
108
  //? Before any phase: a profile that would ship a secret stops the build, not the image after it.
104
109
  if (this.#profile)
105
- await ServerEnvProfile.assertShippable({
106
- envDir: this.#envDir,
107
- environment: this.#environment ?? baseDevEnv.env,
108
- profile: this.#profile,
109
- });
110
+ await ServerEnvProfile.assertShippable({ envDir: this.#envDir, environment, profile: this.#profile });
110
111
  await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"));
111
112
  if (!this.#fast) await this.#runPhase("typecheck", "Typechecking", () => this.typecheck());
112
113
  await this.#runPhase(
@@ -363,9 +364,17 @@ void run().catch((error) => {
363
364
  return { base, allRoutes };
364
365
  }
365
366
 
367
+ //? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
366
368
  async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
367
- const result = await Bun.build(config);
369
+ const metafileDir = process.env.AKAN_BUILD_METAFILE
370
+ ? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
371
+ : null;
372
+ const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
368
373
  if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
374
+ if (metafileDir && result.metafile) {
375
+ const entry = path.parse(String(config.entrypoints[0] ?? label)).name;
376
+ await Bun.write(path.join(metafileDir, `${entry}.json`), JSON.stringify(result.metafile));
377
+ }
369
378
  return result;
370
379
  }
371
380
 
@@ -46,6 +46,8 @@ describe("resolveSsrPageEntries", () => {
46
46
  expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
47
47
  expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
48
48
  expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
49
+ // The ancestor root is its own level of the stack, so inheriting its loading would nest it inside itself.
50
+ expect(generatedSource).toContain("export const Loading = userLayout.Loading;");
49
51
  expect(generatedSource).toContain(
50
52
  "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
51
53
  );
@@ -201,6 +201,7 @@ async function writeGeneratedRootLayoutFile(opts: {
201
201
  export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
202
202
  export const Error = userLayout.Error ?? inheritedLayout.Error;
203
203
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
204
+ export const Loading = userLayout.Loading;
204
205
 
205
206
  ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
206
207
  ${layoutBinding} return `;
package/biome.base.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
@@ -85,16 +85,6 @@
85
85
  "overrides": [
86
86
  {
87
87
  "includes": [
88
- "**/env.client.local.ts",
89
- "**/env.client.debug.ts",
90
- "**/env.client.develop.ts",
91
- "**/env.client.main.ts",
92
- "**/env.client.testing.ts",
93
- "**/env.server.local.ts",
94
- "**/env.server.develop.ts",
95
- "**/env.server.main.ts",
96
- "**/env.server.testing.ts",
97
- "**/env.server.type.ts",
98
88
  "apps/*/lib/cnst.ts",
99
89
  "apps/*/lib/dict.ts",
100
90
  "apps/*/lib/db.ts",
package/biomeBase.ts CHANGED
@@ -3,4 +3,4 @@ export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
3
3
 
4
4
  // Pinned: Biome moves rules between groups across minors and a stale position is a hard config error. Bump it with
5
5
  // `biome.base.json` in one commit, running `biome migrate --write` in the workspace root and in this package.
6
- export const biomeVersion = "2.5.12";
6
+ export const biomeVersion = "2.5.14";
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
+ });