@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0

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 (188) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -227,6 +227,7 @@ export class AkanAppConfig implements AppConfigResult {
227
227
  this.i18n = resolveAkanI18nConfig(config?.i18n);
228
228
  process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
229
229
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
230
+ process.env.AKAN_PUBLIC_LOCALE_PREFIX = this.i18n.localePrefix ?? "always";
230
231
  this.api = {
231
232
  prefix: normalizeRoutePrefix(config?.api?.prefix) ?? "/api",
232
233
  websocketPrefix: normalizeRoutePrefix(config?.api?.websocketPrefix) ?? "/ws",
@@ -468,6 +469,8 @@ export class AkanAppConfig implements AppConfigResult {
468
469
  "deepLinks",
469
470
  "updates",
470
471
  "devtools",
472
+ "csp",
473
+ "openUrls",
471
474
  "ios",
472
475
  "android",
473
476
  "desktop",
@@ -623,6 +626,7 @@ export class AkanAppConfig implements AppConfigResult {
623
626
  ...(this.basePaths.size ? { AKAN_PUBLIC_BASE_PATHS: [...this.basePaths].join(",") } : {}),
624
627
  AKAN_PUBLIC_DEFAULT_LOCALE: this.i18n.defaultLocale,
625
628
  AKAN_PUBLIC_LOCALES: this.i18n.locales.join(","),
629
+ ...(this.i18n.localePrefix === "as-needed" ? { AKAN_PUBLIC_LOCALE_PREFIX: this.i18n.localePrefix } : {}),
626
630
  AKAN_PUBLIC_API_PREFIX: this.api.prefix,
627
631
  AKAN_PUBLIC_WS_PREFIX: this.api.websocketPrefix,
628
632
  AKAN_PUBLIC_OPERATION_MODE: "cloud",
@@ -634,6 +638,11 @@ export class AkanAppConfig implements AppConfigResult {
634
638
  ...(this.web.csr ? {} : { AKAN_CSR: "false" }),
635
639
  };
636
640
  }
641
+ //? An image runs where the build cannot see, and AKAN_PUBLIC_ENV=local serves the dev routes to every caller there.
642
+ static readonly localImageGuard = [
643
+ "ARG AKAN_ALLOW_LOCAL_IMAGE",
644
+ `RUN if [ "$AKAN_ALLOW_LOCAL_IMAGE" != "1" ]; then echo "This build ran with AKAN_PUBLIC_ENV=local, which serves the dev routes to every caller. Build with the environment it deploys to, or pass --build-arg AKAN_ALLOW_LOCAL_IMAGE=1." >&2; exit 1; fi`,
645
+ ].join("\n");
637
646
  dockerfileFor(environment: string, { lockfile = true }: { lockfile?: boolean } = {}): string {
638
647
  return this.#makeDockerfile(environment, lockfile);
639
648
  }
@@ -643,8 +652,9 @@ export class AkanAppConfig implements AppConfigResult {
643
652
  const preRunScripts = this.#getDockerRunScripts(preRuns);
644
653
  const postRunScripts = this.#getDockerRunScripts(postRuns);
645
654
  const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
655
+ const isLocal = (environment ?? this.baseDevEnv.env) === "local";
646
656
  const envLines = Object.entries(this.getProductionEnv(environment)).map(([key, value]) => `ENV ${key}=${value}`);
647
- return `${imageScript}
657
+ return `${imageScript}${isLocal ? `\n${AkanAppConfig.localImageGuard}` : ""}
648
658
  RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
649
659
  RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
650
660
  ARG TARGETARCH
@@ -776,7 +786,15 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
776
786
  static get akanVersion() {
777
787
  return getAkanPackageJson().version;
778
788
  }
789
+ //? The image installs from the registry alone: a pin to a path or a workspace package resolves nowhere there.
790
+ static #registryPins(pins: Record<string, string> | undefined) {
791
+ if (!pins) return undefined;
792
+ const kept = Object.entries(pins).filter(([, spec]) => !/^(?:file:|link:|workspace:|portal:|[./~])/.test(spec));
793
+ return kept.length ? Object.fromEntries(kept) : undefined;
794
+ }
779
795
  getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
796
+ const overrides = AkanAppConfig.#registryPins(this.rootPackageJson.overrides);
797
+ const resolutions = AkanAppConfig.#registryPins(this.rootPackageJson.resolutions);
780
798
  return {
781
799
  name: this.app.name,
782
800
  description: this.app.name,
@@ -789,6 +807,8 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
789
807
  ]),
790
808
  ),
791
809
  ...(this.trustedDependencies.length ? { trustedDependencies: this.trustedDependencies } : {}),
810
+ ...(overrides ? { overrides } : {}),
811
+ ...(resolutions ? { resolutions } : {}),
792
812
  ...data,
793
813
  };
794
814
  }
@@ -70,6 +70,37 @@ describe("ProductionLockfile", () => {
70
70
  });
71
71
  });
72
72
 
73
+ test("keeps a version an override pinned outside its parent's range", async () => {
74
+ const overriddenLock = `{
75
+ "lockfileVersion": 1,
76
+ "configVersion": 1,
77
+ "workspaces": {
78
+ "": {
79
+ "name": "repo",
80
+ "dependencies": {
81
+ "loose-envify": "^1.4.0",
82
+ },
83
+ },
84
+ },
85
+ "overrides": {
86
+ "js-tokens": "2.0.0",
87
+ },
88
+ "packages": {
89
+ "js-tokens": ["js-tokens@2.0.0", "", {}, "sha512-kk4RjoztHFeAlCNPdTr/uF04oN5z53L18LJfADBQ7YHkO3iR2rP2ZwOH0n33r2FRw0psRmjTDEkOWPE//zmujg=="],
90
+
91
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
92
+ }
93
+ }
94
+ `;
95
+ await withWorkspace(overriddenLock, async (root) => {
96
+ const lock = await ProductionLockfile.prune(root, {
97
+ ...productionPackageJson,
98
+ overrides: { "js-tokens": "2.0.0" },
99
+ });
100
+ expect(lock).toContain('"js-tokens": ["js-tokens@2.0.0"');
101
+ });
102
+ });
103
+
73
104
  test("refuses a workspace without a bun.lock, so the build can fall back to an unlocked install", async () => {
74
105
  await withWorkspace(null, async (root) => {
75
106
  await expect(ProductionLockfile.prune(root, productionPackageJson)).rejects.toThrow(
@@ -13,9 +13,11 @@ export class ProductionLockfile {
13
13
  if (!(await workspaceLock.exists())) throw new Error(`the workspace root has no ${ProductionLockfile.fileName}`);
14
14
  const dir = await mkdtemp(path.join(os.tmpdir(), "akan-production-lock-"));
15
15
  try {
16
+ const npmrc = Bun.file(path.join(workspaceRoot, ".npmrc"));
16
17
  await Promise.all([
17
18
  Bun.write(path.join(dir, "package.json"), JSON.stringify(packageJson, null, 2)),
18
19
  Bun.write(path.join(dir, ProductionLockfile.fileName), workspaceLock),
20
+ ...((await npmrc.exists()) ? [Bun.write(path.join(dir, ".npmrc"), npmrc)] : []),
19
21
  ]);
20
22
  const install = Bun.spawn([process.execPath, "install", "--lockfile-only", "--no-progress"], {
21
23
  cwd: dir,
package/akanContext.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { capitalize, isRecord } from "akanjs/common";
3
+ import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
4
4
  import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
5
5
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
6
6
  import { FileSys } from "./fileSys";
@@ -48,12 +48,25 @@ export interface AkanModuleContext {
48
48
  files: string[];
49
49
  }
50
50
 
51
+ export interface AkanRouteContext {
52
+ /** The URL pattern without the `/:lang` segment every route starts with. */
53
+ path: string;
54
+ file: string;
55
+ search: string[];
56
+ /** The page or a layout above it calls `.guard()`. */
57
+ guarded: boolean;
58
+ devOnly: boolean;
59
+ }
60
+
51
61
  export interface AkanSysContext {
52
62
  type: "app" | "lib";
53
63
  name: string;
54
64
  path: string;
55
65
  hasConfig: boolean;
56
66
  modules: AkanModuleContext[];
67
+ /** Listed for the app `--app` names only; reading them parses every route file. */
68
+ routes?: AkanRouteContext[];
69
+ routeError?: string;
57
70
  }
58
71
 
59
72
  export interface AkanPackageContext {
@@ -280,6 +293,7 @@ const generatedFiles = [
280
293
  ];
281
294
 
282
295
  const validationCommands = [
296
+ "akan verify [<app-or-lib-or-pkg>]",
283
297
  "akan sync <app-or-lib>",
284
298
  "akan lint <app-or-lib-or-pkg>",
285
299
  "akan typecheck <app-or-lib>",
@@ -294,7 +308,7 @@ const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
294
308
  status: "unknown" as const,
295
309
  message: "Run sync before validation so generated Akan files match the current source conventions.",
296
310
  refreshCommand: "akan sync <app-or-lib>",
297
- verifyingCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>"],
311
+ verifyingCommands: ["akan verify <app-or-lib>"],
298
312
  };
299
313
 
300
314
  const repairAction = (
@@ -454,7 +468,7 @@ const generatedFreshnessFromStates = async (workspace: WorkspaceExecutor): Promi
454
468
  ? `Generated files were refreshed for ${lastFresh.target} at ${lastFresh.lastSyncedAt}.`
455
469
  : "Generated sync state exists, but the last recorded repair did not pass.",
456
470
  refreshCommand: "akan sync <app-or-lib>",
457
- verifyingCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>"],
471
+ verifyingCommands: ["akan verify <app-or-lib>"],
458
472
  targets,
459
473
  };
460
474
  };
@@ -556,6 +570,34 @@ const getSysModules = async (
556
570
  return modules.sort((a, b) => `${a.sysName}:${a.path}`.localeCompare(`${b.sysName}:${b.path}`));
557
571
  };
558
572
 
573
+ const getAppRoutes = async (app: AppExecutor, workspaceRoot: string): Promise<AkanRouteContext[]> => {
574
+ const sources = await app.getRouteSources();
575
+ const guardedLayouts = new Set<string>();
576
+ const pages: { source: (typeof sources)[number]; parsed: ReturnType<typeof parseRouteModuleKey> }[] = [];
577
+ for (const source of sources) {
578
+ const parsed = parseRouteModuleKey(source.key);
579
+ if (parsed.kind === "layout" && source.info.chain?.guarded) guardedLayouts.add(parsed.routeSegments.join("/"));
580
+ else if (parsed.kind === "page") pages.push({ source, parsed });
581
+ }
582
+ return pages
583
+ .map(({ source, parsed }) => {
584
+ const segments = parsed.routeSegments;
585
+ //? The layouts that wrap a page, as the route seed index walks them: an `_index` sits inside its own folder.
586
+ const reach = parsed.leaf === "_index" ? segments.length : segments.length - 1;
587
+ const underGuard = Array.from({ length: reach + 1 }, (_, idx) => segments.slice(0, idx).join("/")).some(
588
+ (prefix) => guardedLayouts.has(prefix),
589
+ );
590
+ return {
591
+ path: parsed.pattern.replace(/^\/:lang/, "") || "/",
592
+ file: path.relative(workspaceRoot, source.absPath).replaceAll(path.sep, "/"),
593
+ search: source.info.chain?.search ?? [],
594
+ guarded: !!source.info.chain?.guarded || underGuard,
595
+ devOnly: source.info.devOnly,
596
+ };
597
+ })
598
+ .sort((a, b) => a.path.localeCompare(b.path));
599
+ };
600
+
559
601
  const getSysContext = async (
560
602
  workspace: WorkspaceExecutor,
561
603
  type: "app" | "lib",
@@ -563,7 +605,7 @@ const getSysContext = async (
563
605
  options: AkanContextOptions,
564
606
  ): Promise<AkanSysContext> => {
565
607
  const sys = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
566
- return {
608
+ const context: AkanSysContext = {
567
609
  type,
568
610
  name,
569
611
  path: `${type}s/${name}`,
@@ -573,6 +615,12 @@ const getSysContext = async (
573
615
  module: options.module,
574
616
  }),
575
617
  };
618
+ if (sys instanceof AppExecutor && options.app === name)
619
+ await getAppRoutes(sys, workspace.workspaceRoot).then(
620
+ (routes) => (context.routes = routes),
621
+ (error: unknown) => (context.routeError = error instanceof Error ? error.message : String(error)),
622
+ );
623
+ return context;
576
624
  };
577
625
 
578
626
  export class AkanContextAnalyzer {
@@ -632,6 +680,42 @@ export class AkanContextAnalyzer {
632
680
  };
633
681
  }
634
682
 
683
+ //? Codex joins every AGENTS.md from the repo root down to its folder and drops what passes 32 KiB (`project_doc_max_bytes`).
684
+ static async #agentGuideOversize(workspace: WorkspaceExecutor, sysPaths: string[]) {
685
+ const sizeOf = async (file: string) => {
686
+ const content = await safeReadText(path.join(workspace.workspaceRoot, file));
687
+ return { file, bytes: content === null ? 0 : Buffer.byteLength(content) };
688
+ };
689
+ const root = await sizeOf("AGENTS.md");
690
+ if (root.bytes === 0) return null;
691
+ const scoped = await Promise.all(sysPaths.map((sysPath) => sizeOf(`${sysPath}/AGENTS.md`)));
692
+ const largest = scoped.reduce((max, entry) => (entry.bytes > max.bytes ? entry : max), { file: "", bytes: 0 });
693
+ const total = root.bytes + largest.bytes;
694
+ if (total <= 32 * 1024 * 0.8) return null;
695
+ const subject = largest.bytes ? `AGENTS.md and ${largest.file} add up to` : "AGENTS.md is";
696
+ return `${subject} ${Math.ceil(total / 1024)} KiB of the 32 KiB Codex reads — run \`akan agent install\`, then shorten the text outside the akan:agent markers or move it into a skill.`;
697
+ }
698
+
699
+ static async #agentGuideLeftovers(workspace: WorkspaceExecutor) {
700
+ const root = workspace.workspaceRoot;
701
+ const guide = (await safeReadText(path.join(root, "AGENTS.md"))) ?? "";
702
+ const leftovers: { path: string; message: string }[] = [];
703
+ for (const doc of ["docs/AI-DEVELOPMENT.md", "docs/GENERATED.md"]) {
704
+ const pointer = guide.includes(doc);
705
+ if ((await safeReadText(path.join(root, doc))) !== null)
706
+ leftovers.push({
707
+ path: doc,
708
+ message: `${doc} is an older template's copy of the agent guide, and nothing reads it now — delete it${pointer ? " and the AGENTS.md sentence that points to it" : ""}.`,
709
+ });
710
+ else if (pointer)
711
+ leftovers.push({
712
+ path: "AGENTS.md",
713
+ message: `AGENTS.md points to ${doc}, which is gone — delete that sentence.`,
714
+ });
715
+ }
716
+ return leftovers;
717
+ }
718
+
635
719
  //? Older templates wrote these for Next.js: nothing installs or reads them, and editors warn about the plugin.
636
720
  static async #configLeftovers(workspace: WorkspaceExecutor, sysPaths: string[]) {
637
721
  const leftovers: { path: string; message: string }[] = [];
@@ -724,6 +808,11 @@ export class AkanContextAnalyzer {
724
808
  repairAction("generated", "akan agent install agents-md", agentDrift.hint, true),
725
809
  );
726
810
  const sysPaths = [...context.apps, ...context.libs].map((sys) => sys.path);
811
+ const oversize = await AkanContextAnalyzer.#agentGuideOversize(workspace, sysPaths);
812
+ if (oversize)
813
+ diagnostics.push({ severity: "warning", code: "agent-guide-oversized", path: "AGENTS.md", message: oversize });
814
+ for (const leftover of await AkanContextAnalyzer.#agentGuideLeftovers(workspace))
815
+ diagnostics.push({ severity: "warning", code: "agent-guide-leftover", ...leftover });
727
816
  for (const leftover of await AkanContextAnalyzer.#configLeftovers(workspace, sysPaths))
728
817
  diagnostics.push({ severity: "warning", code: "config-leftover", ...leftover });
729
818
 
@@ -944,6 +1033,12 @@ export class AkanContextAnalyzer {
944
1033
  ...context.pkgs.map((pkg) => `- ${pkg.name}${pkg.version ? ` (${pkg.version})` : ""}`),
945
1034
  );
946
1035
 
1036
+ for (const app of context.apps) {
1037
+ if (app.routeError) lines.push("", `## Routes: ${app.name}`, `- Not read: ${app.routeError}`);
1038
+ else if (app.routes)
1039
+ lines.push("", `## Routes: ${app.name}`, ...AkanContextAnalyzer.#routeLines(app.name, app.routes));
1040
+ }
1041
+
947
1042
  const modules = AkanContextAnalyzer.findModules(context, moduleName);
948
1043
  lines.push("", "## Modules");
949
1044
  for (const module of modules) {
@@ -958,4 +1053,34 @@ export class AkanContextAnalyzer {
958
1053
  lines.push("", "## Validation", ...context.validationCommands.map((command) => `- \`${command}\``));
959
1054
  return `${lines.join("\n")}\n`;
960
1055
  }
1056
+
1057
+ static readonly routeListLimit = 60;
1058
+
1059
+ //? Past the limit the biggest top folders fold into one line each: a docs section alone would fill the context.
1060
+ static #routeLines(appName: string, routes: AkanRouteContext[]) {
1061
+ const folderOf = (route: AkanRouteContext) => route.file.split("/").slice(0, 4).join("/");
1062
+ const groups = Map.groupBy(routes, folderOf);
1063
+ const folded = new Set<string>();
1064
+ let listed = routes.length;
1065
+ for (const [folder, members] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
1066
+ if (listed <= AkanContextAnalyzer.routeListLimit) break;
1067
+ folded.add(folder);
1068
+ listed -= members.length;
1069
+ }
1070
+ const lines = [`Paths omit the \`/:lang\` prefix; \`akan preview ${appName} <path>\` opens one.`];
1071
+ for (const route of routes) {
1072
+ if (folded.has(folderOf(route))) continue;
1073
+ const search = route.search.length ? ` ?${route.search.join("&")}` : "";
1074
+ const marks = `${route.guarded ? " (guarded)" : ""}${route.devOnly ? " (dev only)" : ""}`;
1075
+ lines.push(`- ${route.path}${search}${marks} — ${route.file}`);
1076
+ }
1077
+ for (const folder of folded) {
1078
+ const members = groups.get(folder) ?? [];
1079
+ const examples = members.slice(0, 3).map((route) => route.path);
1080
+ lines.push(
1081
+ `- ${folder}/: ${members.length} routes folded (${examples.join(", ")}, …); \`--format json\` lists every route`,
1082
+ );
1083
+ }
1084
+ return lines;
1085
+ }
961
1086
  }
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { createAkanValidationContract } from "./akanMcpContract";
2
+ import { mkdir, symlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createAkanValidationContract, workflowArtifactPath } from "./akanMcpContract";
5
+ import { createTempApp, tempRoots } from "./testHelpers";
6
+
7
+ const track = tempRoots();
3
8
 
4
9
  describe("createAkanValidationContract", () => {
5
10
  test("exposes reference-first tooling rollout gate semantics", () => {
@@ -35,3 +40,25 @@ describe("createAkanValidationContract", () => {
35
40
  );
36
41
  });
37
42
  });
43
+
44
+ describe("workflowArtifactPath", () => {
45
+ test("keeps the plans and reports an MCP call names inside the workspace's workflow folder", async () => {
46
+ const { root, workspace } = track(await createTempApp("demo"));
47
+ const workflows = path.join(root, ".akan/workflows");
48
+ await mkdir(path.join(workflows, "plans"), { recursive: true });
49
+ await symlink(path.join(root, "package.json"), path.join(workflows, "plans/linked.json"));
50
+
51
+ expect(workflowArtifactPath(workspace, ".akan/workflows/plans/a.json")).toBe(path.join(workflows, "plans/a.json"));
52
+ expect(workflowArtifactPath(workspace, `${workflows}/runs/b.json`)).toBe(path.join(workflows, "runs/b.json"));
53
+ expect(workflowArtifactPath(workspace, "add-field-123")).toBe(path.join(workflows, "runs/add-field-123.json"));
54
+ for (const outside of [
55
+ "package.json",
56
+ `${root}/package.json`,
57
+ ".akan/workflows/../../package.json",
58
+ ".akan/workflows",
59
+ ".akan/workflows/plans/linked.json",
60
+ "/tmp/plan.json",
61
+ ])
62
+ expect(() => workflowArtifactPath(workspace, outside)).toThrow("MCP workflow files live under .akan/workflows/");
63
+ });
64
+ });
@@ -1,3 +1,4 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  import { isRecord } from "akanjs/common";
3
4
  import { AkanContextAnalyzer, type AkanDiagnostic, type AkanModuleContext } from "./akanContext";
@@ -127,8 +128,55 @@ const isInspectAkanContextRequestType = (value: unknown): value is InspectAkanCo
127
128
 
128
129
  export const parseJsonOutput = (output: string) => JSON.parse(output) as unknown;
129
130
 
130
- export const workspacePath = (workspace: Workspace, filePath: string) =>
131
- path.isAbsolute(filePath) ? filePath : path.join(workspace.workspaceRoot, filePath);
131
+ const workflowArtifactDir = ".akan/workflows";
132
+ const workflowRunIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
133
+
134
+ const isInside = (base: string, target: string) => {
135
+ const relative = path.relative(base, target);
136
+ return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
137
+ };
138
+
139
+ const hasEntry = (filePath: string) => {
140
+ try {
141
+ lstatSync(filePath);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+
148
+ const realPathOfNearest = (filePath: string) => {
149
+ const rest: string[] = [];
150
+ let current = filePath;
151
+ while (!hasEntry(current) && path.dirname(current) !== current) {
152
+ rest.unshift(path.basename(current));
153
+ current = path.dirname(current);
154
+ }
155
+ return path.join(realpathSync(current), ...rest);
156
+ };
157
+
158
+ const staysInside = (workspaceRoot: string, root: string, resolved: string) => {
159
+ try {
160
+ const realRoot = realPathOfNearest(root);
161
+ return isInside(realPathOfNearest(workspaceRoot), realRoot) && isInside(realRoot, realPathOfNearest(resolved));
162
+ } catch {
163
+ return false;
164
+ }
165
+ };
166
+
167
+ //? An MCP caller acts on text it was shown, so the plans and reports it names stay in the workspace's workflow folder.
168
+ export const workflowArtifactPath = (workspace: Workspace, filePath: string) => {
169
+ const root = path.resolve(workspace.workspaceRoot, workflowArtifactDir);
170
+ const resolved =
171
+ workflowRunIdPattern.test(filePath) && !filePath.endsWith(".json")
172
+ ? path.join(root, "runs", `${filePath}.json`)
173
+ : path.resolve(workspace.workspaceRoot, filePath);
174
+ if (!isInside(root, resolved) || !staysInside(workspace.workspaceRoot, root, resolved))
175
+ throw new Error(
176
+ `MCP workflow files live under ${workflowArtifactDir}/ of the workspace, not ${JSON.stringify(filePath)}.`,
177
+ );
178
+ return resolved;
179
+ };
132
180
 
133
181
  export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanInputs) => {
134
182
  const slug = [
@@ -490,7 +538,7 @@ export const inspectAkanContext = async (
490
538
  if (props.request.type === "workflowDiagnostics") {
491
539
  const doctor = await AkanContextAnalyzer.doctor(workspace, {
492
540
  strict: true,
493
- runIdOrPlan: workspacePath(workspace, props.request.runIdOrPlan),
541
+ runIdOrPlan: workflowArtifactPath(workspace, props.request.runIdOrPlan),
494
542
  });
495
543
  const toWorkflowDiagnostic = (
496
544
  { severity, code, message, scope }: AkanDiagnostic,
@@ -9,6 +9,7 @@ import type { App } from "./commandDecorators";
9
9
  import { AppExecutor, Executor } from "./executors";
10
10
  import { bundleDefine } from "./frontendBuild/bundleDefine";
11
11
  import { createTempApp } from "./testHelpers";
12
+ import { ProjectTypecheck } from "./typecheck/projectTypecheck";
12
13
 
13
14
  describe("ApplicationBuildRunner", () => {
14
15
  test("externalizes Akan optional backend dependencies", () => {
@@ -83,6 +84,42 @@ console.info(new SampleService().name);
83
84
  }
84
85
  });
85
86
 
87
+ test("writes beside main.js the env its Dockerfile sets, so a host without the image runs the build as is", async () => {
88
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
89
+ const config = new AkanAppConfig(
90
+ { name: "portal" } as never,
91
+ [],
92
+ { name: "repo", version: "1.0.0", description: "repo" },
93
+ {},
94
+ { repoName: "akanjs", serveDomain: "akanjs.com", env: "local", portOffset: 0, workspaceRoot: root },
95
+ );
96
+ const app = {
97
+ getConfig: async () => config,
98
+ dist: new Executor("dist", root),
99
+ workspace: { workspaceRoot: root },
100
+ logger: { warn: () => undefined },
101
+ } as unknown as App;
102
+ const builtEnv = async () => JSON.parse(await readFile(path.join(root, "akan.env.json"), "utf8")) as object;
103
+ const dockerfileEnv = async () =>
104
+ Object.fromEntries(
105
+ (await readFile(path.join(root, "Dockerfile"), "utf8"))
106
+ .split("\n")
107
+ .filter((line) => line.startsWith("ENV "))
108
+ .map((line) => line.slice("ENV ".length).split(/=(.*)/s).slice(0, 2)),
109
+ );
110
+ try {
111
+ await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
112
+ expect(await builtEnv()).toEqual(config.getProductionEnv("debug"));
113
+ expect(await builtEnv()).toEqual(await dockerfileEnv());
114
+ expect(await builtEnv()).toMatchObject({ AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_ENV: "debug" });
115
+ await new ApplicationBuildRunner(app).buildAppMeta();
116
+ expect(await builtEnv()).toMatchObject({ AKAN_PUBLIC_ENV: "local" });
117
+ expect(await builtEnv()).toEqual(await dockerfileEnv());
118
+ } finally {
119
+ await rm(root, { recursive: true, force: true });
120
+ }
121
+ });
122
+
86
123
  test("writes the pruned lockfile beside package.json, or warns and leaves the image's install unlocked", async () => {
87
124
  const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
88
125
  const config = new AkanAppConfig(
@@ -249,4 +286,20 @@ console.info(new SampleService().name);
249
286
  await rm(root, { recursive: true, force: true });
250
287
  }
251
288
  });
289
+
290
+ test("akan typecheck names the script/ files nothing imports, and a build's typecheck leaves them out", async () => {
291
+ const { root, app } = await createTempApp("portal");
292
+ const run = spyOn(ProjectTypecheck.prototype, "run").mockImplementation(async () => "");
293
+ try {
294
+ const runner = new ApplicationBuildRunner(app);
295
+ await runner.typecheck({ tests: true });
296
+ await runner.typecheck();
297
+ const [checked, built] = run.mock.calls.map(([entries]) => entries);
298
+ expect(checked).toContain("../../script/**/*.ts");
299
+ expect(built).not.toContain("../../script/**/*.ts");
300
+ } finally {
301
+ run.mockRestore();
302
+ await rm(root, { recursive: true, force: true });
303
+ }
304
+ });
252
305
  });
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { toError } from "akanjs/common";
3
+ import { BUILD_ENV_FILE } from "akanjs/server/lifecycle/buildEnv";
3
4
  import type { BunPlugin } from "bun";
4
5
  import { ProductionLockfile } from "./akanConfig/productionLockfile";
5
6
  import type {
@@ -25,8 +26,9 @@ import type { BaseDevEnv, PackageJson } from "./types";
25
26
  export interface TypecheckOptions {
26
27
  clean?: boolean;
27
28
  incremental?: boolean;
28
- /** The app's own tests and specs too (`akan typecheck`); a build leaves them out. */
29
+ /** The app's own tests, specs and `script/` files too (`akan typecheck`); a build leaves them out. */
29
30
  tests?: boolean;
31
+ report?: "pretty" | "records";
30
32
  }
31
33
 
32
34
  export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
@@ -205,6 +207,7 @@ export class ApplicationBuildRunner {
205
207
  "../../page/**/*.ts",
206
208
  "../../page/**/*.tsx",
207
209
  ...libPageIncludes,
210
+ ...(options.tests ? ["../../script/**/*.ts"] : []),
208
211
  "../../../../pkgs/akanjs/*/types/**/*.d.ts",
209
212
  ],
210
213
  options,
@@ -238,13 +241,13 @@ export class ApplicationBuildRunner {
238
241
 
239
242
  async buildAppMeta() {
240
243
  const akanConfig = await this.#app.getConfig();
244
+ const environment = this.#environment ?? akanConfig.baseDevEnv.env;
241
245
  const packageJson = akanConfig.getProductionPackageJson();
242
246
  const lockfile = await this.#pruneLockfile(packageJson);
243
- const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env, {
244
- lockfile: lockfile !== null,
245
- });
247
+ const dockerfile = akanConfig.dockerfileFor(environment, { lockfile: lockfile !== null });
246
248
  await Promise.all([
247
249
  this.#app.dist.writeJson("package.json", packageJson),
250
+ this.#app.dist.writeJson(BUILD_ENV_FILE, akanConfig.getProductionEnv(environment)),
248
251
  ...(lockfile !== null
249
252
  ? [this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/${ProductionLockfile.fileName}`, lockfile)]
250
253
  : []),
@@ -33,7 +33,7 @@ async function writeGeneratedOverridesLayoutFile(opts: {
33
33
  const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
34
34
  const absPath = path.join(path.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
35
35
  const source = `"use client";
36
- import { UiOverrideProvider } from "akanjs/ui";
36
+ import { UiOverrideProvider } from "akanjs/ui/UiOverride/UiOverride.tsx";
37
37
  import { createElement, type ReactNode } from "react";
38
38
  import value from ${JSON.stringify(importSpecifier(absPath, opts.userAbsPath))};
39
39
 
@@ -213,11 +213,13 @@ ${guardExport}
213
213
 
214
214
  ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
215
215
  ${layoutBinding} return `;
216
+ //? System from its file, not the barrel: a dev registry compiles every module the barrel names, and with them the
217
+ //? optional peers (react-icons) an app drawing none of those components never installs.
216
218
  const source = opts.includeSystemProvider
217
219
  ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
218
220
  import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
219
221
  import type { ReactNode } from "react";
220
- import { System } from "akanjs/ui";
222
+ import { System } from "akanjs/ui/System/index.tsx";
221
223
  import type { McpAppRootProps } from "akanjs/webkit";
222
224
  import { env } from "@apps/${opts.appName}/env/env.client";
223
225
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
package/biome.base.json CHANGED
@@ -211,7 +211,10 @@
211
211
  "!**/*.spec.ts",
212
212
  "!**/*.spec.tsx"
213
213
  ],
214
- "plugins": ["./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit"]
214
+ "plugins": [
215
+ "./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit",
216
+ "./node_modules/@akanjs/devkit/lint/no-oversized-list-limit.grit"
217
+ ]
215
218
  },
216
219
  {
217
220
  "includes": [
@@ -1,6 +1,24 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
2
5
  import { GlobalConfig } from "./globalConfig";
3
6
 
7
+ const tempHome = tempDirs("akan-global-config-");
8
+ //* A child process, so `~/.akan` resolves under the temp HOME and never the person's own.
9
+ const writeConfigUnder = async (home: string) => {
10
+ const proc = Bun.spawn(
11
+ [
12
+ "bun",
13
+ "-e",
14
+ `await (await import(${JSON.stringify(path.join(import.meta.dir, "globalConfig.ts"))})).GlobalConfig.setTestTargets({});`,
15
+ ],
16
+ { env: { ...process.env, HOME: home, USERPROFILE: home }, stdout: "pipe", stderr: "pipe" },
17
+ );
18
+ expect(await proc.exited).toBe(0);
19
+ };
20
+ const modeOf = async (target: string) => (await stat(target)).mode & 0o777;
21
+
4
22
  const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
5
23
 
6
24
  describe("GlobalConfig", () => {
@@ -14,4 +32,20 @@ describe("GlobalConfig", () => {
14
32
  expect(await GlobalConfig.hostFor(workspace(null), "http://localhost:8283/")).toBe("http://localhost:8283");
15
33
  expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
16
34
  });
35
+
36
+ test.skipIf(process.platform === "win32")(
37
+ "keeps ~/.akan to its owner: a new one is made 0700 and a looser one it owns is tightened",
38
+ async () => {
39
+ const fresh = await tempHome();
40
+ await writeConfigUnder(fresh);
41
+ expect(await modeOf(path.join(fresh, ".akan"))).toBe(0o700);
42
+ expect(await modeOf(path.join(fresh, ".akan", "config.json"))).toBe(0o600);
43
+
44
+ const loose = await tempHome();
45
+ await mkdir(path.join(loose, ".akan"), { mode: 0o755 });
46
+ await writeConfigUnder(loose);
47
+ expect(await modeOf(path.join(loose, ".akan"))).toBe(0o700);
48
+ },
49
+ 20_000,
50
+ );
17
51
  });
@@ -1,4 +1,4 @@
1
- import { mkdir, rm, writeFile } from "node:fs/promises";
1
+ import { rm, writeFile } from "node:fs/promises";
2
2
  import { trimTrailingSlashes } from "akanjs/common";
3
3
  import dayjs from "dayjs";
4
4
  import { FileSys } from "../fileSys";
@@ -36,7 +36,7 @@ export class GlobalConfig {
36
36
  // Holds the cloud jwt and refresh token, so owner-only 0600: written to a 0600 sibling and renamed over the file,
37
37
  // which also replaces a world-readable one and never shows another CLI process a half-written file.
38
38
  static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
39
- await mkdir(basePath, { recursive: true, mode: 0o700 });
39
+ FileSys.ensurePrivateDir(basePath);
40
40
  const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
41
41
  try {
42
42
  await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });