@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.110

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 (187) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +34 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +21 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +30 -14
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +37 -2
  22. package/applicationBuildRunner.ts +52 -23
  23. package/artifact/implicitRootLayout.test.ts +70 -1
  24. package/artifact/implicitRootLayout.ts +41 -14
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +28 -1
  27. package/biomeBase.ts +1 -1
  28. package/capacitorApp.ts +10 -0
  29. package/cloud/globalConfig.ts +9 -2
  30. package/codegenLock.test.ts +137 -0
  31. package/codegenLock.ts +136 -0
  32. package/commandDecorators/argMeta.ts +9 -1
  33. package/commandDecorators/command.ts +94 -7
  34. package/commandDecorators/commandBuilder.ts +20 -5
  35. package/commandDecorators/commandDecorators.test.ts +115 -3
  36. package/commandDecorators/helpFormatter.ts +4 -0
  37. package/dependencyScanner.test.ts +99 -0
  38. package/dependencyScanner.ts +27 -24
  39. package/devkitUtils.test.ts +0 -104
  40. package/executors.test.ts +135 -7
  41. package/executors.ts +115 -25
  42. package/fileEditor.ts +19 -19
  43. package/fileSys.ts +19 -1
  44. package/formSetterScanner.test.ts +80 -0
  45. package/formSetterScanner.ts +92 -0
  46. package/frontendBuild/autoImportSync.test.ts +58 -0
  47. package/frontendBuild/autoImportSync.ts +7 -2
  48. package/frontendBuild/buildRouteClient.test.ts +47 -24
  49. package/frontendBuild/clientBuildTypes.ts +4 -0
  50. package/frontendBuild/clientEntriesBundler.ts +4 -1
  51. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  52. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  53. package/frontendBuild/cssCompiler.ts +63 -10
  54. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  55. package/frontendBuild/fontOptimizer.test.ts +75 -2
  56. package/frontendBuild/fontOptimizer.ts +98 -27
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +124 -10
  60. package/frontendBuild/hmrWatcher.ts +1 -1
  61. package/frontendBuild/index.ts +1 -1
  62. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  63. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  64. package/frontendBuild/routeClientBuilder.ts +12 -5
  65. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  66. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  67. package/frontendBuild/styleContract.ts +14 -20
  68. package/frontendBuild/themeValidator.ts +22 -17
  69. package/frontendBuild/vendorSpecifiers.ts +1 -0
  70. package/frontendBuild/watchRootResolver.test.ts +129 -0
  71. package/frontendBuild/watchRootResolver.ts +66 -1
  72. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  73. package/incrementalBuilder/devWatchBatch.ts +6 -2
  74. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  75. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  76. package/index.ts +0 -7
  77. package/integration/devStabilityHarness.ts +13 -19
  78. package/libSource.test.ts +112 -0
  79. package/libSource.ts +126 -0
  80. package/lint/__fixtures__/README.md +40 -0
  81. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  83. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  84. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  85. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  86. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  87. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  88. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  89. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  90. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  91. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  92. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  93. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  95. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  96. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  97. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  98. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  99. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  100. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  101. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  102. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  103. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  104. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  105. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  106. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  107. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  108. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  110. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  111. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  112. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  113. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  114. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  116. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  117. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  118. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  119. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  120. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  121. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  122. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  123. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  124. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  125. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  126. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  127. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  128. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  129. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  130. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  131. package/lint/gritRules.test.ts +178 -0
  132. package/lint/no-arbitrary-color.grit +1 -1
  133. package/lint/no-async-component-in-ui.grit +35 -0
  134. package/lint/no-bang-comment-in-client.grit +23 -10
  135. package/lint/no-daisyui-legacy-class.grit +26 -9
  136. package/lint/no-deprecated-log-level.grit +17 -0
  137. package/lint/no-init-fetch-in-client.grit +47 -0
  138. package/lint/no-inline-color.grit +10 -8
  139. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  140. package/lint/no-model-type-in-util-zone.grit +58 -0
  141. package/lint/no-raw-palette-class.grit +3 -3
  142. package/lint/no-unpublished-form-setter.grit +41 -0
  143. package/lint/non-scalar-props-restricted.grit +16 -7
  144. package/linter.test.ts +80 -0
  145. package/linter.ts +82 -13
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +80 -142
  149. package/qualityScanner.ts +89 -31
  150. package/recipeScanner.ts +4 -1
  151. package/routeSourceValidator.test.ts +63 -0
  152. package/routeSourceValidator.ts +123 -16
  153. package/scanInfo.ts +32 -26
  154. package/semver.test.ts +26 -0
  155. package/semver.ts +31 -0
  156. package/slicePlanner.test.ts +182 -0
  157. package/slicePlanner.ts +235 -0
  158. package/ssrScanner.test.ts +301 -0
  159. package/subspace.test.ts +418 -0
  160. package/subspace.ts +723 -0
  161. package/subspaceConfig.ts +76 -0
  162. package/transforms/asyncDefaultExportDetector.ts +105 -0
  163. package/transforms/barrelImportsPlugin.ts +1 -1
  164. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  165. package/transforms/transforms.test.ts +5 -5
  166. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  167. package/tsconfig.json +6 -2
  168. package/typeChecker.ts +1 -1
  169. package/types.ts +1 -0
  170. package/uploadRelease.ts +2 -2
  171. package/workflow/executor.test.ts +146 -0
  172. package/workspaceLayout.test.ts +56 -4
  173. package/workspaceLayout.ts +48 -2
  174. package/builder.ts +0 -164
  175. package/extractDeps.ts +0 -86
  176. package/frontendBuild/styleGuard.test.ts +0 -165
  177. package/frontendBuild/styleGuard.ts +0 -322
  178. package/getCredentials.ts +0 -19
  179. package/getModelFileData.ts +0 -62
  180. package/mcpScanner.ts +0 -217
  181. package/src/capacitorApp.ts +0 -282
  182. package/storeScanner.ts +0 -173
  183. package/streamAi.ts +0 -45
  184. package/ui/MultiScrollList.tsx +0 -242
  185. package/ui/ScrollList.tsx +0 -107
  186. package/ui/index.ts +0 -2
  187. package/useStdoutDimensions.ts +0 -20
package/akanContext.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  workflowRunArtifactPath,
16
16
  workflowSyncDir,
17
17
  } from "./workflow";
18
- import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
18
+ import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
19
19
 
20
20
  export type AkanContextFormat = "json" | "markdown";
21
21
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -130,15 +130,29 @@ export type CursorMcpConfig = {
130
130
  mcpServers?: Record<string, unknown>;
131
131
  };
132
132
 
133
- export const resourceList = [
133
+ export const guidelineResourceUri = (name: string) => `akan://guidelines/${name}`;
134
+
135
+ /**
136
+ * Resources that exist whatever the workspace holds. The guideline entries are **not** here: they are
137
+ * one per directory under `cli/guidelines/`, and a hardcoded pair listed 2 of 31 while `readResource`
138
+ * threw on the other 29. `buildResourceList` is the only place the two halves meet.
139
+ */
140
+ const staticResourceList = [
134
141
  { uri: "akan://docs/framework", name: "Akan framework guide", mimeType: "text/markdown" },
135
- { uri: "akan://guidelines/framework", name: "Framework guideline", mimeType: "text/markdown" },
136
- { uri: "akan://guidelines/modelSignal", name: "Model signal guideline", mimeType: "text/markdown" },
137
142
  { uri: "akan://workspace/summary", name: "Workspace summary", mimeType: "application/json" },
138
143
  { uri: "akan://workspace/apps", name: "Workspace apps", mimeType: "application/json" },
139
144
  { uri: "akan://workspace/modules", name: "Workspace modules", mimeType: "application/json" },
140
145
  ];
141
146
 
147
+ export const buildResourceList = (guidelineNames: readonly string[]) => [
148
+ ...staticResourceList,
149
+ ...guidelineNames.map((name) => ({
150
+ uri: guidelineResourceUri(name),
151
+ name: `${name} guideline`,
152
+ mimeType: "text/markdown",
153
+ })),
154
+ ];
155
+
142
156
  export const cursorMcpConfigPath = ".cursor/mcp.json";
143
157
  // Claude Code reads project-scoped MCP servers from `.mcp.json` at the workspace root.
144
158
  export const claudeMcpConfigPath = ".mcp.json";
@@ -666,23 +680,25 @@ export class AkanContextAnalyzer {
666
680
  ),
667
681
  ];
668
682
 
669
- for (const app of context.apps) {
670
- const appPath = path.join(workspace.workspaceRoot, app.path);
671
- for (const entry of await safeReadDir(appPath)) {
672
- if (!isScannedAppRootEntry(entry.name)) continue;
673
- const allowed = entry.isDirectory() ? appRootAllowedDirs.has(entry.name) : appRootAllowedFiles.has(entry.name);
683
+ for (const sys of [...context.apps, ...context.libs]) {
684
+ const sysPath = path.join(workspace.workspaceRoot, sys.path);
685
+ for (const entry of await safeReadDir(sysPath)) {
686
+ if (!isScannedRootEntry(sys.type, entry.name)) continue;
687
+ const allowed = entry.isDirectory()
688
+ ? rootAllowedDirs[sys.type].has(entry.name)
689
+ : rootAllowedFiles[sys.type].has(entry.name);
674
690
  if (!allowed) {
675
691
  const action = repairAction(
676
692
  "module-shape",
677
- `akan repair module-shape --app ${app.name}`,
678
- "Review app root shape and remove or move the unknown entry.",
693
+ `akan repair module-shape --app ${sys.name}`,
694
+ `Review ${sys.type} root shape and remove or move the unknown entry.`,
679
695
  false,
680
696
  );
681
697
  diagnostics.push({
682
698
  severity: "error",
683
- code: "app-root-unknown-entry",
684
- path: `${app.path}/${entry.name}`,
685
- message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in app root: ${app.path}/${entry.name}`,
699
+ code: `${sys.type}-root-unknown-entry`,
700
+ path: `${sys.path}/${entry.name}`,
701
+ message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}`,
686
702
  repairActions: [action],
687
703
  });
688
704
  repairActions.push(action);
@@ -391,10 +391,31 @@ export const applyMcpTools: McpToolDefinition[] = [
391
391
  },
392
392
  ];
393
393
 
394
- export const listAkanMcpTools = (mode: "readonly" | "plan" | "apply" = "readonly") => {
395
- if (mode === "readonly") return readonlyMcpTools;
396
- if (mode === "plan") return [...readonlyMcpTools, ...planMcpTools];
397
- return [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
394
+ /**
395
+ * `guidelineNames` fills the `get_guideline` enum. Without it the argument is a bare string and a model
396
+ * has to guess one of the ~30 directory names, which it cannot see from here — the names are a
397
+ * filesystem listing, so they can only arrive from a caller that already read it.
398
+ */
399
+ export const listAkanMcpTools = (
400
+ mode: "readonly" | "plan" | "apply" = "readonly",
401
+ { guidelineNames = [] }: { guidelineNames?: readonly string[] } = {},
402
+ ) => {
403
+ const tools =
404
+ mode === "readonly"
405
+ ? readonlyMcpTools
406
+ : mode === "plan"
407
+ ? [...readonlyMcpTools, ...planMcpTools]
408
+ : [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
409
+ if (guidelineNames.length === 0) return tools;
410
+ return tools.map((tool) =>
411
+ tool.name === "get_guideline"
412
+ ? {
413
+ ...tool,
414
+ description: `${tool.description ?? ""} One of: ${guidelineNames.join(", ")}.`.trim(),
415
+ inputSchema: { ...tool.inputSchema, properties: { name: { type: "string", enum: [...guidelineNames] } } },
416
+ }
417
+ : tool,
418
+ );
398
419
  };
399
420
 
400
421
  export const createAkanValidationContract = (listTools = listAkanMcpTools) => ({
@@ -0,0 +1,28 @@
1
+ import path from "node:path";
2
+ import { FileSys } from "./fileSys";
3
+
4
+ interface StoredSelection {
5
+ apps: string[];
6
+ }
7
+
8
+ /**
9
+ * The apps the last interactive pick chose, so `akan start` + Enter repeats a multi-app session instead
10
+ * of re-ticking it. Advisory only: a missing, unreadable or stale file just means nothing is pre-ticked.
11
+ */
12
+ export class AppSelectionMemory {
13
+ static pathIn(workspaceRoot: string) {
14
+ return path.join(workspaceRoot, "local", ".akan", "lastStart.json");
15
+ }
16
+ static async read(workspaceRoot: string): Promise<string[]> {
17
+ const stored = await Bun.file(AppSelectionMemory.pathIn(workspaceRoot))
18
+ .json()
19
+ .catch(() => null);
20
+ const apps = (stored as StoredSelection | null)?.apps;
21
+ return Array.isArray(apps) ? apps.filter((name): name is string => typeof name === "string") : [];
22
+ }
23
+ static async write(workspaceRoot: string, apps: string[]) {
24
+ await FileSys.writeJson(AppSelectionMemory.pathIn(workspaceRoot), { apps } satisfies StoredSelection).catch(
25
+ () => undefined,
26
+ );
27
+ }
28
+ }
@@ -1,10 +1,45 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
2
+ import { rm } from "node:fs/promises";
3
+ import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
3
4
 
4
5
  describe("ApplicationBuildRunner", () => {
5
6
  test("externalizes Akan optional backend dependencies", () => {
6
7
  expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
7
- expect.arrayContaining(["@libsql/client", "bullmq", "croner", "ioredis", "postgres", "protobufjs"]),
8
+ expect.arrayContaining(["@libsql/client", "bullmq", "ioredis", "postgres", "protobufjs"]),
8
9
  );
9
10
  });
11
+
12
+ test("keeps backend identifiers so a class names its own logger and stack frames", () => {
13
+ expect(AKAN_BACKEND_MINIFY.identifiers).toBe(false);
14
+ expect(AKAN_BACKEND_MINIFY.whitespace).toBe(true);
15
+ expect(AKAN_BACKEND_MINIFY.syntax).toBe(true);
16
+ });
17
+
18
+ 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
+ await Bun.write(
22
+ entry,
23
+ `const serve = () => class Service { readonly name = this.constructor.name; };
24
+ export class SampleService extends serve() {}
25
+ console.info(new SampleService().name);
26
+ `,
27
+ );
28
+ const build = async (minify: Bun.BuildConfig["minify"]) => {
29
+ const result = await Bun.build({ entrypoints: [entry], target: "bun", minify, outdir: `${dir}/out` });
30
+ expect(result.success).toBe(true);
31
+ const proc = Bun.spawn([process.execPath, result.outputs[0].path], { stdout: "pipe" });
32
+ return (await new Response(proc.stdout).text()).trim();
33
+ };
34
+ try {
35
+ expect(await build(AKAN_BACKEND_MINIFY)).toBe("SampleService");
36
+ expect(await build(true)).not.toBe("SampleService");
37
+ // Bun 1.4.2 accepts `keepNames` and ignores it; drop this expectation once it keeps the name.
38
+ expect(await build({ whitespace: true, syntax: true, identifiers: true, keepNames: true })).not.toBe(
39
+ "SampleService",
40
+ );
41
+ } finally {
42
+ await rm(dir, { recursive: true, force: true });
43
+ }
44
+ });
10
45
  });
@@ -7,7 +7,13 @@ import type {
7
7
  ApplicationBuildResult,
8
8
  } from "./applicationBuildReporter";
9
9
  import type { App } from "./commandDecorators";
10
- import { AllRoutesBuilder, CsrArtifactBuilder, precompressArtifacts, SsrBaseArtifactBuilder } from "./frontendBuild";
10
+ import {
11
+ AllRoutesBuilder,
12
+ CsrArtifactBuilder,
13
+ FontPruner,
14
+ precompressArtifacts,
15
+ SsrBaseArtifactBuilder,
16
+ } from "./frontendBuild";
11
17
  import { Spinner } from "./spinner";
12
18
 
13
19
  export interface TypecheckOptions {
@@ -15,7 +21,7 @@ export interface TypecheckOptions {
15
21
  incremental?: boolean;
16
22
  }
17
23
 
18
- export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "compress" | "metadata";
24
+ export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
19
25
 
20
26
  export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
21
27
  export type BuildResult = ApplicationBuildResult;
@@ -37,6 +43,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
37
43
  backend: "📦",
38
44
  ssr: "🧭",
39
45
  csr: "🎨",
46
+ assets: "✂️",
40
47
  compress: "🗜️",
41
48
  metadata: "📝",
42
49
  };
@@ -53,10 +60,14 @@ const SSR_RENDER_EXTERNALS = [
53
60
  "react-server-dom-webpack/client.browser",
54
61
  ] as const;
55
62
 
63
+ // Identifier mangling renames every class, and `this.constructor.name` is what names a service's logger, an
64
+ // `Exception`, a guard, and every frame of a stack trace — for ~2% of boot on server bytes nothing downloads.
65
+ // `minify.keepNames` typechecks and does nothing as of Bun 1.4.2.
66
+ export const AKAN_BACKEND_MINIFY = { whitespace: true, syntax: true, identifiers: false } as const;
67
+
56
68
  export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
57
69
  "@libsql/client",
58
70
  "bullmq",
59
- "croner",
60
71
  "ioredis",
61
72
  "postgres",
62
73
  "protobufjs",
@@ -78,6 +89,7 @@ export class ApplicationBuildRunner {
78
89
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
79
90
  // serial build is needed because of Bun.build is unstable for parallel build
80
91
  const phaseOptions = { spinner };
92
+ const { web, assets } = await this.#app.getConfig();
81
93
  await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"), undefined, {
82
94
  spinner,
83
95
  });
@@ -92,18 +104,30 @@ export class ApplicationBuildRunner {
92
104
  await this.#runPhase(
93
105
  "ssr",
94
106
  "Building SSR route artifacts",
95
- () => this.#buildSsr(),
107
+ async () => (web.ssr ? await this.#buildSsr() : null),
96
108
  (result) =>
97
109
  result
98
110
  ? `${result.allRoutes.manifest.routeIds.length} routes, ${result.allRoutes.manifest.knownEntries.length} entries`
99
- : "skipped",
111
+ : web.ssr
112
+ ? "skipped"
113
+ : "disabled by akan.config.ts web.ssr",
100
114
  phaseOptions,
101
115
  );
102
116
  await this.#runPhase(
103
117
  "csr",
104
118
  "Building CSR assets",
105
- () => this.#buildCsr(),
106
- (result) => result?.outputDir ?? "skipped",
119
+ async () => (web.csr ? await this.#buildCsr() : null),
120
+ (result) => result?.outputDir ?? (web.csr ? "skipped" : "disabled by akan.config.ts web.csr"),
121
+ phaseOptions,
122
+ );
123
+ await this.#runPhase(
124
+ "assets",
125
+ "Trimming unread static assets",
126
+ async () => (assets.pruneFonts ? await new FontPruner(this.#app, assets).prune() : null),
127
+ (result) =>
128
+ result
129
+ ? `${result.removed.length} font file(s) dropped, ${ApplicationBuildRunner.formatBytes(result.freedBytes)} freed; ${result.kept.length} kept`
130
+ : "disabled by akan.config.ts assets.pruneFonts",
107
131
  phaseOptions,
108
132
  );
109
133
  await this.#runPhase(
@@ -166,7 +190,7 @@ export class ApplicationBuildRunner {
166
190
  const akanConfig = await this.#app.getConfig();
167
191
  await Promise.all([
168
192
  this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
169
- this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.docker.content),
193
+ this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
170
194
  ]);
171
195
  }
172
196
 
@@ -175,6 +199,7 @@ export class ApplicationBuildRunner {
175
199
  const backendExternals = [
176
200
  ...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS, ...AKAN_OPTIONAL_BACKEND_EXTERNALS]),
177
201
  ];
202
+ const { web } = akanConfig;
178
203
  const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
179
204
  for (const entrypoint of backendEntryPoints) {
180
205
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
@@ -183,34 +208,38 @@ export class ApplicationBuildRunner {
183
208
  entrypoints: backendEntryPoints,
184
209
  outdir: this.#app.dist.cwdPath,
185
210
  target: "bun",
186
- minify: true,
187
- naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
188
- define: { "process.env.NODE_ENV": JSON.stringify("production") },
189
- plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
190
- });
191
- const rscWorkerResult = await this.#buildOrThrow("rsc-worker", {
192
- entrypoints: [this.#resolveRscWorkerBuildEntry()],
193
- outdir: this.#app.dist.cwdPath,
194
- target: "bun",
195
- minify: true,
211
+ minify: AKAN_BACKEND_MINIFY,
196
212
  naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
197
- conditions: ["react-server"],
198
- // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
199
213
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
200
214
  plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
201
215
  });
216
+ // Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
217
+ const rscWorkerResult = web.ssr
218
+ ? await this.#buildOrThrow("rsc-worker", {
219
+ entrypoints: [this.#resolveRscWorkerBuildEntry()],
220
+ outdir: this.#app.dist.cwdPath,
221
+ target: "bun",
222
+ minify: AKAN_BACKEND_MINIFY,
223
+ naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
224
+ conditions: ["react-server"],
225
+ // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
226
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
227
+ plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
228
+ })
229
+ : null;
202
230
  const consoleRuntimeResult = await this.#buildOrThrow("console-runtime", {
203
231
  entrypoints: [this.#resolveConsoleRuntimeBuildEntry()],
204
232
  outdir: this.#app.dist.cwdPath,
205
233
  target: "bun",
206
- minify: true,
234
+ minify: AKAN_BACKEND_MINIFY,
207
235
  naming: { entry: "console-runtime.[ext]", chunk: "chunk-[hash].[ext]" },
208
236
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
209
237
  });
210
238
  await this.#writeConsoleShim();
211
239
  return {
212
- entrypoints: backendEntryPoints.length + 2,
213
- outputs: backendResult.outputs.length + rscWorkerResult.outputs.length + consoleRuntimeResult.outputs.length + 1,
240
+ entrypoints: backendEntryPoints.length + (rscWorkerResult ? 2 : 1),
241
+ outputs:
242
+ backendResult.outputs.length + (rscWorkerResult?.outputs.length ?? 0) + consoleRuntimeResult.outputs.length + 1,
214
243
  };
215
244
  }
216
245
 
@@ -50,7 +50,9 @@ describe("resolveSsrPageEntries", () => {
50
50
  expect(groupedRoot?.seedAbsPaths).toContain(groupedLayoutPath);
51
51
 
52
52
  const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
53
- expect(generatedSource).toContain('import * as inheritedLayout from "../../../page/_layout.tsx";');
53
+ // Both user modules go through the route resolver, so a `rootLayout()` chain and the legacy exports read alike.
54
+ expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
55
+ expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
54
56
  expect(generatedSource).not.toContain("<System.Provider");
55
57
  expect(generatedSource).toContain("export async function generateMetadata(props: PageProps)");
56
58
  expect(generatedSource).toContain("if (userLayout.generateMetadata) return userLayout.generateMetadata(props);");
@@ -102,4 +104,71 @@ describe("resolveSsrPageEntries", () => {
102
104
  );
103
105
  expect(generatedDictMacro).toContain("export const allDictionary = getAllDictionary();");
104
106
  });
107
+
108
+ test("awaits an async grouped root layout in the CSR bundle instead of mounting it as JSX", async () => {
109
+ const appRoot = await makeTempRoot();
110
+ const pageRoot = path.join(appRoot, "page");
111
+
112
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
113
+ await write(
114
+ path.join(pageRoot, "(user)", "_layout.tsx"),
115
+ "export default async function Layout({ children }) { await Promise.resolve(); return <div>{children}</div>; }\n",
116
+ );
117
+ await write(
118
+ path.join(pageRoot, "(sign)", "_layout.tsx"),
119
+ "export default function Layout({ children }) { return children; }\n",
120
+ );
121
+
122
+ const entries = await resolveSsrPageEntries({
123
+ appCwdPath: appRoot,
124
+ appName: "demo",
125
+ pageKeys: ["./(user)/_layout.tsx", "./(user)/self/_index.tsx", "./(sign)/_layout.tsx", "./(sign)/signin.tsx"],
126
+ });
127
+
128
+ const userRoot = entries.find((entry) => entry.key === "./(user)/__root_layout.tsx");
129
+ const userSource = await Bun.file(userRoot?.moduleAbsPath ?? "").text();
130
+ expect(userSource).toContain("<System.Provider");
131
+ expect(userSource).toContain("export default async function GeneratedLayout(");
132
+ expect(userSource).toContain('process.env.AKAN_PUBLIC_RENDER_ENV === "csr"');
133
+ expect(userSource).toContain("? await UserLayout({ params, searchParams, children })");
134
+ expect(userSource).toContain(": <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;");
135
+ expect(userSource).toContain(" {layout}\n </System.Provider>");
136
+
137
+ const signRoot = entries.find((entry) => entry.key === "./(sign)/__root_layout.tsx");
138
+ const signSource = await Bun.file(signRoot?.moduleAbsPath ?? "").text();
139
+ expect(signSource).toContain("<System.Provider");
140
+ expect(signSource).toContain("export default function GeneratedLayout(");
141
+ expect(signSource).not.toContain("await UserLayout(");
142
+ expect(signSource).toContain(
143
+ " <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>",
144
+ );
145
+ });
146
+
147
+ test("returns the awaited node directly for an async nested root boundary", async () => {
148
+ const appRoot = await makeTempRoot();
149
+ const pageRoot = path.join(appRoot, "page");
150
+
151
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
152
+ await write(
153
+ path.join(pageRoot, "_layout.tsx"),
154
+ "export default function Layout({ children }) { return children; }\n",
155
+ );
156
+ await write(
157
+ path.join(pageRoot, "(home)", "_layout.tsx"),
158
+ "const Layout = async ({ children }) => children;\nexport default Layout;\n",
159
+ );
160
+
161
+ const entries = await resolveSsrPageEntries({
162
+ appCwdPath: appRoot,
163
+ appName: "demo",
164
+ pageKeys: ["./_layout.tsx", "./(home)/_layout.tsx", "./(home)/_index.tsx"],
165
+ });
166
+
167
+ const groupedRoot = entries.find((entry) => entry.key === "./(home)/__root_layout.tsx");
168
+ const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
169
+ expect(generatedSource).not.toContain("<System.Provider");
170
+ expect(generatedSource).toContain("export default async function GeneratedLayout(");
171
+ expect(generatedSource).toContain("? await UserLayout({ params, searchParams, children })");
172
+ expect(generatedSource).toContain(" return layout;\n}");
173
+ });
105
174
  });
@@ -1,6 +1,7 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { App } from "../commandDecorators";
4
+ import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
4
5
 
5
6
  export interface PageEntry {
6
7
  key: string;
@@ -55,7 +56,7 @@ interface RootBoundary {
55
56
  segments: string[];
56
57
  }
57
58
 
58
- function getRootBoundarySegments(key: string): string[] | null {
59
+ export function getRootBoundarySegments(key: string): string[] | null {
59
60
  const match = LAYOUT_KEY_RE.exec(key);
60
61
  if (!match) return null;
61
62
  const prefix = match[1]?.replace(/\/$/, "");
@@ -76,7 +77,7 @@ function implicitDictionaryMacroAbsPath(appCwdPath: string): string {
76
77
  return path.join(path.resolve(appCwdPath), IMPLICIT_DICT_DIR, "useDict.ts");
77
78
  }
78
79
 
79
- function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
80
+ export function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
80
81
  const firstVisibleIndex = segments.findIndex((segment) => !/^\(.+\)$/.test(segment));
81
82
  if (firstVisibleIndex === -1) return segments.length <= 1;
82
83
  if (segments.slice(firstVisibleIndex + 1).some((segment) => /^\(.+\)$/.test(segment))) return false;
@@ -184,16 +185,40 @@ async function writeGeneratedRootLayoutFile(opts: {
184
185
  const clientImport = opts.includeStInit
185
186
  ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
186
187
  : `import "@apps/${opts.appName}/client";\n`;
188
+ // Both user modules go through `resolveRouteModule`: a `rootLayout()` chain and the legacy named exports read
189
+ // the same afterwards, and this file never has to know which shape the app wrote.
190
+ const inheritedLabel = inheritedSourceAbsPath ? path.relative(opts.appCwdPath, inheritedSourceAbsPath) : "";
187
191
  const inheritedImport = inheritedSourceSpecifier
188
- ? `import * as inheritedLayout from ${JSON.stringify(inheritedSourceSpecifier)};\n`
189
- : "const inheritedLayout = {};\n";
192
+ ? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)}).module as LayoutModule;\n`
193
+ : "const inheritedLayout: LayoutModule = {};\n";
190
194
  const prefix = routePrefixForSegments(opts.boundary.segments);
195
+ const userLabel = opts.boundary.sourceAbsPath ? path.relative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
191
196
  const userImport = sourceSpecifier
192
- ? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
193
- : "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
197
+ ? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)}).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
198
+ : "const UserLayout = ({ children }: LayoutProps) => children;\nconst userLayout: LayoutModule = {};\n";
199
+ const isAsyncUserLayout = opts.boundary.sourceAbsPath
200
+ ? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
201
+ : false;
202
+ const userLayoutElement = "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>";
203
+ // React has no async client component, so the CSR bundle calls an async layout and awaits its node the way
204
+ // `RenderLayer` does for pages. The RSC render keeps the element: awaiting there would hold the shell behind
205
+ // the layout's own awaits instead of streaming it as its own Flight chunk.
206
+ const layoutSignature = isAsyncUserLayout
207
+ ? "export default async function GeneratedLayout"
208
+ : "export default function GeneratedLayout";
209
+ const layoutBinding = isAsyncUserLayout
210
+ ? ` const layout =
211
+ process.env.AKAN_PUBLIC_RENDER_ENV === "csr"
212
+ ? await UserLayout({ params, searchParams, children })
213
+ : ${userLayoutElement};
214
+ `
215
+ : "";
216
+ const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
217
+ const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
194
218
  const source = opts.includeSystemProvider
195
- ? `import type { LayoutProps, PageProps } from "akanjs/client";
196
- import { loadFonts } from "akanjs/client";
219
+ ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
220
+ import { loadFonts, resolveRouteModule } from "akanjs/client";
221
+ import type { ReactNode } from "react";
197
222
  import { System } from "akanjs/ui";
198
223
  import { env } from "@apps/${opts.appName}/env/env.client";
199
224
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
@@ -222,8 +247,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
222
247
  export const Error = userLayout.Error ?? inheritedLayout.Error;
223
248
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
224
249
 
225
- export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
226
- return (
250
+ ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
251
+ ${layoutBinding} return (
227
252
  <System.Provider
228
253
  of={GeneratedLayout as never}
229
254
  appName=${JSON.stringify(opts.appName)}
@@ -239,12 +264,14 @@ export default function GeneratedLayout({ children, params, searchParams }: Layo
239
264
  wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
240
265
  allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}
241
266
  >
242
- <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>
267
+ ${layoutChild}
243
268
  </System.Provider>
244
269
  );
245
270
  }
246
271
  `
247
- : `import type { LayoutProps, PageProps } from "akanjs/client";
272
+ : `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
273
+ import { resolveRouteModule } from "akanjs/client";
274
+ import type { ReactNode } from "react";
248
275
  ${inheritedImport}${userImport}
249
276
  export async function generateHead(props: PageProps) {
250
277
  if (userLayout.generateHead) return userLayout.generateHead(props);
@@ -264,8 +291,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
264
291
  export const Error = userLayout.Error ?? inheritedLayout.Error;
265
292
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
266
293
 
267
- export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
268
- return <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;
294
+ ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
295
+ ${layoutBinding} return ${layoutReturn};
269
296
  }
270
297
  `;
271
298
  await Bun.write(absPath, source);
@@ -1,3 +1,4 @@
1
+ import { describe, expect, test } from "bun:test";
1
2
  import { computeRouteSeedIndex, serializeRouteSeedIndexForArtifact } from "./routeSeedIndex";
2
3
 
3
4
  describe("computeRouteSeedIndex", () => {
package/biome.base.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
@@ -170,10 +170,37 @@
170
170
  "includes": ["{apps,libs}/**/*.store.ts"],
171
171
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
172
172
  },
173
+ {
174
+ "includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
175
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
176
+ },
173
177
  {
174
178
  "includes": ["{apps,libs}/**/*.signal.ts"],
175
179
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit"]
176
180
  },
181
+ {
182
+ "includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
183
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
184
+ },
185
+ {
186
+ "includes": ["{apps,libs}/**/ui/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
187
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-async-component-in-ui.grit"]
188
+ },
189
+ {
190
+ "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
191
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
192
+ },
193
+ {
194
+ "includes": [
195
+ "{apps,libs}/**/*.ts",
196
+ "{apps,libs}/**/*.tsx",
197
+ "!**/*.test.ts",
198
+ "!**/*.test.tsx",
199
+ "!**/*.spec.ts",
200
+ "!**/*.spec.tsx"
201
+ ],
202
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit"]
203
+ },
177
204
  {
178
205
  "includes": [
179
206
  "**/ui/**/*.ts",
package/biomeBase.ts CHANGED
@@ -6,4 +6,4 @@ export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
6
6
  // Biome disagrees with the shipped base config therefore fails to load it at all, which is why the version is
7
7
  // pinned here instead of resolved to latest at create time. Bump this and `biome.base.json` in one commit, and run
8
8
  // `biome migrate --write` in the workspace root and in `pkgs/@akanjs/devkit` so both configs move together.
9
- export const biomeVersion = "2.5.8";
9
+ export const biomeVersion = "2.5.12";
package/capacitorApp.ts CHANGED
@@ -123,6 +123,16 @@ interface MaterializeCapacitorConfigOptions {
123
123
  }
124
124
  type MobilePlatform = "ios" | "android";
125
125
 
126
+ /**
127
+ * `AppExecutor.spawn`'s own options plus the two the Capacitor config is written from before the spawn:
128
+ * the platform the command targets, and — for iOS — whether it targets a device or a simulator, which
129
+ * decide the `capacitor.config.json` that command reads.
130
+ */
131
+ type SpawnMobileOptions = Parameters<AppExecutor["spawn"]>[2] & {
132
+ platform?: MobilePlatform;
133
+ iosRunTargetKind?: IosRunTargetKind;
134
+ };
135
+
126
136
  export interface LocalDevHostResolution {
127
137
  host: string;
128
138
  source: "override" | "detected" | "loopback" | "platform";
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { chmod, mkdir } from "node:fs/promises";
2
2
  import dayjs from "dayjs";
3
3
  import { FileSys } from "../fileSys";
4
4
  import {
@@ -29,9 +29,16 @@ export class GlobalConfig {
29
29
  remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
30
30
  };
31
31
  }
32
+ /**
33
+ * This file holds the cloud jwt, a refresh token that does not expire, and the LLM api key, so it is
34
+ * written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
35
+ * and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
36
+ * existing world-readable file is tightened by the next write rather than left as it was found.
37
+ */
32
38
  static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
33
- await mkdir(basePath, { recursive: true });
39
+ await mkdir(basePath, { recursive: true, mode: 0o700 });
34
40
  await Bun.write(configPath, JSON.stringify(akanConfig, null, 2));
41
+ await chmod(configPath, 0o600);
35
42
  }
36
43
  static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
37
44
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();