@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
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { EnvSeed } from "./envSeed";
5
+ import { tempDirs, writeText } from "./testHelpers";
6
+
7
+ const makeEnvDir = tempDirs("akan-env-seed-");
8
+ const templates = path.resolve(import.meta.dir, "../cli/templates");
9
+ const template = async (file: string) => await readFile(path.join(templates, file), "utf8");
10
+
11
+ const selector = (side: "server" | "client", names: string[]) =>
12
+ `${names.map((name) => `import { env as ${name} } from "./env.${side}.${name}";`).join("\n")}\n\nexport const env = { ${names.join(", ")} };\n`;
13
+
14
+ const seededIn = async (envDir: string) =>
15
+ Object.fromEntries((await new EnvSeed(envDir).missing()).map(({ fileName, content }) => [fileName, content]));
16
+
17
+ describe("EnvSeed", () => {
18
+ test("names each environment file the selectors import and the testing one, of those missing", async () => {
19
+ const envDir = await makeEnvDir();
20
+ await writeText(path.join(envDir, "env.server.ts"), selector("server", ["debug", "local"]));
21
+ await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local", "staging"]));
22
+ await writeText(path.join(envDir, "env.server.debug.ts"), "export const env = { kept: true };\n");
23
+
24
+ expect(Object.keys(await seededIn(envDir))).toEqual([
25
+ "env.client.local.ts",
26
+ "env.client.staging.ts",
27
+ "env.server.local.ts",
28
+ "env.server.testing.ts",
29
+ ]);
30
+ });
31
+
32
+ test("passes over the generated type, the tracked example and a build profile", async () => {
33
+ const envDir = await makeEnvDir();
34
+ await writeText(
35
+ path.join(envDir, "env.server.ts"),
36
+ [
37
+ 'import { libEnv } from "./env.server.type";',
38
+ 'import { env as example } from "./env.server.example";',
39
+ 'import { env as desktop } from "./env.server.debug.desktop";',
40
+ "",
41
+ "export const env = { ...libEnv, ...example, ...desktop };",
42
+ "",
43
+ ].join("\n"),
44
+ );
45
+
46
+ expect(Object.keys(await seededIn(envDir))).toEqual(["env.server.testing.ts"]);
47
+ });
48
+
49
+ test("writes what the app scaffold writes when the folder has no example", async () => {
50
+ const envDir = await makeEnvDir();
51
+ await writeText(path.join(envDir, "env.server.ts"), selector("server", ["local", "testing"]));
52
+ await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local"]));
53
+ await writeText(path.join(envDir, "env.client.type.ts"), "export type AppClientEnv = Record<never, never>;\n");
54
+
55
+ expect(await seededIn(envDir)).toEqual({
56
+ "env.client.local.ts": await template("app/env/env.client.local.ts.template"),
57
+ "env.server.local.ts": await template("app/env/env.server.local.ts.template"),
58
+ "env.server.testing.ts": await template("app/env/env.server.testing.ts.template"),
59
+ });
60
+ });
61
+
62
+ test("copies the side's tracked example into each of its files", async () => {
63
+ const envDir = await makeEnvDir();
64
+ const example =
65
+ 'import type { ModulesOptions } from "../lib/option";\n\nexport const env: ModulesOptions = { port: 1 };\n';
66
+ await writeText(path.join(envDir, "env.server.ts"), selector("server", ["local"]));
67
+ await writeText(path.join(envDir, "env.server.example.ts"), example);
68
+
69
+ expect(await seededIn(envDir)).toEqual({ "env.server.local.ts": example, "env.server.testing.ts": example });
70
+ });
71
+
72
+ test("leaves a client env untyped when the folder has no env.client.type.ts to type it", async () => {
73
+ const envDir = await makeEnvDir();
74
+ await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local"]));
75
+
76
+ expect((await seededIn(envDir))["env.client.local.ts"]).toBe("export const env = {};\n");
77
+ });
78
+
79
+ test("gives a lib its testing env from the example the lib scaffold tracks", async () => {
80
+ const envDir = await makeEnvDir();
81
+ await writeText(
82
+ path.join(envDir, "env.server.example.ts"),
83
+ await template("libRoot/env/env.server.example.ts.template"),
84
+ );
85
+
86
+ expect(await seededIn(envDir)).toEqual({
87
+ "env.server.testing.ts": await template("libRoot/env/env.server.testing.ts.template"),
88
+ });
89
+ });
90
+ });
package/envSeed.ts ADDED
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { FileSys } from "./fileSys";
3
+
4
+ export interface EnvSeedFile {
5
+ fileName: string;
6
+ content: string;
7
+ }
8
+
9
+ //? These files are gitignored for the secrets they hold, so a fresh clone has none of them.
10
+ export class EnvSeed {
11
+ static readonly #server = `import type { ModulesOptions } from "../lib/option";
12
+
13
+ export const env: ModulesOptions = {};
14
+ `;
15
+ static readonly #testingServer = `import type { ModulesOptions } from "../lib/option";
16
+ import { libEnv } from "./env.server.type";
17
+
18
+ export const env: ModulesOptions = {
19
+ ...libEnv,
20
+ };
21
+ `;
22
+ static readonly #client = `import type { AppClientEnv } from "./env.client.type";
23
+
24
+ export const env: AppClientEnv = {};
25
+ `;
26
+ static readonly #untypedClient = "export const env = {};\n";
27
+ static readonly #selectors = ["env.server.ts", "env.client.ts"];
28
+ //? `type` is generated and `example` tracked; a dotted name (`debug.desktop`) is a build profile no selector imports.
29
+ static readonly #selectedPattern = /^\.\/(env\.(?:server|client)\.(?!(?:type|example)$)[\w-]+)$/;
30
+ //? A lib's server.ts and the signal test runtime import it by name, whatever the selector imports.
31
+ static readonly #testing = "env.server.testing.ts";
32
+
33
+ readonly #envDir: string;
34
+ constructor(envDir: string) {
35
+ this.#envDir = envDir;
36
+ }
37
+
38
+ async missing(): Promise<EnvSeedFile[]> {
39
+ const wanted = [...new Set([EnvSeed.#testing, ...(await this.#selected())])].sort();
40
+ const exists = await Promise.all(wanted.map((fileName) => FileSys.fileExists(path.join(this.#envDir, fileName))));
41
+ const missing = wanted.filter((_, idx) => !exists[idx]);
42
+ return await Promise.all(missing.map(async (fileName) => ({ fileName, content: await this.#contentOf(fileName) })));
43
+ }
44
+
45
+ async #selected() {
46
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
47
+ const selected = await Promise.all(
48
+ EnvSeed.#selectors.map(async (selector) => {
49
+ const file = path.join(this.#envDir, selector);
50
+ if (!(await FileSys.fileExists(file))) return [];
51
+ return transpiler
52
+ .scan(await FileSys.readText(file))
53
+ .imports.flatMap(({ path: specifier }) => specifier.match(EnvSeed.#selectedPattern)?.[1] ?? [])
54
+ .map((name) => `${name}.ts`);
55
+ }),
56
+ );
57
+ return selected.flat();
58
+ }
59
+
60
+ async #contentOf(fileName: string) {
61
+ const side = fileName.startsWith("env.client.") ? "client" : "server";
62
+ const example = path.join(this.#envDir, `env.${side}.example.ts`);
63
+ if (await FileSys.fileExists(example)) return await FileSys.readText(example);
64
+ if (side === "server") return fileName === EnvSeed.#testing ? EnvSeed.#testingServer : EnvSeed.#server;
65
+ const typed = await FileSys.fileExists(path.join(this.#envDir, "env.client.type.ts"));
66
+ return typed ? EnvSeed.#client : EnvSeed.#untypedClient;
67
+ }
68
+ }
package/executors.test.ts CHANGED
@@ -275,10 +275,6 @@ describe("Executor filesystem helpers", () => {
275
275
  "Akan workspace agent guide",
276
276
  );
277
277
  expect(await readFile(path.join(root, "workspace/AGENTS.md"), "utf8")).toContain("sample Agent Guide");
278
- expect(await readFile(path.join(root, "workspace/docs/AI-DEVELOPMENT.md"), "utf8")).toContain(
279
- "AI Development Guide",
280
- );
281
- expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
282
278
  // Rules live in the package's base config, so a framework release reaches an existing workspace on `bun update`.
283
279
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
284
280
  '"extends": ["@akanjs/devkit/biome.base.json"]',
@@ -380,6 +376,23 @@ describe("Workspace and app executor environment contracts", () => {
380
376
  });
381
377
  });
382
378
 
379
+ test("a workspace with no .env, as a fresh clone has, runs local unless the shell names an environment", async () => {
380
+ const root = await makeTempRoot();
381
+ delete process.env.AKAN_PUBLIC_ENV;
382
+
383
+ WorkspaceExecutor.defaultCloneToLocalEnv(root);
384
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("local");
385
+
386
+ process.env.AKAN_PUBLIC_ENV = "main";
387
+ WorkspaceExecutor.defaultCloneToLocalEnv(root);
388
+ expect(process.env.AKAN_PUBLIC_ENV).toBe("main");
389
+
390
+ delete process.env.AKAN_PUBLIC_ENV;
391
+ await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
392
+ WorkspaceExecutor.defaultCloneToLocalEnv(root);
393
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("debug");
394
+ });
395
+
383
396
  test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
384
397
  const baseDevEnv = {
385
398
  repoName: "fixture-repo",
@@ -1309,7 +1322,8 @@ const createScannableLib = async (
1309
1322
  await writeJson(path.join(libDir, "tsconfig.json"), { compilerOptions: { target: "ESNext", paths: {} } });
1310
1323
  await writeText(path.join(libDir, "akan.config.ts"), config);
1311
1324
  await mkdir(path.join(libDir, "lib", "__scalar"), { recursive: true });
1312
- for (const [file, content] of Object.entries(files)) await writeText(path.join(libDir, file), content);
1325
+ const libFiles = { "env/env.server.testing.ts": "export const env = {};\n", ...files };
1326
+ for (const [file, content] of Object.entries(libFiles)) await writeText(path.join(libDir, file), content);
1313
1327
  return { ...temp, libDir };
1314
1328
  };
1315
1329
 
package/executors.ts CHANGED
@@ -44,11 +44,13 @@ import {
44
44
  import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
45
45
  import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
46
46
  import { CodegenLock } from "./codegenLock";
47
+ import { EnvSeed } from "./envSeed";
47
48
  import { FileSys, getDirname } from "./fileSys";
48
49
  import { JsonFormatter } from "./jsonFormatter";
49
50
  import { Linter } from "./linter";
50
51
  import { ManifestDependencies } from "./manifestDependencies";
51
52
  import { resolveRepoName } from "./repoIdentity";
53
+ import type { RouteSourceInfo } from "./routeSourceValidator";
52
54
  import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
53
55
  import { Spinner } from "./spinner";
54
56
  // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
@@ -66,6 +68,12 @@ export interface PageRoot {
66
68
  keyPrefix: string;
67
69
  }
68
70
 
71
+ export interface AppRouteSource {
72
+ key: string;
73
+ absPath: string;
74
+ info: RouteSourceInfo;
75
+ }
76
+
69
77
  export interface WrittenFileContent extends FileContent {
70
78
  written: boolean;
71
79
  }
@@ -422,11 +430,13 @@ export class Executor {
422
430
  targetPath,
423
431
  scanInfo,
424
432
  overwrite = true,
433
+ dryRun = false,
425
434
  }: {
426
435
  templatePath: string;
427
436
  targetPath: string;
428
437
  scanInfo?: AppInfo | LibInfo | null;
429
438
  overwrite?: boolean;
439
+ dryRun?: boolean;
430
440
  },
431
441
  dict: { [key: string]: string } = {},
432
442
  options: { [key: string]: unknown } = {},
@@ -449,6 +459,7 @@ export class Executor {
449
459
  const filename = typeof result === "object" ? result.filename : path.basename(targetPath).replace(".js", ".ts");
450
460
  const content = typeof result === "object" ? result.content : result;
451
461
  const convertedTargetPath = convertPath(`${path.dirname(targetPath)}/${filename}`);
462
+ if (dryRun) return { filePath: this.getPath(convertedTargetPath), content, written: false };
452
463
  this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
453
464
  return this.writeFile(convertedTargetPath, content, { overwrite });
454
465
  } else if (targetPath.endsWith(".template")) {
@@ -458,11 +469,13 @@ export class Executor {
458
469
  (data, [key, value]) => data.replace(new RegExp(`<%= ${key} %>`, "g"), value),
459
470
  content,
460
471
  );
472
+ if (dryRun) return { filePath: this.getPath(convertedTargetPath), content: convertedContent, written: false };
461
473
  this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
462
474
  return this.writeFile(convertedTargetPath, convertedContent, { overwrite });
463
475
  } else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
464
476
  const convertedTargetPath = convertPath(targetPath);
465
477
  const writePath = this.getPath(convertedTargetPath);
478
+ if (dryRun) return { filePath: writePath, content: "", written: false };
466
479
  if (!overwrite && (await FileSys.fileExists(writePath)))
467
480
  return { filePath: writePath, content: "", written: false };
468
481
  const dirname = path.dirname(writePath);
@@ -479,6 +492,7 @@ export class Executor {
479
492
  dict = {},
480
493
  options = {},
481
494
  overwrite = true,
495
+ dryRun = false,
482
496
  }: {
483
497
  basePath: string;
484
498
  template: string;
@@ -486,6 +500,7 @@ export class Executor {
486
500
  dict?: { [key: string]: string };
487
501
  options?: { [key: string]: unknown };
488
502
  overwrite?: boolean;
503
+ dryRun?: boolean;
489
504
  }): Promise<WrittenFileContent[]> {
490
505
  const templateRoot = await this.#resolveTemplateRoot();
491
506
  const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
@@ -496,6 +511,7 @@ export class Executor {
496
511
  targetPath: path.join(basePath, path.basename(templatePath)),
497
512
  scanInfo,
498
513
  overwrite,
514
+ dryRun,
499
515
  },
500
516
  dict,
501
517
  options,
@@ -514,6 +530,7 @@ export class Executor {
514
530
  targetPath: path.join(basePath, subdir),
515
531
  scanInfo,
516
532
  overwrite,
533
+ dryRun,
517
534
  },
518
535
  dict,
519
536
  options,
@@ -526,6 +543,7 @@ export class Executor {
526
543
  scanInfo,
527
544
  dict,
528
545
  overwrite,
546
+ dryRun,
529
547
  options,
530
548
  });
531
549
  }),
@@ -551,14 +569,15 @@ export class Executor {
551
569
  dict?: { [key: string]: string };
552
570
  options?: { [key: string]: unknown };
553
571
  overwrite?: boolean;
572
+ dryRun?: boolean;
554
573
  }): Promise<FileContent[]> {
555
574
  const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
556
575
  await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
557
576
  return fileContents;
558
577
  }
559
578
 
560
- // A template cannot sort the identifiers it emits (`Task` vs `Zoo`). Best-effort: `create-akan-workspace`
561
- // scaffolds before `bun install`, with no local Biome binary yet.
579
+ // Best-effort: `create-akan-workspace` scaffolds before `bun install`, with no local Biome binary yet, so a
580
+ // template emits Biome's form itself (`templates/biomeForm.ts`).
562
581
  async #formatAppliedTemplate(fileContents: FileContent[]) {
563
582
  const filePaths = fileContents
564
583
  .map((fileContent) => fileContent.filePath)
@@ -705,6 +724,10 @@ export class WorkspaceExecutor extends Executor {
705
724
  defaulted,
706
725
  };
707
726
  }
727
+ //? A clone has no `.env` (gitignored for its secrets): it runs local, as a new workspace and env.server.ts do, not debug.
728
+ static defaultCloneToLocalEnv(workspaceRoot: string) {
729
+ if (!existsSync(path.join(workspaceRoot, ".env"))) process.env.AKAN_PUBLIC_ENV ??= "local";
730
+ }
708
731
  //? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
709
732
  static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
710
733
  const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
@@ -797,11 +820,19 @@ export class WorkspaceExecutor extends Executor {
797
820
  };
798
821
  return await getDirs(basePath);
799
822
  }
800
- async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
823
+ async commit(
824
+ message: string,
825
+ {
826
+ init = false,
827
+ add = true,
828
+ identity,
829
+ }: { init?: boolean; add?: boolean; identity?: { name: string; email: string } } = {},
830
+ ) {
801
831
  if (init) await this.spawn("git", ["init", "--quiet"]);
802
832
  if (add) await this.spawn("git", ["add", "."]);
833
+ const author = identity ? ["-c", `user.name=${identity.name}`, "-c", `user.email=${identity.email}`] : [];
803
834
  // Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
804
- await this.spawn("git", ["commit", "--quiet", "-m", message]);
835
+ await this.spawn("git", [...author, "commit", "--quiet", "-m", message]);
805
836
  }
806
837
  /** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
807
838
  async hasChanges() {
@@ -916,8 +947,23 @@ export class SysExecutor extends Executor {
916
947
  if (!this.hasScanInfo() && !allowEmpty) throw new Error("Scan info is not available");
917
948
  return this.#scanInfo as AllowEmpty extends true ? AppInfo | LibInfo | null : AppInfo | LibInfo;
918
949
  }
950
+ async #seedEnvFiles(): Promise<FileContent[]> {
951
+ const envDir = path.join(this.cwdPath, "env");
952
+ const missing = await new EnvSeed(envDir).missing();
953
+ if (!missing.length) return [];
954
+ const written = await Promise.all(
955
+ missing.map(({ fileName, content }) =>
956
+ this.writeFile(path.join(envDir, fileName), content, { overwrite: false, silent: true }),
957
+ ),
958
+ );
959
+ this.logger.info(
960
+ `${missing.map(({ fileName }) => `env/${fileName}`).join(", ")} were missing (gitignored, so a clone has none) and now hold local defaults: put this machine's values in them, or fetch the team's with \`akan download-env\`.`,
961
+ );
962
+ return written;
963
+ }
919
964
  #getScanTemplateTasks(scanInfo: AppInfo | LibInfo): (Promise<FileContent[]> | null)[] {
920
965
  return [
966
+ this.#seedEnvFiles(),
921
967
  this._applyTemplate({ basePath: "env", template: "env", scanInfo }),
922
968
  this._applyTemplate({ basePath: "lib", template: "lib", scanInfo }),
923
969
  this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
@@ -1278,6 +1324,7 @@ export class AppExecutor extends SysExecutor {
1278
1324
  }
1279
1325
 
1280
1326
  #pageKeys: string[] | null = null;
1327
+ #routeSources: AppRouteSource[] = [];
1281
1328
  #excludeDevOnlyPages = false;
1282
1329
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1283
1330
  if (this.#pageKeys && !refresh) return this.#pageKeys;
@@ -1288,6 +1335,7 @@ export class AppExecutor extends SysExecutor {
1288
1335
  const devOnlyKeys = new Set<string>();
1289
1336
  const devOnlyDirs: string[] = [];
1290
1337
  const pageRoots = await this.getPageRoots();
1338
+ const routeSources: AppRouteSource[] = [];
1291
1339
  const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1292
1340
  for (const root of pageRoots) {
1293
1341
  if (!(await FileSys.dirExists(root.dir))) continue;
@@ -1333,6 +1381,7 @@ export class AppExecutor extends SysExecutor {
1333
1381
  rootLayout: isRootLayout,
1334
1382
  pattern: parsed.pattern,
1335
1383
  });
1384
+ routeSources.push({ key, absPath, info });
1336
1385
  if (info.devOnly) {
1337
1386
  devOnlyKeys.add(key);
1338
1387
  //* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
@@ -1343,9 +1392,15 @@ export class AppExecutor extends SysExecutor {
1343
1392
  }
1344
1393
  }
1345
1394
  pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1395
+ this.#routeSources = routeSources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1346
1396
  this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
1347
1397
  return this.#pageKeys;
1348
1398
  }
1399
+ /** Each page and layout file `getPageKeys` read, with what its chain declares; overrides files are not listed. */
1400
+ async getRouteSources({ refresh }: { refresh?: boolean } = {}): Promise<AppRouteSource[]> {
1401
+ await this.getPageKeys({ refresh });
1402
+ return this.#routeSources;
1403
+ }
1349
1404
  #dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
1350
1405
  if (!devOnlyKeys.size) return pageKeys;
1351
1406
  const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
package/fileSys.test.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
2
4
  import path from "node:path";
3
5
  import { pathToFileURL } from "node:url";
4
- import { getDirname } from "./fileSys";
6
+ import { FileSys, getDirname } from "./fileSys";
5
7
 
6
8
  describe("getDirname", () => {
7
9
  test("converts file URLs to filesystem paths", () => {
@@ -19,3 +21,29 @@ describe("getDirname", () => {
19
21
  expect(dirname).toBe("C:\\Users\\ken78\\.bun\\install\\global\\node_modules\\@akanjs\\devkit");
20
22
  });
21
23
  });
24
+
25
+ describe("FileSys.ensurePrivateDir", () => {
26
+ //? macOS refuses even the owner a chmod of a folder flagged user-immutable; Linux needs root for the same.
27
+ test.skipIf(process.platform !== "darwin")(
28
+ "warns instead of throwing when the folder's mode cannot be changed",
29
+ () => {
30
+ const root = mkdtempSync(path.join(tmpdir(), "akan-private-dir-"));
31
+ const dir = path.join(root, "locked");
32
+ mkdirSync(dir);
33
+ chmodSync(dir, 0o755);
34
+ Bun.spawnSync(["chflags", "uchg", dir]);
35
+ const warn = spyOn(FileSys.logger, "warn").mockImplementation(() => undefined);
36
+ try {
37
+ expect(FileSys.ensurePrivateDir(dir)).toBe(dir);
38
+ expect(statSync(dir).mode & 0o777).toBe(0o755);
39
+ expect(warn.mock.calls.map(([line]) => String(line))).toEqual([
40
+ expect.stringContaining(`${dir} stays at mode 755`),
41
+ ]);
42
+ } finally {
43
+ warn.mockRestore();
44
+ Bun.spawnSync(["chflags", "nouchg", dir]);
45
+ rmSync(root, { recursive: true, force: true });
46
+ }
47
+ },
48
+ );
49
+ });
package/fileSys.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { chmodSync, mkdirSync, statSync } from "node:fs";
1
2
  import { lstat, rename, rm, stat } from "node:fs/promises";
2
3
  import nodePath from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -70,6 +71,19 @@ export class FileSys {
70
71
  }
71
72
  }
72
73
  }
74
+ //? `mkdir`'s mode only applies to a directory it creates, so one an older build made is tightened here.
75
+ static ensurePrivateDir(dir: string) {
76
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
77
+ if (process.platform === "win32") return dir;
78
+ const { mode, uid } = statSync(dir);
79
+ if ((mode & 0o077) === 0 || uid !== process.getuid?.()) return dir;
80
+ try {
81
+ chmodSync(dir, 0o700);
82
+ } catch (error) {
83
+ FileSys.logger.warn(`${dir} stays at mode ${(mode & 0o777).toString(8)}: ${(error as Error).message}`);
84
+ }
85
+ return dir;
86
+ }
73
87
  static async writeJson(path: string, content: object) {
74
88
  return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
75
89
  }
@@ -10,8 +10,26 @@ const fileOf = (file: string, content: string): SourceFileInfo => ({
10
10
  sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
11
11
  });
12
12
 
13
+ const taskConstant = fileOf(
14
+ "apps/demo/lib/task/task.constant.ts",
15
+ `export class TaskInput extends via((field) => ({
16
+ title: field(String),
17
+ type: field(String),
18
+ phone: field(String),
19
+ project: field(Project).optional(),
20
+ members: field([User]),
21
+ })) {}
22
+
23
+ export class TaskObject extends via(TaskInput, (field) => {
24
+ return { status: field(String) };
25
+ }) {}
26
+
27
+ export class TaskInsight extends via(Task, (field) => ({ onSale: field(Int) })) {}
28
+ `,
29
+ );
30
+
13
31
  const scan = (content: string) =>
14
- new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
32
+ new FormSetterScanner().scan([taskConstant, fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
15
33
 
16
34
  describe("FormSetterScanner", () => {
17
35
  test("a setter passed by reference publishes, so nothing is reported", () => {
@@ -73,6 +91,31 @@ describe("FormSetterScanner", () => {
73
91
  ).toEqual([]);
74
92
  });
75
93
 
94
+ test("a slice setter is not a field setter, though its name reads set…On…", () => {
95
+ expect(
96
+ scan(`export const Filters = () => (
97
+ <Select onChange={(page) => st.do.setPageOfTaskOnSale(page)} />
98
+ );
99
+ `),
100
+ ).toEqual([]);
101
+ });
102
+
103
+ test("a field is known from its model's constant, so another model's setter is not counted", () => {
104
+ expect(
105
+ scan(`export const General = () => (
106
+ <>
107
+ <Field.Text onChange={(title) => st.do.setTitleOnProject(title)} />
108
+ <Field.Text onChange={(onSale) => st.do.setOnSaleOnTask(onSale)} />
109
+ </>
110
+ );
111
+ `),
112
+ ).toEqual([]);
113
+ expect(
114
+ scan(`export const General = () => <Field.Text onChange={(status) => st.do.setStatusOnTask(status)} />;\n`)[0]
115
+ ?.message,
116
+ ).toContain("setStatusOnTask");
117
+ });
118
+
76
119
  test("only .tsx is scanned", () => {
77
120
  const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
78
121
  expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
@@ -1,3 +1,4 @@
1
+ import { capitalize, formSetterNames } from "akanjs/common";
1
2
  import ts from "typescript";
2
3
  import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
4
 
@@ -5,17 +6,61 @@ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
5
6
  // forwarding wrappers; this counts every field left unpublished whatever the reason. A zero-parameter handler is a
6
7
  // button setting a constant, not a form control, so it is not counted.
7
8
  export class FormSetterScanner {
8
- static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
9
+ static #constantPath = /(?:^|\/)lib\/([^_/][^/]*)\/\1\.constant\.ts$/;
10
+ static #baseFields = ["id", "createdAt", "updatedAt", "removedAt"];
9
11
 
10
12
  scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
11
- return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
13
+ const fieldSetters = FormSetterScanner.#fieldSettersOf(sourceFiles);
14
+ return sourceFiles
15
+ .filter((sourceFile) => sourceFile.file.endsWith(".tsx"))
16
+ .flatMap((f) => this.#scanFile(f, fieldSetters));
12
17
  }
13
18
 
14
- #scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
19
+ // Built forward from the constants: matching `set…On…` also caught slice setters such as `setPageOfProductOnSale`.
20
+ static #fieldSettersOf(sourceFiles: SourceFileInfo[]) {
21
+ const setters = new Set<string>();
22
+ for (const { file, sourceFile } of sourceFiles) {
23
+ const refName = FormSetterScanner.#constantPath.exec(file)?.[1];
24
+ if (!refName) continue;
25
+ const className = capitalize(refName);
26
+ const fullModelClasses = new Set([`${className}Input`, `${className}Object`, `Light${className}`, className]);
27
+ const keys = new Set(FormSetterScanner.#baseFields);
28
+ for (const statement of sourceFile.statements)
29
+ if (ts.isClassDeclaration(statement) && statement.name && fullModelClasses.has(statement.name.text))
30
+ for (const key of FormSetterScanner.#viaFieldKeysOf(statement)) keys.add(key);
31
+ for (const key of keys) setters.add(formSetterNames(className, key).setFieldOnModel);
32
+ }
33
+ return setters;
34
+ }
35
+
36
+ static #viaFieldKeysOf(declaration: ts.ClassDeclaration): string[] {
37
+ const via = declaration.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
38
+ ?.expression;
39
+ if (!via || !ts.isCallExpression(via)) return [];
40
+ return via.arguments.flatMap((argument) => {
41
+ if (!ts.isArrowFunction(argument) && !ts.isFunctionExpression(argument)) return [];
42
+ const fields = FormSetterScanner.#returnedObjectOf(argument.body);
43
+ return (fields?.properties ?? []).flatMap((property) =>
44
+ property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
45
+ ? [property.name.text]
46
+ : [],
47
+ );
48
+ });
49
+ }
50
+
51
+ static #returnedObjectOf(body: ts.ConciseBody): ts.ObjectLiteralExpression | null {
52
+ let expression: ts.Node | undefined = body;
53
+ if (ts.isBlock(body)) expression = body.statements.find(ts.isReturnStatement)?.expression;
54
+ while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)))
55
+ expression = expression.expression;
56
+ return expression && ts.isObjectLiteralExpression(expression) ? expression : null;
57
+ }
58
+
59
+ #scanFile({ file, sourceFile }: SourceFileInfo, fieldSetters: Set<string>): QualityWarning[] {
15
60
  const wrapped: { setter: string; line: number }[] = [];
16
61
  const visit = (node: ts.Node) => {
17
62
  if (ts.isJsxAttribute(node)) {
18
- const setter = FormSetterScanner.#wrappedSetterOf(node);
63
+ const setter = FormSetterScanner.#wrappedSetterOf(node, fieldSetters);
19
64
  if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
20
65
  }
21
66
  ts.forEachChild(node, visit);
@@ -40,17 +85,17 @@ export class FormSetterScanner {
40
85
  }
41
86
 
42
87
  /** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
43
- static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
88
+ static #wrappedSetterOf(attribute: ts.JsxAttribute, fieldSetters: Set<string>): string | null {
44
89
  if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
45
90
  const initializer = attribute.initializer;
46
91
  if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
47
92
  const handler = initializer.expression;
48
93
  if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
49
94
  if (!handler.parameters.length) return null;
50
- return FormSetterScanner.#setterCallIn(handler.body);
95
+ return FormSetterScanner.#setterCallIn(handler.body, fieldSetters);
51
96
  }
52
97
 
53
- static #setterCallIn(node: ts.Node): string | null {
98
+ static #setterCallIn(node: ts.Node, fieldSetters: Set<string>): string | null {
54
99
  let found: string | null = null;
55
100
  const visit = (current: ts.Node) => {
56
101
  if (found) return;
@@ -61,7 +106,7 @@ export class FormSetterScanner {
61
106
  ts.isIdentifier(expression.expression) &&
62
107
  expression.expression.text === "st" &&
63
108
  expression.name.text === "do" &&
64
- FormSetterScanner.#fieldSetter.test(name.text)
109
+ fieldSetters.has(name.text)
65
110
  ) {
66
111
  found = name.text;
67
112
  return;