@akanjs/devkit 3.0.0-alpha.10 → 3.0.0-alpha.100

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 (183) 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 +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +82 -43
  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 +134 -4
  41. package/executors.ts +114 -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 +123 -12
  54. package/frontendBuild/cssImportResolver.ts +8 -7
  55. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  56. package/frontendBuild/fontOptimizer.ts +37 -18
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +178 -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 +2 -10
  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 +99 -25
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +116 -0
  149. package/qualityScanner.ts +89 -21
  150. package/recipeScanner.ts +4 -1
  151. package/scanInfo.ts +32 -26
  152. package/semver.test.ts +26 -0
  153. package/semver.ts +31 -0
  154. package/slicePlanner.test.ts +182 -0
  155. package/slicePlanner.ts +235 -0
  156. package/ssrScanner.test.ts +301 -0
  157. package/subspace.test.ts +418 -0
  158. package/subspace.ts +723 -0
  159. package/subspaceConfig.ts +76 -0
  160. package/transforms/asyncDefaultExportDetector.ts +103 -0
  161. package/transforms/barrelImportsPlugin.ts +1 -1
  162. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  163. package/transforms/transforms.test.ts +5 -5
  164. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  165. package/tsconfig.json +6 -2
  166. package/typeChecker.ts +1 -1
  167. package/types.ts +1 -0
  168. package/uploadRelease.ts +2 -2
  169. package/workflow/executor.test.ts +146 -0
  170. package/workspaceLayout.test.ts +56 -4
  171. package/workspaceLayout.ts +48 -2
  172. package/builder.ts +0 -164
  173. package/extractDeps.ts +0 -86
  174. package/frontendBuild/styleGuard.test.ts +0 -165
  175. package/frontendBuild/styleGuard.ts +0 -322
  176. package/getCredentials.ts +0 -19
  177. package/getModelFileData.ts +0 -62
  178. package/src/capacitorApp.ts +0 -282
  179. package/streamAi.ts +0 -45
  180. package/ui/MultiScrollList.tsx +0 -242
  181. package/ui/ScrollList.tsx +0 -107
  182. package/ui/index.ts +0 -2
  183. package/useStdoutDimensions.ts +0 -20
@@ -6,8 +6,6 @@ import { ApplicationBuildReporter } from "./applicationBuildReporter";
6
6
  import { resolveSignalTestPreloadPath } from "./applicationTestPreload";
7
7
  import { TypeScriptDependencyScanner } from "./dependencyScanner";
8
8
  import { AppExecutor, WorkspaceExecutor } from "./executors";
9
- import { extractDependencies } from "./extractDeps";
10
- import { getModelFileData } from "./getModelFileData";
11
9
  import type { PackageJson, TsConfigJson } from "./types";
12
10
 
13
11
  const tempRoots: string[] = [];
@@ -27,65 +25,6 @@ afterEach(async () => {
27
25
  await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
28
26
  });
29
27
 
30
- describe("extractDependencies", () => {
31
- const packageJson: PackageJson = {
32
- name: "fixture",
33
- version: "1.0.0",
34
- description: "fixture",
35
- dependencies: {
36
- react: "19.0.0",
37
- "@scope/pkg": "1.0.0",
38
- lodash: "4.0.0",
39
- },
40
- devDependencies: {
41
- typescript: "6.0.0",
42
- vite: "5.0.0",
43
- },
44
- };
45
-
46
- test("extracts runtime package versions from imports and requires", () => {
47
- const deps = extractDependencies(
48
- [
49
- {
50
- path: "index.ts",
51
- text: [
52
- 'import React from "react";',
53
- 'import { value } from "@scope/pkg/subpath";',
54
- 'import type { Type } from "typescript";',
55
- 'const lodash = require("lodash/fp");',
56
- 'const fs = require("node:fs");',
57
- 'const path = require("path");',
58
- "",
59
- ].join("\n"),
60
- },
61
- { path: "style.css", text: '@import "vite";' },
62
- ],
63
- packageJson,
64
- ["vite"],
65
- );
66
-
67
- expect(deps).toEqual({
68
- "@scope/pkg": "1.0.0",
69
- lodash: "4.0.0",
70
- react: "19.0.0",
71
- typescript: "6.0.0",
72
- vite: "5.0.0",
73
- });
74
- });
75
-
76
- test("reports missing dependency sections and missing versions", () => {
77
- expect(() =>
78
- extractDependencies([{ path: "index.ts", text: 'import React from "react";' }], {
79
- name: "broken",
80
- version: "1.0.0",
81
- description: "broken",
82
- }),
83
- ).toThrow("No dependencies found");
84
-
85
- expect(() => extractDependencies([], packageJson, ["missing"])).toThrow("No version found for missing");
86
- });
87
- });
88
-
89
28
  describe("resolveSignalTestPreloadPath", () => {
90
29
  test("resolves the preload file from an installed akanjs package", async () => {
91
30
  const root = await makeTempRoot();
@@ -252,49 +191,6 @@ describe("scan convention", () => {
252
191
  });
253
192
  });
254
193
 
255
- describe("getModelFileData", () => {
256
- test("reads model files and derives imported local, scalar, and lib models", async () => {
257
- const root = await makeTempRoot();
258
- const cwd = process.cwd();
259
- process.chdir(root);
260
- try {
261
- await write(
262
- path.join(root, "apps/demo/lib/post/post.constant.ts"),
263
- [
264
- 'import { cnst as shared } from "@libs/shared";',
265
- 'import { User } from "../user/user.constant";',
266
- 'import { Money } from "../_money/money.constant";',
267
- "export const Post = {};",
268
- "",
269
- ].join("\n"),
270
- );
271
- await write(
272
- path.join(root, "apps/demo/lib/post/Post.Unit.tsx"),
273
- "export default function Unit() { return null; }\n",
274
- );
275
- await write(
276
- path.join(root, "apps/demo/lib/post/Post.View.tsx"),
277
- "export default function View() { return null; }\n",
278
- );
279
-
280
- const data = await getModelFileData("apps/demo", "post");
281
- expect(data).toMatchObject({
282
- moduleType: "app",
283
- moduleName: "demo",
284
- modelName: "post",
285
- importModelNames: ["user"],
286
- hasImportScalar: true,
287
- importLibNames: ["shared"],
288
- });
289
- expect(data.constantFileStr).toContain("export const Post");
290
- expect(data.unitFileStr).toContain("Unit");
291
- expect(data.viewFileStr).toContain("View");
292
- } finally {
293
- process.chdir(cwd);
294
- }
295
- });
296
- });
297
-
298
194
  describe("ApplicationBuildReporter", () => {
299
195
  test("formats duration, phase lines, and nested errors", () => {
300
196
  expect(ApplicationBuildReporter.formatDuration(999)).toBe("999ms");
package/executors.test.ts CHANGED
@@ -31,7 +31,6 @@ const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
31
31
  react: "19.0.0",
32
32
  "react-dom": "19.0.0",
33
33
  "react-server-dom-webpack": "19.0.0",
34
- sharp: "1.0.0",
35
34
  lodash: "4.0.0",
36
35
  },
37
36
  devDependencies: {
@@ -128,6 +127,49 @@ describe("Executor filesystem helpers", () => {
128
127
  expect(await readFile(path.join(root, "local/docker-compose.yaml"), "utf8")).toBe("custom");
129
128
  });
130
129
 
130
+ test("hands every scaffolded TypeScript file to the formatter, and nothing else", async () => {
131
+ // A template emits identifiers it cannot sort — `import { fetch, Task, usePage }` is right for a model
132
+ // named Task and wrong for Zoo — and unsorted imports fail `biome check`. So the scaffold is formatted
133
+ // on the way out, or it is red for most model names whatever the template says.
134
+ const root = await makeTempRoot();
135
+ const exec = new Executor("fixture", root);
136
+ const formatted: string[] = [];
137
+ exec.getLinter = () =>
138
+ ({
139
+ fixFiles: async (filePaths: string[]) => {
140
+ formatted.push(...filePaths);
141
+ return { fixed: [] };
142
+ },
143
+ }) as unknown as ReturnType<Executor["getLinter"]>;
144
+
145
+ await exec.applyTemplate({
146
+ basePath: "apps/demo/page/task",
147
+ template: "crudSinglePage",
148
+ dict: { model: "task", appName: "demo" },
149
+ });
150
+
151
+ expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
152
+ });
153
+
154
+ test("a formatter that cannot run does not fail the scaffold", async () => {
155
+ // `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary and often
156
+ // no config above the target. An unformatted file is a lint fix; a failed scaffold is not.
157
+ const root = await makeTempRoot();
158
+ const exec = new Executor("fixture", root);
159
+ exec.getLinter = () => {
160
+ throw new Error("biome.json not found");
161
+ };
162
+
163
+ const created = await exec.applyTemplate({
164
+ basePath: "apps/demo/page/task",
165
+ template: "crudSinglePage",
166
+ dict: { model: "task", appName: "demo" },
167
+ });
168
+
169
+ expect(created).toHaveLength(1);
170
+ expect(await readFile(path.join(root, "apps/demo/page/task/_index.tsx"), "utf8")).toContain("Task.Zone.Card");
171
+ });
172
+
131
173
  test("applies hidden files and directories from CLI templates", async () => {
132
174
  const root = await makeTempRoot();
133
175
  const exec = new Executor("fixture", root);
@@ -204,6 +246,7 @@ describe("Workspace and app executor environment contracts", () => {
204
246
  serveDomain: "example.com",
205
247
  env: "local",
206
248
  portOffset: 10,
249
+ workspaceId: undefined,
207
250
  });
208
251
 
209
252
  delete process.env.AKAN_PUBLIC_REPO_NAME;
@@ -234,6 +277,7 @@ describe("Workspace and app executor environment contracts", () => {
234
277
  serveDomain: "file.example.com",
235
278
  env: "develop",
236
279
  portOffset: 7,
280
+ workspaceId: undefined,
237
281
  });
238
282
  });
239
283
 
@@ -271,6 +315,11 @@ describe("Workspace and app executor environment contracts", () => {
271
315
  const prepared = await app.prepareCommand("build");
272
316
  expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
273
317
  expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
318
+ // Bundling reads `process.env` through getPublicEnv and `define`s every AKAN_PUBLIC_* into a literal, so a
319
+ // dev port published here would be baked into the artifact and outrank the PORT the container is run with.
320
+ expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("demo");
321
+ expect(process.env.AKAN_PUBLIC_CLIENT_PORT).toBeUndefined();
322
+ expect(process.env.AKAN_PUBLIC_SERVER_PORT).toBeUndefined();
274
323
  expect((await stat(path.join(root, "dist/apps/demo/private"))).isDirectory()).toBe(true);
275
324
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
276
325
  });
@@ -622,6 +671,64 @@ describe("Workspace and app executor environment contracts", () => {
622
671
  });
623
672
  });
624
673
 
674
+ describe("root layout source validation during page key discovery", () => {
675
+ const makeRouteValidationApp = async (appName: string, config: string, files: Record<string, string>) => {
676
+ const root = await makeTempRoot();
677
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
678
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
679
+ process.env.AKAN_PUBLIC_ENV = "local";
680
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
681
+ await mkdir(path.join(root, "apps", appName), { recursive: true });
682
+ await writeFile(path.join(root, `apps/${appName}/akan.config.ts`), config);
683
+ for (const [file, source] of Object.entries(files)) {
684
+ const filePath = path.join(root, `apps/${appName}/page`, file);
685
+ await mkdir(path.dirname(filePath), { recursive: true });
686
+ await writeFile(filePath, source);
687
+ }
688
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
689
+ return AppExecutor.from(workspace, appName);
690
+ };
691
+ const wsConnectLayout = [
692
+ "export const wsConnect = false;",
693
+ "export default function Layout({ children }) { return children; }",
694
+ "",
695
+ ].join("\n");
696
+
697
+ test("accepts wsConnect on a configured base-path root layout", async () => {
698
+ process.env.AKAN_PUBLIC_BASE_PATHS = "web,admin";
699
+ const app = await makeRouteValidationApp(
700
+ "base-path-root-ws",
701
+ [
702
+ "export default {",
703
+ ' routes: [{ basePath: "web", domains: {} }, { basePath: "admin", domains: {} }],',
704
+ "};",
705
+ "",
706
+ ].join("\n"),
707
+ { "admin/_layout.tsx": wsConnectLayout },
708
+ );
709
+
710
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./admin/_layout.tsx"]);
711
+ });
712
+
713
+ test("rejects wsConnect on a nested layout", async () => {
714
+ const app = await makeRouteValidationApp(
715
+ "nested-layout-ws",
716
+ 'export default { routes: [{ basePath: "admin", domains: {} }] };\n',
717
+ { "admin/users/_layout.tsx": wsConnectLayout },
718
+ );
719
+
720
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(/unsupported export "wsConnect"/);
721
+ });
722
+
723
+ test("accepts wsConnect on a grouped root layout", async () => {
724
+ const app = await makeRouteValidationApp("grouped-root-ws", "export default {};\n", {
725
+ "(docs)/_layout.tsx": wsConnectLayout,
726
+ });
727
+
728
+ await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./(docs)/_layout.tsx"]);
729
+ });
730
+ });
731
+
625
732
  test("accepts metadata route exports during page key discovery", async () => {
626
733
  const root = await makeTempRoot();
627
734
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
@@ -760,6 +867,29 @@ describe("Workspace and app executor environment contracts", () => {
760
867
  expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
761
868
  expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
762
869
  });
870
+
871
+ test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
872
+ const root = await makeTempRoot();
873
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
874
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
875
+ process.env.AKAN_PUBLIC_ENV = "local";
876
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
877
+ await mkdir(path.join(root, "apps/ambient"), { recursive: true });
878
+ await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
879
+
880
+ const originalNodeEnv = process.env.NODE_ENV;
881
+ process.env.NODE_ENV = "production";
882
+ try {
883
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
884
+ const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
885
+ expect(started.env.NODE_ENV).toBe("development");
886
+ // The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
887
+ expect(process.env.NODE_ENV).toBe("development");
888
+ } finally {
889
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
890
+ else process.env.NODE_ENV = originalNodeEnv;
891
+ }
892
+ });
763
893
  });
764
894
 
765
895
  describe("PkgExecutor package generation", () => {
@@ -773,7 +903,7 @@ describe("PkgExecutor package generation", () => {
773
903
  exports: { "./extra": { import: "./extra.ts" } },
774
904
  peerDependencies: { react: "19.0.0" },
775
905
  peerDependenciesMeta: { react: { optional: true } },
776
- optionalDependencies: { sharp: "1.0.0" },
906
+ optionalDependencies: { "@sample/native": "1.0.0" },
777
907
  });
778
908
 
779
909
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -783,12 +913,12 @@ describe("PkgExecutor package generation", () => {
783
913
  expect(distPackageJson).toMatchObject({
784
914
  name: "@sample/tool",
785
915
  type: "module",
786
- engines: { bun: ">=1.3.13" },
916
+ engines: { bun: ">=1.4.0" },
787
917
  dependencies: { lodash: "4.0.0" },
788
918
  devDependencies: { typescript: "6.0.0" },
789
919
  peerDependencies: { react: "19.0.0" },
790
920
  peerDependenciesMeta: { react: { optional: true } },
791
- optionalDependencies: { sharp: "1.0.0" },
921
+ optionalDependencies: { "@sample/native": "1.0.0" },
792
922
  });
793
923
  expect(distPackageJson.exports?.["."]).toEqual({
794
924
  import: "./index.ts",
package/executors.ts CHANGED
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
23
23
  import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
24
24
  import {
25
25
  capitalize,
26
+ getPageSourceFileViolation,
26
27
  isRouteSourceFile,
27
28
  Logger,
28
29
  parseRouteModuleKey,
@@ -39,6 +40,8 @@ import {
39
40
  upsertAgentBlock,
40
41
  } from "./agentsIndex";
41
42
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
43
+ import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
44
+ import { CodegenLock } from "./codegenLock";
42
45
  import { FileSys } from "./fileSys";
43
46
  import { getDirname } from "./getDirname";
44
47
  import { Linter } from "./linter";
@@ -233,7 +236,7 @@ export class Executor {
233
236
  );
234
237
  });
235
238
  proc.on("exit", (code, signal) => {
236
- if (!!code || signal)
239
+ if (code || signal)
237
240
  reject(
238
241
  new CommandExecutionError({
239
242
  command,
@@ -342,7 +345,7 @@ export class Executor {
342
345
  );
343
346
  });
344
347
  proc.on("exit", (code, signal) => {
345
- if (!!code || signal)
348
+ if (code || signal)
346
349
  reject(
347
350
  new CommandExecutionError({
348
351
  command: modulePath,
@@ -430,7 +433,9 @@ export class Executor {
430
433
  const writePath = this.getPath(filePath);
431
434
  const dir = path.dirname(writePath);
432
435
  if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
433
- let contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
436
+ //? Biome formats every tracked .json and always ends a file with a newline, so a JSON write without one
437
+ //? loses a byte to `akan lint` and takes it back on the next `akan sync` — a permanent one-line git diff.
438
+ let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
434
439
 
435
440
  if (await FileSys.fileExists(writePath)) {
436
441
  const currentContent = await FileSys.readText(writePath);
@@ -686,7 +691,30 @@ export class Executor {
686
691
  Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
687
692
  ),
688
693
  };
689
- return this._applyTemplate({ ...options, dict });
694
+ const fileContents = await this._applyTemplate({ ...options, dict });
695
+ await this.#formatAppliedTemplate(fileContents);
696
+ return fileContents;
697
+ }
698
+
699
+ /**
700
+ * A template emits identifiers it cannot sort. `import { fetch, Task, usePage }` is correctly ordered for
701
+ * a model named Task and wrong for one named Zoo, and `organizeImports` fails `biome check` — so a
702
+ * scaffold that is not formatted on the way out is red for most model names, whatever the template says.
703
+ *
704
+ * Best-effort: `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary
705
+ * and often no config above the target yet. An unformatted file is a lint fix; a failed scaffold is not.
706
+ */
707
+ async #formatAppliedTemplate(fileContents: FileContent[]) {
708
+ const filePaths = fileContents
709
+ .map((fileContent) => fileContent.filePath)
710
+ .filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
711
+ if (filePaths.length === 0) return;
712
+ try {
713
+ const { fixed } = await this.getLinter().fixFiles(filePaths);
714
+ if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
715
+ } catch (err) {
716
+ this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
717
+ }
690
718
  }
691
719
  // Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
692
720
  // not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
@@ -909,9 +937,30 @@ export class WorkspaceExecutor extends Executor {
909
937
  return await getDirs(basePath);
910
938
  }
911
939
  async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
912
- if (init) await this.exec(`git init --quiet`);
913
- if (add) await this.exec(`git add .`);
914
- await this.exec(`git commit --quiet -m "${message}"`);
940
+ if (init) await this.spawn("git", ["init", "--quiet"]);
941
+ if (add) await this.spawn("git", ["add", "."]);
942
+ // Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
943
+ await this.spawn("git", ["commit", "--quiet", "-m", message]);
944
+ }
945
+ /** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
946
+ async hasChanges() {
947
+ return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
948
+ }
949
+ /**
950
+ * Workspace-relative paths git knows about, sorted. `untracked` adds files that exist but are not
951
+ * committed yet, still honoring `.gitignore` — which is what a freshly copied library looks like.
952
+ *
953
+ * Reading the file set from git is what keeps generated barrels, the `page/**` and `public/libs`
954
+ * symlinks, env values and the lockfile out of it without any caller restating that list.
955
+ */
956
+ async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
957
+ if (!paths.length) return [];
958
+ const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
959
+ const stdout = await this.spawn("git", ["ls-files", "-z", ...mode, "--", ...paths]);
960
+ return stdout
961
+ .split("\0")
962
+ .filter((file) => !!file)
963
+ .sort();
915
964
  }
916
965
  async #getDirHasFile(basePath: string, targetFilename: string) {
917
966
  const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
@@ -1006,6 +1055,16 @@ export class SysExecutor extends Executor {
1006
1055
  const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
1007
1056
  return await this.workspace.getDirInModule(path, this.name);
1008
1057
  }
1058
+ async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
1059
+ if (!(await this.exists("page"))) return [];
1060
+ const violations: { relativePath: string; reason: string }[] = [];
1061
+ for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
1062
+ const relativePath = entry.split(path.sep).join("/");
1063
+ const reason = getPageSourceFileViolation(relativePath);
1064
+ if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
1065
+ }
1066
+ return violations;
1067
+ }
1009
1068
 
1010
1069
  #scanInfo: AppInfo | LibInfo | null = null;
1011
1070
  hasScanInfo() {
@@ -1078,18 +1137,22 @@ export class SysExecutor extends Executor {
1078
1137
  : await LibInfo.fromExecutor(this as unknown as LibExecutor, {
1079
1138
  refresh,
1080
1139
  });
1081
- if (write) {
1082
- await Promise.all(this.#getScanTemplateTasks(scanInfo));
1083
- await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
1084
- if (this.type === "lib") this.#updateDependencies(scanInfo);
1140
+ //* `writeLib` regenerates every dependency lib's barrels, and each mounting app's `akan start`
1141
+ //* regenerates the same ones — so this region races the other dev servers in the workspace and the
1142
+ //* builders that watch what it writes.
1143
+ if (write)
1144
+ await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
1145
+ await Promise.all(this.#getScanTemplateTasks(scanInfo));
1146
+ await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
1147
+ if (this.type === "lib") this.#updateDependencies(scanInfo);
1085
1148
 
1086
- if (writeLib) {
1087
- const libInfos = [...scanInfo.getLibInfos().values()];
1088
- await this.#updateDependencies(scanInfo);
1089
- await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
1090
- }
1091
- await this.syncAgentsIndex(scanInfo);
1092
- }
1149
+ if (writeLib) {
1150
+ const libInfos = [...scanInfo.getLibInfos().values()];
1151
+ await this.#updateDependencies(scanInfo);
1152
+ await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
1153
+ }
1154
+ await this.syncAgentsIndex(scanInfo);
1155
+ });
1093
1156
  this.#scanInfo = scanInfo;
1094
1157
  return scanInfo;
1095
1158
  }
@@ -1347,6 +1410,8 @@ export class AppExecutor extends SysExecutor {
1347
1410
  const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
1348
1411
  const routeEnv = {
1349
1412
  AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
1413
+ AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
1414
+ AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
1350
1415
  AKAN_DATABASE_MODE: databaseMode,
1351
1416
  };
1352
1417
  Object.assign(process.env, routeEnv);
@@ -1355,15 +1420,30 @@ export class AppExecutor extends SysExecutor {
1355
1420
  //* generated against and typechecked. Drop the cache so the build phases re-read it without them.
1356
1421
  this.#excludeDevOnlyPages = true;
1357
1422
  this.#pageKeys = null;
1358
- if (await this.exists(this.dist.cwdPath)) await this.dist.exec(`rm -rf ${this.dist.cwdPath}`);
1423
+ await this.dist.removeDir(this.dist.cwdPath);
1359
1424
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1360
1425
  //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
1361
1426
  //* release tarball root, neither of which follows a link out of itself, so materialize them here.
1427
+ //* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
1362
1428
  await Promise.all([
1363
1429
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1364
- this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1430
+ ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1365
1431
  ]);
1366
- } else await this.removeDir(".akan");
1432
+ } else {
1433
+ await this.removeDir(".akan");
1434
+ //? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
1435
+ //? builder is also the file watcher, so switching it off would take server-code HMR with it.
1436
+ if (!akanConfig.web.ssr || !akanConfig.web.csr)
1437
+ this.logger.verbose(
1438
+ `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1439
+ );
1440
+ }
1441
+ //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1442
+ //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1443
+ //? and a dev server that believes it is production serves from the production route cache, whose every
1444
+ //? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
1445
+ //? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
1446
+ if (type === "start") process.env.NODE_ENV = "development";
1367
1447
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1368
1448
  const env = this.getCommandEnv({
1369
1449
  AKAN_COMMAND_TYPE: type,
@@ -1375,7 +1455,15 @@ export class AppExecutor extends SysExecutor {
1375
1455
  // which filters to AKAN_PUBLIC_*) sees AKAN_PUBLIC_APP_NAME — otherwise SSR throws
1376
1456
  // "environment variable AKAN_PUBLIC_APP_NAME is required". Only AKAN_PUBLIC_* is baked into bundles, so
1377
1457
  // this does not leak non-public env.
1378
- if (type === "build") Object.assign(process.env, env);
1458
+ // The port keys are this machine's dev allocation, and `define` turns an `AKAN_PUBLIC_*` into a literal the
1459
+ // artifact can never be run with a different value for — a baked port would outrank the `PORT` the container
1460
+ // is started with and send every SSR self-call to a port nothing bound.
1461
+ if (type === "build") {
1462
+ const buildEnv = { ...env };
1463
+ delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1464
+ delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1465
+ Object.assign(process.env, buildEnv);
1466
+ }
1379
1467
  return { env };
1380
1468
  }
1381
1469
  #publicEnv: Record<string, string> | null = null;
@@ -1452,7 +1540,8 @@ export class AppExecutor extends SysExecutor {
1452
1540
  );
1453
1541
  }
1454
1542
  if (!owner) owners.set(routeId, { absPath, fromLib });
1455
- const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
1543
+ const layoutSegments = getRootBoundarySegments(key);
1544
+ const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
1456
1545
  const routeSource = await Bun.file(absPath).text();
1457
1546
  const validator = await AppExecutor.#getRouteSourceValidator();
1458
1547
  if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
@@ -1701,7 +1790,7 @@ export class LibExecutor extends SysExecutor {
1701
1790
  #akanConfig: AkanLibConfig | null = null;
1702
1791
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1703
1792
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1704
- this.#akanConfig = await AkanLibConfig.from(this);
1793
+ this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
1705
1794
  return this.#akanConfig;
1706
1795
  }
1707
1796
  }
@@ -1807,7 +1896,7 @@ export class PkgExecutor extends Executor {
1807
1896
  default: "./index.ts",
1808
1897
  },
1809
1898
  },
1810
- engines: { bun: ">=1.3.13" },
1899
+ engines: { bun: ">=1.4.0" },
1811
1900
  ...dependencyMaps,
1812
1901
  };
1813
1902
  await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);