@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19

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 (96) hide show
  1. package/abstractDoc.ts +5 -4
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +33 -6
  8. package/artifact/implicitRootLayout.test.ts +6 -11
  9. package/artifact/implicitRootLayout.ts +0 -15
  10. package/artifact/routeSeedIndex.test.ts +7 -6
  11. package/biome.base.json +6 -1
  12. package/capacitorApp.test.ts +28 -2
  13. package/capacitorApp.ts +25 -4
  14. package/cloud/constants.ts +23 -0
  15. package/cloud/globalConfig.ts +13 -0
  16. package/codeAgent/agent/AkanCodeServices.ts +38 -12
  17. package/codeAgent/agent/CodeAgent.ts +94 -30
  18. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  19. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  20. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  21. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  22. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  23. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  24. package/codeAgent/agent/akanCodeModel.ts +1 -1
  25. package/codeAgent/agent/akanCodePaths.ts +23 -3
  26. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  27. package/codeAgent/agent/akanSystemPrompt.ts +5 -0
  28. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  29. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  30. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  31. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  32. package/codeAgent/skills/akan-code/SKILL.md +122 -0
  33. package/codeAgent/skills/akan-module/SKILL.md +2 -2
  34. package/codeAgent/skills/akan-store/SKILL.md +6 -2
  35. package/codeAgent/tools/AkanCodePlugins.ts +19 -3
  36. package/codeAgent/tools/AskToolPack.ts +111 -0
  37. package/codeAgent/tools/McpClient.ts +51 -5
  38. package/codeAgent/tools/McpOAuth.ts +268 -0
  39. package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
  40. package/codeAgent/tools/McpServerConfig.ts +69 -34
  41. package/codeAgent/tools/McpSignIn.ts +161 -0
  42. package/codeAgent/tools/McpTokenStore.ts +92 -0
  43. package/codeAgent/tools/McpToolPack.ts +16 -3
  44. package/codeAgent/tools/askToolPack.test.ts +119 -0
  45. package/codeAgent/tools/mcpOAuth.test.ts +324 -0
  46. package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
  47. package/commandDecorators/command.ts +2 -0
  48. package/commandDecorators/commandDecorators.test.ts +1 -1
  49. package/commandDecorators/helpFormatter.ts +2 -1
  50. package/dependencyScanner.ts +4 -2
  51. package/devkitUtils.test.ts +1 -1
  52. package/executors.linkFallback.test.ts +103 -0
  53. package/executors.test.ts +18 -46
  54. package/executors.ts +31 -8
  55. package/frontendBuild/autoImportSync.test.ts +13 -0
  56. package/frontendBuild/autoImportSync.ts +19 -1
  57. package/frontendBuild/buildRouteClient.test.ts +52 -1
  58. package/frontendBuild/cssImportResolver.ts +1 -1
  59. package/frontendBuild/fontOptimizer.test.ts +1 -1
  60. package/frontendBuild/frontendBuild.test.ts +19 -17
  61. package/frontendBuild/hmrWatcher.test.ts +1 -1
  62. package/frontendBuild/routeClientBuilder.ts +27 -10
  63. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  64. package/getDirname.test.ts +4 -2
  65. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  66. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  67. package/index.ts +0 -1
  68. package/integration/devStabilityHarness.test.ts +7 -2
  69. package/lint/gritRules.test.ts +2 -1
  70. package/lint/no-deep-internal-import.grit +2 -2
  71. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  72. package/linter.ts +4 -1
  73. package/package.json +2 -2
  74. package/platformTest/BunTestReport.test.ts +60 -0
  75. package/platformTest/BunTestReport.ts +65 -0
  76. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  77. package/platformTest/IdleWatchedProcess.ts +77 -0
  78. package/platformTest/LinuxDockerTarget.ts +120 -0
  79. package/platformTest/PlatformTestRun.test.ts +68 -0
  80. package/platformTest/PlatformTestRun.ts +141 -0
  81. package/platformTest/PlatformTestTarget.ts +120 -0
  82. package/platformTest/TestSnapshot.test.ts +80 -0
  83. package/platformTest/TestSnapshot.ts +107 -0
  84. package/platformTest/WindowsSshTarget.ts +167 -0
  85. package/prompter.ts +0 -7
  86. package/qualityScanner.test.ts +3 -2
  87. package/qualityScanner.ts +1 -1
  88. package/routeSourceValidator.test.ts +0 -1
  89. package/routeSourceValidator.ts +0 -12
  90. package/scanInfo.ts +5 -2
  91. package/subspace.test.ts +5 -2
  92. package/subspace.ts +19 -8
  93. package/workflow/executor.ts +52 -0
  94. package/workflow/rolloutGate.test.ts +1 -1
  95. package/workflow/source.ts +19 -13
  96. package/guideline.ts +0 -19
package/abstractDoc.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import type { SysExecutor } from "./executors";
2
3
 
3
4
  export type AbstractKind = "domain" | "service" | "scalar" | "other";
@@ -5,10 +6,8 @@ export type AbstractKind = "domain" | "service" | "scalar" | "other";
5
6
  /** One `*.abstract.md` file: the agent-facing summary a module carries next to its source. */
6
7
  export class AbstractDoc {
7
8
  static readonly suffix = ".abstract.md";
8
- /** `akan quality` warns above this and points at `akan compact`. */
9
+ /** `akan quality` warns above this. */
9
10
  static readonly maxLines = 300;
10
- /** `akan compact` floor, and the line budget it compacts down to, so a compacted file is never a candidate again. */
11
- static readonly compactMinLines = 40;
12
11
  // Abstracts only live in facet folders; globbing the sys root instead would walk node_modules.
13
12
  static readonly #facetRoots = "{lib,ui,webkit,srvkit,common,plugin}";
14
13
 
@@ -27,7 +26,9 @@ export class AbstractDoc {
27
26
  return folder.startsWith("_") ? "service" : "domain";
28
27
  }
29
28
  static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
30
- const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`)).sort();
29
+ const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`))
30
+ .map((filePath) => filePath.split(path.sep).join("/"))
31
+ .sort();
31
32
  const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
32
33
  if (!module) return docs;
33
34
  return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
@@ -621,28 +621,37 @@ export class AkanAppHost {
621
621
  );
622
622
  }
623
623
  /**
624
- * Another process's RSS, read from the OS rather than asked of the process. `/proc` where it exists,
625
- * `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat as
626
- * "no new information" rather than as zero.
624
+ * Another process's RSS, read from the OS rather than asked of the process. `tasklist` on Windows, `/proc`
625
+ * where it exists, `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat
626
+ * as "no new information" rather than as zero.
627
627
  */
628
628
  static async readProcessRssBytes(pid: number): Promise<number | null> {
629
+ if (process.platform === "win32")
630
+ return await AkanAppHost.#readRssKbVia(
631
+ ["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
632
+ AkanAppHost.#tasklistRssKb,
633
+ );
629
634
  const status = await Bun.file(`/proc/${pid}/status`)
630
635
  .text()
631
636
  .catch(() => null);
632
637
  const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
633
638
  if (vmRssKb) return Number(vmRssKb) * 1024;
634
- return await AkanAppHost.#readRssViaPs(pid);
639
+ return await AkanAppHost.#readRssKbVia(["ps", "-o", "rss=", "-p", String(pid)], (output) => Number(output.trim()));
635
640
  }
636
641
  /**
637
- * `ps`, bounded. An absent `ps` is already handled — it answers `null`, which callers read as "no new
638
- * information" — but a `ps` that never answers was not: the only caller awaits it after a 20s settle,
642
+ * `ps` or `tasklist`, bounded. An absent tool is already handled — it answers `null`, which callers read as
643
+ * "no new information" — but a `ps` that never answers was not: the only caller awaits it after a 20s settle,
639
644
  * so the recycle it was about to commit simply never happened, silently. The harness has hit exactly
640
645
  * this hang while shelling out to `ps` under load.
641
646
  */
642
- static async #readRssViaPs(pid: number, timeoutMs = PS_RSS_TIMEOUT_MS): Promise<number | null> {
647
+ static async #readRssKbVia(
648
+ command: string[],
649
+ parseKb: (output: string) => number,
650
+ timeoutMs = PS_RSS_TIMEOUT_MS,
651
+ ): Promise<number | null> {
643
652
  let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
644
653
  try {
645
- proc = Bun.spawn(["ps", "-o", "rss=", "-p", String(pid)], { stdio: ["ignore", "pipe", "ignore"] });
654
+ proc = Bun.spawn(command, { stdio: ["ignore", "pipe", "ignore"] });
646
655
  } catch {
647
656
  return null;
648
657
  }
@@ -650,7 +659,7 @@ export class AkanAppHost {
650
659
  try {
651
660
  const output = await new Response(proc.stdout).text();
652
661
  await proc.exited;
653
- const rssKb = Number(output.trim());
662
+ const rssKb = parseKb(output);
654
663
  return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
655
664
  } catch {
656
665
  return null;
@@ -658,6 +667,11 @@ export class AkanAppHost {
658
667
  clearTimeout(killer);
659
668
  }
660
669
  }
670
+ /** `Mem Usage` is the working set — what `process.memoryUsage.rss()` reports on Windows — in locale-grouped KB. */
671
+ static #tasklistRssKb(output: string): number {
672
+ const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
673
+ return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
674
+ }
661
675
  #cancelRssRecycle(): void {
662
676
  this.#rssRecycleReason = null;
663
677
  this.#rssRecycleOver = null;
@@ -249,31 +249,65 @@ describe("AkanAppConfig", () => {
249
249
  ...akanPackageJson.dependencies,
250
250
  ...akanPackageJson.peerDependencies,
251
251
  };
252
- const singleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "single" }, baseDevEnv);
253
- const multipleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "multiple" }, baseDevEnv);
254
- const clusterConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "cluster" }, baseDevEnv);
255
-
256
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("ioredis");
257
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("bullmq");
258
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
259
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
260
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("protobufjs");
261
-
262
- expect(multipleConfig.getProductionPackageJson().dependencies).toMatchObject({
263
- "@libsql/client": runtimeDependencies["@libsql/client"],
252
+ const configOf = (database: { modes: ("single" | "multiple" | "cluster")[] }) =>
253
+ new AkanAppConfig(app, [], packageJson, { database }, baseDevEnv);
254
+ const dependenciesOf = (config: AkanAppConfig) => config.getProductionPackageJson().dependencies ?? {};
255
+ const singleConfig = configOf({ modes: ["single"] });
256
+ const multipleConfig = configOf({ modes: ["multiple"] });
257
+ const clusterConfig = configOf({ modes: ["cluster"] });
258
+ const edgeAndCloud = configOf({ modes: ["single", "cluster"] });
259
+
260
+ for (const driver of ["ioredis", "bullmq", "postgres", "@libsql/client", "protobufjs"])
261
+ expect(dependenciesOf(singleConfig)).not.toHaveProperty(driver);
262
+ expect(dependenciesOf(multipleConfig)).toMatchObject({
264
263
  bullmq: runtimeDependencies.bullmq,
265
264
  ioredis: runtimeDependencies.ioredis,
266
- protobufjs: runtimeDependencies.protobufjs,
267
265
  });
268
- expect(multipleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
269
-
270
- expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject({
266
+ expect(dependenciesOf(clusterConfig)).toMatchObject({
271
267
  bullmq: runtimeDependencies.bullmq,
272
268
  ioredis: runtimeDependencies.ioredis,
273
269
  postgres: runtimeDependencies.postgres,
274
- protobufjs: runtimeDependencies.protobufjs,
275
270
  });
276
- expect(clusterConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
271
+ // multiple opens the same SQLite as single and Redis replaced protobuf on the wire, so neither ships by default.
272
+ for (const config of [multipleConfig, clusterConfig])
273
+ for (const driver of ["@libsql/client", "protobufjs"]) expect(dependenciesOf(config)).not.toHaveProperty(driver);
274
+ expect(dependenciesOf(multipleConfig)).not.toHaveProperty("postgres");
275
+ // One image for an edge site and a cloud cluster carries both modes' drivers, and says which it carries.
276
+ expect(dependenciesOf(edgeAndCloud)).toMatchObject({ postgres: runtimeDependencies.postgres });
277
+ expect(edgeAndCloud.dockerfile).toContain("ENV AKAN_DATABASE_MODES=single,cluster");
278
+ expect(singleConfig.dockerfile).toContain("ENV AKAN_DATABASE_MODES=single");
279
+ });
280
+
281
+ test("runs single when the app declares no mode", () => {
282
+ expect(new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).database.modes).toEqual(["single"]);
283
+ });
284
+
285
+ test("refuses a mode name that is not one of the three, naming the file", () => {
286
+ expect(
287
+ () => new AkanAppConfig(app, [], packageJson, { database: { modes: ["clsuter" as "cluster"] } }, baseDevEnv),
288
+ ).toThrow("database.modes in apps/portal/akan.config.ts");
289
+ });
290
+
291
+ test("resolves a command's mode within the declared ones", () => {
292
+ const previous = process.env.AKAN_DATABASE_MODE;
293
+ try {
294
+ const config = new AkanAppConfig(
295
+ app,
296
+ [],
297
+ packageJson,
298
+ { database: { modes: ["single", "cluster"] } },
299
+ baseDevEnv,
300
+ );
301
+ delete process.env.AKAN_DATABASE_MODE;
302
+ expect(config.resolveDatabaseMode()).toBe("single");
303
+ process.env.AKAN_DATABASE_MODE = "cluster";
304
+ expect(config.resolveDatabaseMode()).toBe("cluster");
305
+ process.env.AKAN_DATABASE_MODE = "multiple";
306
+ expect(() => config.resolveDatabaseMode()).toThrow('Add "multiple" to database.modes');
307
+ } finally {
308
+ if (previous === undefined) delete process.env.AKAN_DATABASE_MODE;
309
+ else process.env.AKAN_DATABASE_MODE = previous;
310
+ }
277
311
  });
278
312
 
279
313
  test("resolves database mode runtime packages and missing install specs", () => {
@@ -300,20 +334,11 @@ describe("AkanAppConfig", () => {
300
334
  );
301
335
 
302
336
  expect(config.getDatabaseModeRuntimePackages("single")).toEqual([]);
303
- expect(config.getDatabaseModeRuntimePackages("multiple")).toEqual([
304
- "@libsql/client",
305
- "bullmq",
306
- "ioredis",
307
- "protobufjs",
308
- ]);
309
- expect(config.getDatabaseModeRuntimePackages("cluster")).toEqual(["bullmq", "ioredis", "postgres", "protobufjs"]);
310
- expect(config.getMissingDatabaseModeDependencySpecs("multiple")).toEqual([
311
- `@libsql/client@${runtimeDependencies["@libsql/client"]}`,
312
- `protobufjs@${runtimeDependencies.protobufjs}`,
313
- ]);
337
+ expect(config.getDatabaseModeRuntimePackages("multiple")).toEqual(["bullmq", "ioredis"]);
338
+ expect(config.getDatabaseModeRuntimePackages("cluster")).toEqual(["bullmq", "ioredis", "postgres"]);
339
+ expect(config.getMissingDatabaseModeDependencySpecs("multiple")).toEqual([]);
314
340
  expect(config.getMissingDatabaseModeDependencySpecs("cluster")).toEqual([
315
341
  `postgres@${runtimeDependencies.postgres}`,
316
- `protobufjs@${runtimeDependencies.protobufjs}`,
317
342
  ]);
318
343
  // The workspace-root install covers the toolchain/runtime plus every app Capacitor plugin
319
344
  // (deduped — "@capacitor/core" appears in both source lists).
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { AkanPlugin } from "akanjs";
5
- import { normalizeRoutePrefix } from "akanjs/base";
5
+ import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
6
6
  import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
7
7
  import type { AkanImageConfig } from "akanjs/server";
8
8
  import type { App, Lib } from "../commandDecorators";
@@ -11,6 +11,7 @@ import type { BaseDevEnv, PackageJson } from "../types";
11
11
  import {
12
12
  type AkanApiConfig,
13
13
  type AkanAssetsConfig,
14
+ type AkanDatabaseConfig,
14
15
  type AkanMobileConfig,
15
16
  type AkanMobileTargetConfig,
16
17
  type AkanRouteConfig,
@@ -93,16 +94,6 @@ const MOBILE_APP_CAPACITOR_PLUGINS = [
93
94
  "@capacitor/push-notifications",
94
95
  "capacitor-plugin-safe-area",
95
96
  ] as const;
96
- const DATABASE_MODE_RUNTIME_PACKAGES = {
97
- single: [],
98
- multiple: ["@libsql/client", "bullmq", "ioredis", "protobufjs"],
99
- cluster: ["bullmq", "ioredis", "postgres", "protobufjs"],
100
- } satisfies Record<DatabaseMode, readonly string[]>;
101
- const AKAN_RUNTIME_PACKAGES = new Set<string>([
102
- ...SSR_RUNTIME_PACKAGES,
103
- ...MOBILE_RUNTIME_PACKAGES,
104
- ...Object.values(DATABASE_MODE_RUNTIME_PACKAGES).flat(),
105
- ]);
106
97
  const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
107
98
  deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
108
99
  imageSizes: [32, 48, 64, 96, 128, 256, 384],
@@ -211,7 +202,7 @@ export class AkanAppConfig implements AppConfigResult {
211
202
  docker: DockerConfig;
212
203
  /** The Dockerfile `akan build` writes: the declared string verbatim, or one assembled from the parts. */
213
204
  dockerfile: string;
214
- defaultDatabaseMode: DatabaseMode;
205
+ database: AkanDatabaseConfig;
215
206
  web: AkanWebConfig;
216
207
  externalLibs: string[];
217
208
  barrelImports: string[];
@@ -250,7 +241,7 @@ export class AkanAppConfig implements AppConfigResult {
250
241
  this.baseDevEnv = baseDevEnv;
251
242
  this.plugins = plugins;
252
243
  this.#applyRoutes(config?.routes);
253
- this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
244
+ this.database = AkanAppConfig.#database(app, config);
254
245
  this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libContributions.externalLibs])];
255
246
  this.barrelImports = [
256
247
  ...DEFAULT_BARREL_IMPORTS,
@@ -458,6 +449,7 @@ ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
458
449
  ENV AKAN_PUBLIC_API_PREFIX=${this.api.prefix}
459
450
  ENV AKAN_PUBLIC_WS_PREFIX=${this.api.websocketPrefix}
460
451
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
452
+ ENV AKAN_DATABASE_MODES=${this.database.modes.join(",")}
461
453
  ENV AKAN_LOG_TO_FILE=0
462
454
  ${webEnvLines}
463
455
  CMD [${command.map((c) => `"${c}"`).join(",")}]`;
@@ -526,14 +518,31 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
526
518
  return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
527
519
  }
528
520
  #getProductionRuntimePackages() {
529
- return [...this.externalLibs, ...SSR_RUNTIME_PACKAGES, ...this.getDatabaseModeRuntimePackages()];
521
+ return [
522
+ ...this.externalLibs,
523
+ ...SSR_RUNTIME_PACKAGES,
524
+ ...this.database.modes.flatMap((mode) => this.getDatabaseModeRuntimePackages(mode)),
525
+ ];
530
526
  }
531
- getDatabaseModeRuntimePackages(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
532
- return [...DATABASE_MODE_RUNTIME_PACKAGES[databaseMode]];
527
+ getDatabaseModeRuntimePackages(databaseMode: DatabaseMode) {
528
+ return [...DatabaseModes.drivers[databaseMode]];
533
529
  }
534
- getMissingDatabaseModeDependencySpecs(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
530
+ getMissingDatabaseModeDependencySpecs(databaseMode: DatabaseMode) {
535
531
  return this.#getMissingDependencySpecs(this.getDatabaseModeRuntimePackages(databaseMode));
536
532
  }
533
+ /** The mode a CLI command runs this app in: the shell's `AKAN_DATABASE_MODE` if the app declares it, else the first. */
534
+ resolveDatabaseMode() {
535
+ return DatabaseModes.resolve({
536
+ requested: process.env.AKAN_DATABASE_MODE,
537
+ declared: this.database.modes.join(","),
538
+ local: true,
539
+ });
540
+ }
541
+ static #database(app: App, config: AppConfigDeclaration): AkanDatabaseConfig {
542
+ const declared = config?.database?.modes?.filter((mode): mode is DatabaseMode => !!mode);
543
+ const modes = declared?.length ? declared : ["single"];
544
+ return { modes: DatabaseModes.parseList(modes.join(","), `database.modes in apps/${app.name}/akan.config.ts`) };
545
+ }
537
546
  getMobileRuntimePackages() {
538
547
  // The app Capacitor plugins are installed at the workspace root too, so bun can resolve the
539
548
  // app's "*" declarations to a hoisted, peer-pinned version instead of fetching latest.
@@ -558,6 +567,9 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
558
567
  return `${lib}@${version}`;
559
568
  });
560
569
  }
570
+ get akanVersion() {
571
+ return getAkanPackageJson().version;
572
+ }
561
573
  getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
562
574
  return {
563
575
  name: this.app.name,
@@ -2,6 +2,7 @@ export type {
2
2
  AkanApiConfig,
3
3
  AkanAssetsConfig,
4
4
  AkanConfigFile,
5
+ AkanDatabaseConfig,
5
6
  AkanExecutor,
6
7
  AkanMobileConfig,
7
8
  AkanMobileTargetConfig,
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
3
5
  import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
4
6
 
5
7
  describe("ApplicationBuildRunner", () => {
@@ -16,8 +18,8 @@ describe("ApplicationBuildRunner", () => {
16
18
  });
17
19
 
18
20
  test("a bundler that mangles identifiers loses the class name a Logger reads", async () => {
19
- const dir = `${process.env.TMPDIR ?? "/tmp"}/akan-minify-${Bun.randomUUIDv7()}`;
20
- const entry = `${dir}/entry.ts`;
21
+ const dir = path.join(os.tmpdir(), `akan-minify-${Bun.randomUUIDv7()}`);
22
+ const entry = path.join(dir, "entry.ts");
21
23
  await Bun.write(
22
24
  entry,
23
25
  `const serve = () => class Service { readonly name = this.constructor.name; };
@@ -26,7 +28,7 @@ console.info(new SampleService().name);
26
28
  `,
27
29
  );
28
30
  const build = async (minify: Bun.BuildConfig["minify"]) => {
29
- const result = await Bun.build({ entrypoints: [entry], target: "bun", minify, outdir: `${dir}/out` });
31
+ const result = await Bun.build({ entrypoints: [entry], target: "bun", minify, outdir: path.join(dir, "out") });
30
32
  expect(result.success).toBe(true);
31
33
  const proc = Bun.spawn([process.execPath, result.outputs[0].path], { stdout: "pipe" });
32
34
  return (await new Response(proc.stdout).text()).trim();
@@ -191,9 +191,29 @@ export class ApplicationBuildRunner {
191
191
  await Promise.all([
192
192
  this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
193
193
  this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
194
+ this.#app.dist.writeJson("akan.build.json", {
195
+ buildId: await this.#resolveBuildId(),
196
+ akanVersion: akanConfig.akanVersion,
197
+ builtAt: new Date().toISOString(),
198
+ }),
194
199
  ]);
195
200
  }
196
201
 
202
+ //* Read back at runtime as `buildId` on the ops channel; a deployment's own AKAN_BUILD_ID still wins over it there.
203
+ async #resolveBuildId() {
204
+ const fromEnv = process.env.AKAN_BUILD_ID?.trim();
205
+ if (fromEnv) return fromEnv;
206
+ const root = this.#app.workspace.workspaceRoot;
207
+ const sha = Bun.spawnSync(["git", "rev-parse", "--short=12", "HEAD"], { cwd: root, stderr: "ignore" });
208
+ if (sha.exitCode !== 0) return null;
209
+ const dirty = Bun.spawnSync(["git", "status", "--porcelain", "--untracked-files=no"], {
210
+ cwd: root,
211
+ stderr: "ignore",
212
+ });
213
+ const suffix = dirty.exitCode === 0 && dirty.stdout.toString().trim() ? "-dirty" : "";
214
+ return `${sha.stdout.toString().trim()}${suffix}`;
215
+ }
216
+
197
217
  async #buildBackend() {
198
218
  const akanConfig = await this.#app.getConfig();
199
219
  const backendExternals = [
@@ -204,15 +224,21 @@ export class ApplicationBuildRunner {
204
224
  for (const entrypoint of backendEntryPoints) {
205
225
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
206
226
  }
207
- const backendResult = await this.#buildOrThrow("backend", {
208
- entrypoints: backendEntryPoints,
227
+ const backendConfig = {
209
228
  outdir: this.#app.dist.cwdPath,
210
229
  target: "bun",
211
230
  minify: AKAN_BACKEND_MINIFY,
212
231
  naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
213
232
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
214
233
  plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
215
- });
234
+ } satisfies Omit<Bun.BuildConfig, "entrypoints">;
235
+ //* Built apart so main.js keeps its own module copies; splitting moves lazy vendor `import()`s out of the boot
236
+ //* parse (minimal: server.js import 79ms → 24ms, 59MB → 36MB RSS).
237
+ const [mainResult, serverResult] = [
238
+ await this.#buildOrThrow("backend", { ...backendConfig, entrypoints: [backendEntryPoints[0]] }),
239
+ await this.#buildOrThrow("backend", { ...backendConfig, entrypoints: [backendEntryPoints[1]], splitting: true }),
240
+ ];
241
+ const backendResult = { outputs: [...mainResult.outputs, ...serverResult.outputs] };
216
242
  // Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
217
243
  const rscWorkerResult = web.ssr
218
244
  ? await this.#buildOrThrow("rsc-worker", {
@@ -246,11 +272,12 @@ export class ApplicationBuildRunner {
246
272
  async #writeConsoleShim() {
247
273
  await Bun.write(
248
274
  path.join(this.#app.dist.cwdPath, "console.js"),
249
- `import { cnst, db, dict, option, server, sig, srv } from "./server.js";
250
- import { assertAkanConsoleAllowed, startAkanConsole } from "./console-runtime.js";
275
+ `process.env.AKAN_COMMAND_TYPE = "console";
276
+ const { cnst, db, dict, option, server, sig, srv } = await import("./server.js");
277
+ const { assertAkanConsoleAllowed, startAkanConsole } = await import("./console-runtime.js");
251
278
 
252
279
  const run = async () => {
253
- assertAkanConsoleAllowed(server.env);
280
+ assertAkanConsoleAllowed();
254
281
  await server.start({ listen: false, web: false });
255
282
  try {
256
283
  await startAkanConsole(server, { globals: { cnst, db, dict, option, sig, srv } });
@@ -31,11 +31,11 @@ describe("resolveSsrPageEntries", () => {
31
31
  await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
32
32
  await write(
33
33
  rootLayoutPath,
34
- 'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\nexport const metadata = { title: "Root" };\n',
34
+ 'export const theme = "dark";\nexport const fonts = [{ name: "pretendard" }];\nexport const head = null;\n',
35
35
  );
36
36
  await write(
37
37
  groupedLayoutPath,
38
- 'export function generateMetadata() { return { title: "Home" }; }\nexport default function Layout({ children }) { return children; }\n',
38
+ "export function generateHead() { return null; }\nexport default function Layout({ children }) { return children; }\n",
39
39
  );
40
40
 
41
41
  const entries = await resolveSsrPageEntries({
@@ -54,15 +54,10 @@ describe("resolveSsrPageEntries", () => {
54
54
  expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
55
55
  expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
56
56
  expect(generatedSource).not.toContain("<System.Provider");
57
- expect(generatedSource).toContain("export async function generateMetadata(props: PageProps)");
58
- expect(generatedSource).toContain("if (userLayout.generateMetadata) return userLayout.generateMetadata(props);");
59
- expect(generatedSource).toContain("if (userLayout.metadata !== undefined) return userLayout.metadata;");
60
- expect(generatedSource).toContain(
61
- "if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);",
62
- );
63
- expect(generatedSource).toContain("return inheritedLayout.metadata;");
64
- expect(generatedSource).not.toContain("Object.keys(userLayout.metadata)");
65
- expect(generatedSource).not.toContain("Object.keys(inheritedLayout.metadata)");
57
+ expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
58
+ expect(generatedSource).toContain("if (userLayout.generateHead) return userLayout.generateHead(props);");
59
+ expect(generatedSource).toContain("return inheritedLayout.head;");
60
+ expect(generatedSource).not.toContain("generateMetadata");
66
61
  expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
67
62
  expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
68
63
  expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
@@ -236,13 +236,6 @@ export async function generateHead(props: PageProps) {
236
236
  return inheritedLayout.head;
237
237
  }
238
238
 
239
- export async function generateMetadata(props: PageProps) {
240
- if (userLayout.generateMetadata) return userLayout.generateMetadata(props);
241
- if (userLayout.metadata !== undefined) return userLayout.metadata;
242
- if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);
243
- return inheritedLayout.metadata;
244
- }
245
-
246
239
  export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
247
240
  export const Error = userLayout.Error ?? inheritedLayout.Error;
248
241
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
@@ -258,7 +251,6 @@ ${layoutBinding} return (
258
251
  theme={userLayout.theme ?? inheritedLayout.theme}
259
252
  fonts={loadFonts(userFonts)}
260
253
  className={defaultFontClassName}
261
- gaTrackingId={userLayout.gaTrackingId ?? inheritedLayout.gaTrackingId}
262
254
  layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
263
255
  reconnect={userLayout.reconnect ?? inheritedLayout.reconnect ?? false}
264
256
  wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
@@ -280,13 +272,6 @@ export async function generateHead(props: PageProps) {
280
272
  return inheritedLayout.head;
281
273
  }
282
274
 
283
- export async function generateMetadata(props: PageProps) {
284
- if (userLayout.generateMetadata) return userLayout.generateMetadata(props);
285
- if (userLayout.metadata !== undefined) return userLayout.metadata;
286
- if (inheritedLayout.generateMetadata) return inheritedLayout.generateMetadata(props);
287
- return inheritedLayout.metadata;
288
- }
289
-
290
275
  export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
291
276
  export const Error = userLayout.Error ?? inheritedLayout.Error;
292
277
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
2
3
  import { computeRouteSeedIndex, serializeRouteSeedIndexForArtifact } from "./routeSeedIndex";
3
4
 
4
5
  describe("computeRouteSeedIndex", () => {
@@ -19,10 +20,10 @@ describe("computeRouteSeedIndex", () => {
19
20
  ]);
20
21
 
21
22
  const leaf = index.entries.find((entry) => entry.routeId === "/:lang/foo");
22
- expect(leaf?.seeds).toContain("/app/page/foo.tsx");
23
- expect(leaf?.seeds).not.toContain("/app/page/foo/_layout.tsx");
24
- expect(leaf?.seeds).toContain("/app/.akan/generated/root-layouts/__root_layout.tsx");
25
- expect(leaf?.seeds).toContain("/app/page/_layout.tsx");
23
+ expect(leaf?.seeds).toContain(path.resolve("/app/page/foo.tsx"));
24
+ expect(leaf?.seeds).not.toContain(path.resolve("/app/page/foo/_layout.tsx"));
25
+ expect(leaf?.seeds).toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
26
+ expect(leaf?.seeds).toContain(path.resolve("/app/page/_layout.tsx"));
26
27
 
27
28
  expect(() =>
28
29
  computeRouteSeedIndex([
@@ -42,8 +43,8 @@ describe("computeRouteSeedIndex", () => {
42
43
  { key: "./robots.txt.tsx", moduleAbsPath: "/app/page/robots.txt.tsx" },
43
44
  ]);
44
45
  const robots = index.entries.find((entry) => entry.routeId === "/robots.txt");
45
- expect(robots?.seeds).not.toContain("/app/.akan/generated/root-layouts/__root_layout.tsx");
46
- expect(robots?.seeds).not.toContain("/app/page/_layout.tsx");
46
+ expect(robots?.seeds).not.toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
47
+ expect(robots?.seeds).not.toContain(path.resolve("/app/page/_layout.tsx"));
47
48
  });
48
49
 
49
50
  test("serializes seed paths relative to the artifact directory", () => {
package/biome.base.json CHANGED
@@ -154,7 +154,12 @@
154
154
  ]
155
155
  },
156
156
  {
157
- "includes": ["{apps,libs}/**/page/**/*.ts", "{apps,libs}/**/page/**/*.tsx"],
157
+ "includes": [
158
+ "{apps,libs}/**/page/**/*.ts",
159
+ "{apps,libs}/**/page/**/*.tsx",
160
+ "{apps,libs}/**/*.Unit.tsx",
161
+ "{apps,libs}/**/*.View.tsx"
162
+ ],
158
163
  "plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
159
164
  },
160
165
  {
@@ -24,6 +24,7 @@ import {
24
24
  sanitizeIosNativeRunEnv,
25
25
  selectLocalDevHost,
26
26
  sortIosRunTargets,
27
+ toIosInfoPlistUsageDescriptions,
27
28
  writeRootCapacitorConfig,
28
29
  } from "./capacitorApp";
29
30
 
@@ -234,7 +235,7 @@ describe("iOS native run helpers", () => {
234
235
  expect(deviceCommand.xcodebuildArgs).toContain("id=device-1");
235
236
  expect(deviceCommand.xcodebuildArgs).toContain("App QA");
236
237
  expect(deviceCommand.appPath).toBe(
237
- "/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app",
238
+ path.join("/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app"),
238
239
  );
239
240
 
240
241
  const simulatorCommand = buildIosNativeRunCommand({
@@ -243,7 +244,7 @@ describe("iOS native run helpers", () => {
243
244
  });
244
245
  expect(simulatorCommand.xcodebuildArgs).toContain("platform=iOS Simulator,id=sim-1");
245
246
  expect(simulatorCommand.appPath).toBe(
246
- "/repo/apps/minimal/ios/DerivedData/sim-1/Build/Products/Debug-iphonesimulator/App.app",
247
+ path.join("/repo/apps/minimal/ios/DerivedData/sim-1/Build/Products/Debug-iphonesimulator/App.app"),
247
248
  );
248
249
  });
249
250
 
@@ -370,3 +371,28 @@ describe("raiseGradleMinSdkVersion", () => {
370
371
  expect(raiseGradleMinSdkVersion("minSdkVersion = 28", 26)).toBeNull();
371
372
  });
372
373
  });
374
+
375
+ describe("toIosInfoPlistUsageDescriptions", () => {
376
+ test("writes Apple's real Info.plist keys, not NS + the description name", () => {
377
+ expect(
378
+ toIosInfoPlistUsageDescriptions({
379
+ cameraUsageDescription: "camera",
380
+ photoAddUsageDescription: "add",
381
+ photoUsageDescription: "read",
382
+ locationAlwaysUsageDescription: "always",
383
+ }),
384
+ ).toEqual({
385
+ NSCameraUsageDescription: "camera",
386
+ NSPhotoLibraryAddUsageDescription: "add",
387
+ NSPhotoLibraryUsageDescription: "read",
388
+ NSLocationAlwaysAndWhenInUseUsageDescription: "always",
389
+ NSLocationAlwaysUsageDescription: "always",
390
+ });
391
+ });
392
+
393
+ test("keeps the NS prefix convention for a key it does not know", () => {
394
+ expect(toIosInfoPlistUsageDescriptions({ bluetoothAlwaysUsageDescription: "ble" })).toEqual({
395
+ NSBluetoothAlwaysUsageDescription: "ble",
396
+ });
397
+ });
398
+ });
package/capacitorApp.ts CHANGED
@@ -489,7 +489,7 @@ export function classifyIosRunFailure(log: string): IosRunFailureClassification
489
489
  kind: "unknown",
490
490
  title: "iOS native run failed.",
491
491
  detail:
492
- "Review the xcodebuild/devicectl output above. You can retry with --noAllowProvisioningUpdates to use the conservative path.",
492
+ "Review the xcodebuild/devicectl output above. You can retry with --no-allow-provisioning-updates to use the conservative path.",
493
493
  };
494
494
  }
495
495
 
@@ -597,6 +597,29 @@ export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROI
597
597
  return content.replace(/(minSdkVersion\s*=\s*)\d+/, `$1${floor}`);
598
598
  }
599
599
 
600
+ //* Apple's Info.plist keys do not follow the description names (photo → NSPhotoLibrary…), so each one is spelled out.
601
+ const iosUsageDescriptionKeys = {
602
+ cameraUsageDescription: ["NSCameraUsageDescription"],
603
+ photoAddUsageDescription: ["NSPhotoLibraryAddUsageDescription"],
604
+ photoUsageDescription: ["NSPhotoLibraryUsageDescription"],
605
+ contactsUsageDescription: ["NSContactsUsageDescription"],
606
+ //? iOS 11+ reads the AlwaysAndWhenInUse key; the Always key only serves iOS 10 and below.
607
+ locationAlwaysUsageDescription: ["NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationAlwaysUsageDescription"],
608
+ locationWhenInUseUsageDescription: ["NSLocationWhenInUseUsageDescription"],
609
+ microphoneUsageDescription: ["NSMicrophoneUsageDescription"],
610
+ speechRecognitionUsageDescription: ["NSSpeechRecognitionUsageDescription"],
611
+ } as const;
612
+
613
+ export const toIosInfoPlistUsageDescriptions = (descriptions: { [key: string]: string }) =>
614
+ Object.fromEntries(
615
+ Object.entries(descriptions).flatMap(([key, value]) => {
616
+ const plistKeys: readonly string[] = Object.hasOwn(iosUsageDescriptionKeys, key)
617
+ ? iosUsageDescriptionKeys[key as keyof typeof iosUsageDescriptionKeys]
618
+ : [`NS${capitalize(key)}`];
619
+ return plistKeys.map((plistKey) => [plistKey, value]);
620
+ }),
621
+ );
622
+
600
623
  const mergeAllowNavigation = (configured: unknown, localIp: string | undefined) => {
601
624
  const values = Array.isArray(configured)
602
625
  ? configured.filter((value): value is string => typeof value === "string")
@@ -1469,9 +1492,7 @@ export class CapacitorApp {
1469
1492
  if (next !== content) await editor.setContent(next).save();
1470
1493
  }
1471
1494
  async #setPermissionInIos(permissions: { [key: string]: string }) {
1472
- const updateNs = Object.fromEntries(
1473
- Object.entries(permissions).map(([key, value]) => [`NS${capitalize(key)}`, value]),
1474
- );
1495
+ const updateNs = toIosInfoPlistUsageDescriptions(permissions);
1475
1496
  await Promise.all([
1476
1497
  this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", updateNs),
1477
1498
  this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs),