@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.91

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 +27 -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 +1 -1
  22. package/applicationBuildRunner.ts +45 -21
  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 +86 -47
  27. package/biomeBase.ts +6 -5
  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 +129 -4
  41. package/executors.ts +105 -24
  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 +67 -0
  71. package/frontendBuild/watchRootResolver.ts +13 -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
package/biomeBase.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /** `extends` target for a workspace `biome.json`; Biome resolves it through node_modules. */
2
2
  export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
3
3
 
4
- // Biome moves rules between groups across minors — `noUnnecessaryConditions` sits in `nursery` at 2.4 and in
5
- // `suspicious` from 2.5, and the old position is a hard "unknown key" error, not a warning. A workspace one minor
6
- // ahead of the shipped base config therefore fails to load it at all, so the version is pinned rather than resolved
7
- // to latest at create time. Bump this and `biome.base.json` in the same commit.
8
- export const biomeVersion = "2.4.4";
4
+ // Biome moves rules between groups across minors — `noUnnecessaryConditions` sat in `nursery` at 2.4 and moved to
5
+ // `suspicious` at 2.5 and the stale position is a hard "unknown key" error, not a warning. A workspace whose
6
+ // Biome disagrees with the shipped base config therefore fails to load it at all, which is why the version is
7
+ // pinned here instead of resolved to latest at create time. Bump this and `biome.base.json` in one commit, and run
8
+ // `biome migrate --write` in the workspace root and in `pkgs/@akanjs/devkit` so both configs move together.
9
+ export const biomeVersion = "2.5.12";
package/capacitorApp.ts CHANGED
@@ -123,6 +123,16 @@ interface MaterializeCapacitorConfigOptions {
123
123
  }
124
124
  type MobilePlatform = "ios" | "android";
125
125
 
126
+ /**
127
+ * `AppExecutor.spawn`'s own options plus the two the Capacitor config is written from before the spawn:
128
+ * the platform the command targets, and — for iOS — whether it targets a device or a simulator, which
129
+ * decide the `capacitor.config.json` that command reads.
130
+ */
131
+ type SpawnMobileOptions = Parameters<AppExecutor["spawn"]>[2] & {
132
+ platform?: MobilePlatform;
133
+ iosRunTargetKind?: IosRunTargetKind;
134
+ };
135
+
126
136
  export interface LocalDevHostResolution {
127
137
  host: string;
128
138
  source: "override" | "detected" | "loopback" | "platform";
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { chmod, mkdir } from "node:fs/promises";
2
2
  import dayjs from "dayjs";
3
3
  import { FileSys } from "../fileSys";
4
4
  import {
@@ -29,9 +29,16 @@ export class GlobalConfig {
29
29
  remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
30
30
  };
31
31
  }
32
+ /**
33
+ * This file holds the cloud jwt, a refresh token that does not expire, and the LLM api key, so it is
34
+ * written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
35
+ * and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
36
+ * existing world-readable file is tightened by the next write rather than left as it was found.
37
+ */
32
38
  static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
33
- await mkdir(basePath, { recursive: true });
39
+ await mkdir(basePath, { recursive: true, mode: 0o700 });
34
40
  await Bun.write(configPath, JSON.stringify(akanConfig, null, 2));
41
+ await chmod(configPath, 0o600);
35
42
  }
36
43
  static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
37
44
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
@@ -0,0 +1,137 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { CodegenLock } from "./codegenLock";
6
+
7
+ const roots: string[] = [];
8
+ const makeRoot = async () => {
9
+ const root = await mkdtemp(path.join(tmpdir(), "akan-codegen-lock-"));
10
+ roots.push(root);
11
+ return root;
12
+ };
13
+ const seedHolder = async (root: string, holder: unknown) => {
14
+ const lockPath = CodegenLock.pathIn(root);
15
+ await Bun.write(lockPath, typeof holder === "string" ? holder : JSON.stringify(holder));
16
+ return lockPath;
17
+ };
18
+ /** A pid that cannot be alive: `kill(0)` on it is ESRCH on every platform this runs on. */
19
+ const deadPid = 0x7ffffff;
20
+
21
+ afterEach(async () => {
22
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("CodegenLock", () => {
26
+ test("serializes concurrent callers in the same process", async () => {
27
+ const root = await makeRoot();
28
+ const order: string[] = [];
29
+ const body = async (name: string) => {
30
+ order.push(`${name}:in`);
31
+ await Bun.sleep(20);
32
+ order.push(`${name}:out`);
33
+ };
34
+ await Promise.all([
35
+ CodegenLock.run(root, "a", () => body("a")),
36
+ CodegenLock.run(root, "b", () => body("b")),
37
+ CodegenLock.run(root, "c", () => body("c")),
38
+ ]);
39
+ for (const name of ["a", "b", "c"]) {
40
+ const enter = order.indexOf(`${name}:in`);
41
+ const leave = order.indexOf(`${name}:out`);
42
+ expect(leave).toBe(enter + 1);
43
+ }
44
+ });
45
+
46
+ test("releases the lock file even when the body throws", async () => {
47
+ const root = await makeRoot();
48
+ const lockPath = CodegenLock.pathIn(root);
49
+ await expect(
50
+ CodegenLock.run(root, "boom", async () => {
51
+ expect(await Bun.file(lockPath).exists()).toBe(true);
52
+ throw new Error("boom");
53
+ }),
54
+ ).rejects.toThrow("boom");
55
+ expect(await Bun.file(lockPath).exists()).toBe(false);
56
+ });
57
+
58
+ test("writes a holder naming this process", async () => {
59
+ const root = await makeRoot();
60
+ const lockPath = CodegenLock.pathIn(root);
61
+ const holder = await CodegenLock.run(root, "scan:minimal", async () => await readFile(lockPath, "utf8"));
62
+ expect(JSON.parse(holder)).toMatchObject({ pid: process.pid, label: "scan:minimal" });
63
+ });
64
+
65
+ test("reclaims a lock whose holder is gone", async () => {
66
+ const root = await makeRoot();
67
+ const lockPath = await seedHolder(root, { pid: deadPid, at: Date.now(), label: "crashed" });
68
+ const started = Date.now();
69
+ const holder = await CodegenLock.run(root, "next", async () => await readFile(lockPath, "utf8"));
70
+ expect(JSON.parse(holder).pid).toBe(process.pid);
71
+ expect(Date.now() - started).toBeLessThan(CodegenLock.waitTimeoutMs);
72
+ });
73
+
74
+ test("respects a live holder until the wait expires, then proceeds without the lock", async () => {
75
+ const root = await makeRoot();
76
+ const lockPath = await seedHolder(root, { pid: process.pid, at: Date.now(), label: "other-session" });
77
+ const waitTimeoutMs = CodegenLock.waitTimeoutMs;
78
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: 150, configurable: true });
79
+ try {
80
+ let ran = false;
81
+ await CodegenLock.run(root, "blocked", async () => {
82
+ ran = true;
83
+ // The foreign holder is left in place: nothing may delete a lock it does not hold.
84
+ expect(JSON.parse(await readFile(lockPath, "utf8")).label).toBe("other-session");
85
+ });
86
+ expect(ran).toBe(true);
87
+ expect(await Bun.file(lockPath).exists()).toBe(true);
88
+ } finally {
89
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
90
+ }
91
+ });
92
+
93
+ test("keeps a young unreadable lock but reclaims a stale one", async () => {
94
+ const young = await makeRoot();
95
+ await seedHolder(young, "");
96
+ const waitTimeoutMs = CodegenLock.waitTimeoutMs;
97
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: 150, configurable: true });
98
+ try {
99
+ await CodegenLock.run(young, "young", async () => undefined);
100
+ expect(await Bun.file(CodegenLock.pathIn(young)).exists()).toBe(true);
101
+ } finally {
102
+ Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
103
+ }
104
+
105
+ const stale = await makeRoot();
106
+ const stalePath = await seedHolder(stale, "");
107
+ const aged = new Date(Date.now() - CodegenLock.unknownHolderStaleMs - 1_000);
108
+ await utimes(stalePath, aged, aged);
109
+ const holder = await CodegenLock.run(stale, "stale", async () => await readFile(stalePath, "utf8"));
110
+ expect(JSON.parse(holder).pid).toBe(process.pid);
111
+ });
112
+
113
+ test("blocks a second process for as long as it holds the lock", async () => {
114
+ const root = await makeRoot();
115
+ const lockPath = CodegenLock.pathIn(root);
116
+ const script = path.join(root, "holder.ts");
117
+ await writeFile(
118
+ script,
119
+ `import { CodegenLock } from ${JSON.stringify(path.resolve(import.meta.dir, "codegenLock.ts"))};
120
+ await CodegenLock.run(${JSON.stringify(root)}, "child", async () => {
121
+ process.stdout.write("held\\n");
122
+ await Bun.sleep(400);
123
+ });
124
+ `,
125
+ );
126
+ const child = Bun.spawn(["bun", script], { stdio: ["ignore", "pipe", "inherit"] });
127
+ const reader = child.stdout.getReader();
128
+ await reader.read();
129
+ reader.releaseLock();
130
+
131
+ const started = Date.now();
132
+ await CodegenLock.run(root, "parent", async () => undefined);
133
+ expect(Date.now() - started).toBeGreaterThan(100);
134
+ await child.exited;
135
+ expect(await stat(lockPath).catch(() => null)).toBeNull();
136
+ });
137
+ });
package/codegenLock.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Logger } from "akanjs/common";
4
+
5
+ interface LockHolder {
6
+ pid: number;
7
+ at: number;
8
+ label: string;
9
+ }
10
+
11
+ /**
12
+ * A workspace-wide mutex over the generated source files every dev server in the workspace rewrites.
13
+ *
14
+ * `WatchRootResolver` narrows the `apps/` container to one app but keeps `libs/` whole on purpose, so
15
+ * with two dev servers up a save under `libs/` reaches both builders and both regenerate the same
16
+ * barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is a
17
+ * rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
18
+ *
19
+ * A wait that expires proceeds *without* the lock rather than failing: this sits on the dev server's
20
+ * hot path, and stalling the file watcher is worse than the torn read `FileSys.writeTextAtomic` already
21
+ * prevents on its own.
22
+ */
23
+ export class CodegenLock {
24
+ static readonly fileName = "codegen.lock";
25
+ static readonly waitTimeoutMs = 10_000;
26
+ /**
27
+ * How long an unreadable lock file is respected. It covers the window between the exclusive create
28
+ * and the holder write, where the file exists but names no pid yet — a young one is somebody else
29
+ * mid-acquire, not a corpse.
30
+ */
31
+ static readonly unknownHolderStaleMs = 60_000;
32
+ static readonly #pollMs = 25;
33
+ static readonly #logger = new Logger("CodegenLock");
34
+ /** Serializes callers inside this process, which one `O_EXCL` file cannot tell apart. */
35
+ static #queue: Promise<void> = Promise.resolve();
36
+
37
+ static pathIn(workspaceRoot: string) {
38
+ return path.join(workspaceRoot, "local", ".akan", CodegenLock.fileName);
39
+ }
40
+
41
+ static async run<T>(workspaceRoot: string, label: string, fn: () => Promise<T>): Promise<T> {
42
+ const ahead = CodegenLock.#queue;
43
+ let done!: () => void;
44
+ CodegenLock.#queue = new Promise<void>((resolve) => {
45
+ done = resolve;
46
+ });
47
+ try {
48
+ await CodegenLock.#waitForQueue(ahead, label);
49
+ return await CodegenLock.#withFileLock(workspaceRoot, label, fn);
50
+ } finally {
51
+ done();
52
+ }
53
+ }
54
+
55
+ static async #waitForQueue(ahead: Promise<void>, label: string) {
56
+ let timer: ReturnType<typeof setTimeout> | null = null;
57
+ const expired = new Promise<"expired">((resolve) => {
58
+ timer = setTimeout(() => resolve("expired"), CodegenLock.waitTimeoutMs);
59
+ });
60
+ try {
61
+ if ((await Promise.race([ahead.then(() => "done" as const), expired])) === "expired")
62
+ CodegenLock.#logger.warn(
63
+ `codegen lock queued past ${CodegenLock.waitTimeoutMs}ms in this process; continuing without waiting (${label})`,
64
+ );
65
+ } finally {
66
+ if (timer) clearTimeout(timer);
67
+ }
68
+ }
69
+
70
+ static async #withFileLock<T>(workspaceRoot: string, label: string, fn: () => Promise<T>): Promise<T> {
71
+ const lockPath = CodegenLock.pathIn(workspaceRoot);
72
+ await mkdir(path.dirname(lockPath), { recursive: true }).catch(() => undefined);
73
+ const held = await CodegenLock.#acquire(lockPath, label);
74
+ try {
75
+ return await fn();
76
+ } finally {
77
+ if (held) await rm(lockPath, { force: true }).catch(() => undefined);
78
+ }
79
+ }
80
+
81
+ static async #acquire(lockPath: string, label: string): Promise<boolean> {
82
+ const deadline = Date.now() + CodegenLock.waitTimeoutMs;
83
+ for (;;) {
84
+ const handle = await open(lockPath, "wx").catch(() => null);
85
+ if (handle) {
86
+ await handle
87
+ .writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies LockHolder))
88
+ .catch(() => undefined);
89
+ await handle.close().catch(() => undefined);
90
+ return true;
91
+ }
92
+ if (await CodegenLock.#reclaimIfAbandoned(lockPath)) continue;
93
+ if (Date.now() >= deadline) {
94
+ CodegenLock.#logger.warn(
95
+ `codegen lock at ${lockPath} held past ${CodegenLock.waitTimeoutMs}ms; continuing without it (${label})`,
96
+ );
97
+ return false;
98
+ }
99
+ await Bun.sleep(CodegenLock.#pollMs);
100
+ }
101
+ }
102
+
103
+ /** A live holder is never reclaimed — the wait timeout is what bounds a pathologically slow one. */
104
+ static async #reclaimIfAbandoned(lockPath: string): Promise<boolean> {
105
+ const info = await stat(lockPath).catch(() => null);
106
+ if (!info) return true;
107
+ const holder = CodegenLock.#parseHolder(await readFile(lockPath, "utf8").catch(() => ""));
108
+ if (holder) {
109
+ if (CodegenLock.#isAlive(holder.pid)) return false;
110
+ } else if (Date.now() - info.mtimeMs < CodegenLock.unknownHolderStaleMs) return false;
111
+ await rm(lockPath, { force: true }).catch(() => undefined);
112
+ return true;
113
+ }
114
+
115
+ static #parseHolder(raw: string): LockHolder | null {
116
+ try {
117
+ const parsed = JSON.parse(raw) as Partial<LockHolder>;
118
+ if (typeof parsed.pid !== "number" || typeof parsed.at !== "number") return null;
119
+ return { pid: parsed.pid, at: parsed.at, label: typeof parsed.label === "string" ? parsed.label : "" };
120
+ } catch {
121
+ // A truncated holder file names no pid, so it is aged by mtime instead.
122
+ return null;
123
+ }
124
+ }
125
+
126
+ static #isAlive(pid: number): boolean {
127
+ if (!Number.isInteger(pid) || pid <= 0) return false;
128
+ try {
129
+ process.kill(pid, 0);
130
+ return true;
131
+ } catch (error) {
132
+ // EPERM is a pid that exists under another user, which still holds the lock.
133
+ return (error as NodeJS.ErrnoException).code === "EPERM";
134
+ }
135
+ }
136
+ }
@@ -12,7 +12,7 @@ import { COMMAND_META, type CommandCls } from "./targetMeta";
12
12
  export const argTypes = ["Argument", "Option"] as const;
13
13
  export type ArgType = (typeof argTypes)[number];
14
14
 
15
- export const internalArgTypes = ["Workspace", "App", "Lib", "Sys", "Pkg", "Module", "Exec"] as const;
15
+ export const internalArgTypes = ["Workspace", "App", "Apps", "Lib", "Sys", "Pkg", "Module", "Exec"] as const;
16
16
  export type InternalArgType = (typeof internalArgTypes)[number];
17
17
 
18
18
  export type PrimitiveArgType = StringConstructor | NumberConstructor | BooleanConstructor;
@@ -83,6 +83,14 @@ export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPri
83
83
  export const App = createInternalArgToken<AppExecutor, "App">("App");
84
84
  export type App = AppExecutor;
85
85
 
86
+ /**
87
+ * One or more apps, from a variadic positional (`akan start a b`, `akan start a,b`, `akan start all`)
88
+ * or a checkbox when none is named. Reach for it only where running several is meaningful — every other
89
+ * command takes `App`, whose single-select is unchanged.
90
+ */
91
+ export const Apps = createInternalArgToken<AppExecutor[], "Apps">("Apps");
92
+ export type Apps = AppExecutor[];
93
+
86
94
  export const Lib = createInternalArgToken<LibExecutor, "Lib">("Lib");
87
95
  export type Lib = LibExecutor;
88
96
 
@@ -1,8 +1,14 @@
1
1
  import path from "node:path";
2
- import type { confirm as inquirerConfirm, input as inquirerInput, select as inquirerSelect } from "@inquirer/prompts";
2
+ import type {
3
+ checkbox as inquirerCheckbox,
4
+ confirm as inquirerConfirm,
5
+ input as inquirerInput,
6
+ select as inquirerSelect,
7
+ } from "@inquirer/prompts";
3
8
  import { Logger } from "akanjs/common";
4
9
  import chalk from "chalk";
5
10
  import { type Command, program } from "commander";
11
+ import { AppSelectionMemory } from "../appSelectionMemory";
6
12
  import { AppExecutor, Executor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
7
13
  // Import the owning modules directly, never the root barrel: `..` re-exports all 41 devkit modules,
8
14
  // so a barrel import here drags ink, @trapezedev/project, ssh2, @langchain/* and the cloud stack into
@@ -103,6 +109,27 @@ const prompts = async () => await import("@inquirer/prompts");
103
109
  const select = ((config, context) => prompts().then((m) => m.select(config, context))) as typeof inquirerSelect;
104
110
  const confirm = ((config, context) => prompts().then((m) => m.confirm(config, context))) as typeof inquirerConfirm;
105
111
  const input = ((config, context) => prompts().then((m) => m.input(config, context))) as typeof inquirerInput;
112
+ const checkbox = ((config, context) => prompts().then((m) => m.checkbox(config, context))) as typeof inquirerCheckbox;
113
+
114
+ /**
115
+ * Rejects a value that is not one of the declared choices, in commander's own wording.
116
+ *
117
+ * Only a static choice list can be checked: a `DynamicEnum` resolves against the command context, which
118
+ * is not populated until the internal args are resolved, and it is the interactive `select` that consumes
119
+ * it. Comparison is stringly on purpose — the value still carries commander's raw string here, while a
120
+ * numeric choice list holds numbers.
121
+ */
122
+ const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
123
+ const enumChoices = argMeta.argsOption.enum;
124
+ if (!enumChoices || typeof enumChoices === "function") return;
125
+ const choices = normalizeEnumChoices(enumChoices);
126
+ if (choices.some((choice) => String(choice.value) === String(value))) return;
127
+ const label =
128
+ argMeta.type === "Option" ? `option '--${camelToKebabCase(argMeta.name)}'` : `argument '${argMeta.name}'`;
129
+ throw new Error(
130
+ `${label} argument '${String(value)}' is invalid. Allowed choices are ${choices.map((choice) => choice.name).join(", ")}.`,
131
+ );
132
+ };
106
133
 
107
134
  const resolveEnumChoices = async (argMeta: ArgMeta, context: CommandContext) => {
108
135
  const enumChoices = argMeta.argsOption.enum;
@@ -111,13 +138,15 @@ const resolveEnumChoices = async (argMeta: ArgMeta, context: CommandContext) =>
111
138
  return enumChoices;
112
139
  };
113
140
 
114
- const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, context: CommandContext) => {
141
+ export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, context: CommandContext) => {
115
142
  const {
116
143
  name,
117
144
  argsOption: { enum: enumChoices, default: defaultValue, type, desc, nullable, example, ask },
118
145
  } = argMeta;
119
- if (opt[argMeta.name] !== undefined) return convertArgValue(opt[argMeta.name] as string, type ?? "string");
120
- else if (defaultValue !== undefined) return defaultValue;
146
+ if (opt[argMeta.name] !== undefined) {
147
+ assertEnumChoice(argMeta, opt[argMeta.name]);
148
+ return convertArgValue(opt[argMeta.name] as string, type ?? "string");
149
+ } else if (defaultValue !== undefined) return defaultValue;
121
150
 
122
151
  if (enumChoices) {
123
152
  const choices = normalizeEnumChoices((await resolveEnumChoices(argMeta, context)) ?? []);
@@ -139,13 +168,15 @@ const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, co
139
168
  }
140
169
  };
141
170
 
142
- const getArgumentValue = async (argMeta: ArgMeta, value: string | undefined) => {
171
+ export const getArgumentValue = async (argMeta: ArgMeta, value: string | undefined) => {
143
172
  const {
144
173
  name,
145
174
  argsOption: { default: defaultValue, type, desc, nullable, example, ask },
146
175
  } = argMeta;
147
- if (value !== undefined) return convertArgValue(value, type ?? "string");
148
- else if (defaultValue !== undefined) return defaultValue;
176
+ if (value !== undefined) {
177
+ assertEnumChoice(argMeta, value);
178
+ return convertArgValue(value, type ?? "string");
179
+ } else if (defaultValue !== undefined) return defaultValue;
149
180
  else if (nullable) return null;
150
181
 
151
182
  const message = ask
@@ -184,6 +215,46 @@ const assertCurrentDirectoryIsWorkspaceRoot = async () => {
184
215
  );
185
216
  };
186
217
 
218
+ /** A variadic positional arrives as an array, and each entry may itself be a comma-separated list. */
219
+ const parseAppNameList = (value: string | string[] | undefined): string[] =>
220
+ (Array.isArray(value) ? value : value === undefined ? [] : [value])
221
+ .flatMap((entry) => entry.split(","))
222
+ .map((entry) => entry.trim())
223
+ .filter(Boolean);
224
+
225
+ /**
226
+ * The `Apps` token: named apps, `all`, or a checkbox when the command line names none. Kept out of
227
+ * `getInternalArgumentValue` because it is the only internal arg whose positional is variadic, so it is
228
+ * the only one whose raw value is an array.
229
+ */
230
+ export const getAppsArgumentValue = async (
231
+ value: string | string[] | undefined,
232
+ workspace: WorkspaceExecutor,
233
+ ): Promise<AppExecutor[]> => {
234
+ const appNames = await workspace.getApps();
235
+ if (appNames.length === 0) throw new Error("No apps found in this workspace (apps/<appName>/akan.config.ts)");
236
+ const requested = parseAppNameList(value);
237
+ if (requested.includes("all")) return appNames.map((name) => AppExecutor.from(workspace, name));
238
+ if (requested.length) {
239
+ const unknown = requested.filter((name) => !appNames.includes(name));
240
+ if (unknown.length)
241
+ throw new Error(
242
+ `Unknown app${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Available: ${appNames.join(", ")}, all`,
243
+ );
244
+ return [...new Set(requested)].map((name) => AppExecutor.from(workspace, name));
245
+ }
246
+ if (appNames.length === 1 && appNames[0]) return [AppExecutor.from(workspace, appNames[0])];
247
+ const remembered = new Set(await AppSelectionMemory.read(workspace.workspaceRoot));
248
+ const picked = await checkbox<string>({
249
+ message: "Select the apps to run (space to toggle, enter to confirm)",
250
+ choices: appNames.map((name) => ({ name, value: name, checked: remembered.has(name) })),
251
+ // Enter with nothing ticked is a mis-keypress far more often than an intent to run nothing.
252
+ required: true,
253
+ });
254
+ await AppSelectionMemory.write(workspace.workspaceRoot, picked);
255
+ return picked.map((name) => AppExecutor.from(workspace, name));
256
+ };
257
+
187
258
  export const getInternalArgumentValue = async (
188
259
  argMeta: InternalArgMeta,
189
260
  value: string | undefined,
@@ -329,6 +400,11 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
329
400
  `[sys-name:module-name]`,
330
401
  `${argMeta.type} in this workspace (apps|libs)/<sys-name>/lib/<module-name>`,
331
402
  );
403
+ } else if (argMeta.type === "Apps") {
404
+ programCommand = programCommand.argument(
405
+ `[apps...]`,
406
+ `apps in this workspace apps/<appName>, or all (space- or comma-separated; omit to pick interactively)`,
407
+ );
332
408
  } else {
333
409
  const sysType = argMeta.type.toLowerCase();
334
410
  programCommand = programCommand.argument(
@@ -357,6 +433,11 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
357
433
  commandArgs[argMeta.idx] = await getOptionValue(argMeta, opt, commandContext);
358
434
  else if (argMeta.type === "Argument")
359
435
  commandArgs[argMeta.idx] = await getArgumentValue(argMeta, cmdArgs[argMeta.idx] as string);
436
+ else if (argMeta.type === "Apps")
437
+ commandArgs[argMeta.idx] = await getAppsArgumentValue(
438
+ cmdArgs[argMeta.idx] as string | string[] | undefined,
439
+ workspace,
440
+ );
360
441
  else
361
442
  commandArgs[argMeta.idx] = await getInternalArgumentValue(
362
443
  argMeta as InternalArgMeta,
@@ -366,6 +447,12 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
366
447
  // set app name to env
367
448
  if (commandArgs[argMeta.idx] instanceof AppExecutor)
368
449
  process.env.AKAN_PUBLIC_APP_NAME = (commandArgs[argMeta.idx] as AppExecutor).name;
450
+ //? Only when exactly one app resolved. Publishing a name while several are in play would make
451
+ //? every env-derived answer in this process belong to whichever app happened to be last.
452
+ else if (Array.isArray(commandArgs[argMeta.idx])) {
453
+ const apps = commandArgs[argMeta.idx] as AppExecutor[];
454
+ if (apps.length === 1 && apps[0]) process.env.AKAN_PUBLIC_APP_NAME = apps[0].name;
455
+ }
369
456
  assignCommandContext(commandContext, argMeta, commandArgs[argMeta.idx]);
370
457
  if ((opt as { verbose?: boolean }).verbose) Executor.setVerbose(true);
371
458
  }
@@ -23,9 +23,24 @@ type PrimitiveValue<T extends PrimitiveArgType> = T extends StringConstructor
23
23
  ? number
24
24
  : boolean;
25
25
  type MaybeNullable<Value, Option> = Option extends { nullable: true } ? Value | null : Value;
26
+ /**
27
+ * The literal union a static `enum` declares, or `never` for a `DynamicEnum` — a function does not extend
28
+ * a readonly array, so a runtime-resolved choice list falls back to the primitive type. `{ label, value }`
29
+ * choices contribute their `value`.
30
+ */
31
+ type EnumValue<Option> = Option extends { enum: infer Choices }
32
+ ? Choices extends readonly (infer Choice)[]
33
+ ? Choice extends { value: infer Value }
34
+ ? Value
35
+ : Choice
36
+ : never
37
+ : never;
38
+ type ArgValue<Type extends PrimitiveArgType, Option> = [EnumValue<Option>] extends [never]
39
+ ? PrimitiveValue<Type>
40
+ : EnumValue<Option>;
26
41
  type AddArg<Params extends unknown[], Type extends PrimitiveArgType, Option> = [
27
42
  ...Params,
28
- MaybeNullable<PrimitiveValue<Type>, Option>,
43
+ MaybeNullable<ArgValue<Type, Option>, Option>,
29
44
  ];
30
45
  type AddInternalArg<Params extends unknown[], Token extends InternalArgToken> = [...Params, Token["_value"]];
31
46
  type AddInternalArgs<Params extends unknown[], Tokens extends readonly InternalArgToken[]> = Tokens extends readonly [
@@ -68,7 +83,7 @@ class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknow
68
83
  this.#args = args;
69
84
  }
70
85
 
71
- arg<Type extends PrimitiveArgType, Option extends ArgsOption<Context> = ArgsOption<Context>>(
86
+ arg<Type extends PrimitiveArgType, const Option extends ArgsOption<Context> = ArgsOption<Context>>(
72
87
  name: string,
73
88
  type: Type,
74
89
  argsOption: Option = {} as Option,
@@ -85,7 +100,7 @@ class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknow
85
100
  ]);
86
101
  }
87
102
 
88
- option<Type extends PrimitiveArgType, Option extends ArgsOption<Context> = ArgsOption<Context>>(
103
+ option<Type extends PrimitiveArgType, const Option extends ArgsOption<Context> = ArgsOption<Context>>(
89
104
  name: string,
90
105
  type: Type,
91
106
  argsOption: Option = {} as Option,
@@ -135,12 +150,12 @@ type CommandBuilderContext<Deps extends readonly DependencyCls[]> = {
135
150
  public: (targetOption?: Omit<TargetOption, "type">) => TargetBuilder<Deps>;
136
151
  cloud: (targetOption?: Omit<TargetOption, "type">) => TargetBuilder<Deps>;
137
152
  dev: (targetOption?: Omit<TargetOption, "type">) => TargetBuilder<Deps>;
138
- arg: <Type extends PrimitiveArgType, Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
153
+ arg: <Type extends PrimitiveArgType, const Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
139
154
  name: string,
140
155
  type: Type,
141
156
  argsOption?: Option,
142
157
  ) => ArgMeta;
143
- option: <Type extends PrimitiveArgType, Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
158
+ option: <Type extends PrimitiveArgType, const Option extends ArgsOption<CommandContext> = ArgsOption<CommandContext>>(
144
159
  name: string,
145
160
  type: Type,
146
161
  argsOption?: Option,