@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.20

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 (265) hide show
  1. package/README.md +1 -0
  2. package/abstractDoc.test.ts +6 -15
  3. package/abstractDoc.ts +6 -8
  4. package/agentsIndex.test.ts +4 -7
  5. package/agentsIndex.ts +6 -22
  6. package/akanApp/BackendImportGraph.test.ts +8 -22
  7. package/akanApp/BackendImportGraph.ts +1 -20
  8. package/akanApp/akanApp.host.test.ts +1 -3
  9. package/akanApp/akanApp.host.ts +82 -254
  10. package/akanApp/devHostPolicy.test.ts +10 -34
  11. package/akanApp/devHostPolicy.ts +12 -98
  12. package/akanConfig/akanConfig.test.ts +85 -69
  13. package/akanConfig/akanConfig.ts +69 -153
  14. package/akanConfig/types.ts +1 -0
  15. package/akanContext.ts +106 -159
  16. package/akanMcpContract.ts +28 -73
  17. package/appSelectionMemory.ts +1 -4
  18. package/applicationBuildReporter.ts +37 -5
  19. package/applicationBuildRunner.test.ts +5 -3
  20. package/applicationBuildRunner.ts +54 -61
  21. package/applicationReleasePackager.ts +24 -44
  22. package/applicationTestPreload.ts +3 -12
  23. package/artifact/implicitRootLayout.test.ts +9 -30
  24. package/artifact/implicitRootLayout.ts +34 -106
  25. package/artifact/routeSeedIndex.test.ts +7 -6
  26. package/artifact/routeSeedIndex.ts +11 -44
  27. package/biome.base.json +6 -1
  28. package/biomeBase.ts +2 -5
  29. package/capacitorApp.test.ts +32 -16
  30. package/capacitorApp.ts +110 -238
  31. package/cloud/cloudApi.test.ts +202 -0
  32. package/cloud/cloudApi.ts +77 -39
  33. package/cloud/configLock.test.ts +64 -0
  34. package/cloud/configLock.ts +96 -0
  35. package/cloud/constants.ts +26 -5
  36. package/cloud/globalConfig.ts +70 -32
  37. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  38. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  39. package/codeAgent/agent/CodeAgent.ts +551 -0
  40. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  41. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  42. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  43. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  44. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  45. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  46. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  47. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  48. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  49. package/codeAgent/agent/CodeMailbox.ts +148 -0
  50. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  51. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  52. package/codeAgent/agent/SubagentPool.ts +214 -0
  53. package/codeAgent/agent/akanCodeModel.ts +47 -0
  54. package/codeAgent/agent/akanCodePaths.ts +31 -0
  55. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  56. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  57. package/codeAgent/agent/codeAgent.test.ts +178 -0
  58. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  59. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  60. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  61. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  62. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  63. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  64. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  65. package/codeAgent/agent/consoleToStderr.ts +8 -0
  66. package/codeAgent/agent/jsonLines.ts +29 -0
  67. package/codeAgent/agent/stringArg.ts +5 -0
  68. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  69. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  70. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  71. package/codeAgent/feedback/PreviewView.ts +95 -0
  72. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  73. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  74. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  75. package/codeAgent/index.ts +34 -0
  76. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  77. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  78. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  79. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  80. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  81. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  82. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  83. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  84. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  85. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  86. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  87. package/codeAgent/tools/AkanCli.ts +50 -0
  88. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  89. package/codeAgent/tools/AkanEditScope.ts +48 -0
  90. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  91. package/codeAgent/tools/AkanToolPack.ts +92 -0
  92. package/codeAgent/tools/AkanVerifier.ts +48 -0
  93. package/codeAgent/tools/AskToolPack.ts +99 -0
  94. package/codeAgent/tools/MailToolPack.ts +76 -0
  95. package/codeAgent/tools/McpClient.ts +187 -0
  96. package/codeAgent/tools/McpOAuth.ts +233 -0
  97. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  98. package/codeAgent/tools/McpServerConfig.ts +129 -0
  99. package/codeAgent/tools/McpSignIn.ts +158 -0
  100. package/codeAgent/tools/McpTokenStore.ts +81 -0
  101. package/codeAgent/tools/McpToolPack.ts +199 -0
  102. package/codeAgent/tools/SessionToolPack.ts +91 -0
  103. package/codeAgent/tools/WebToolPack.ts +147 -0
  104. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  105. package/codeAgent/tools/askToolPack.test.ts +116 -0
  106. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  107. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  108. package/codegenLock.test.ts +4 -13
  109. package/codegenLock.ts +3 -17
  110. package/commandDecorators/argMeta.ts +1 -5
  111. package/commandDecorators/camelToKebabCase.ts +1 -0
  112. package/commandDecorators/command.ts +46 -102
  113. package/commandDecorators/commandBuilder.ts +30 -61
  114. package/commandDecorators/commandDecorators.test.ts +2 -10
  115. package/commandDecorators/dependencyBuilder.ts +10 -1
  116. package/commandDecorators/helpFormatter.ts +68 -145
  117. package/commandDecorators/index.ts +0 -2
  118. package/commandDecorators/targetMeta.ts +9 -7
  119. package/createTunnel.ts +7 -14
  120. package/dependencyScanner.test.ts +4 -16
  121. package/dependencyScanner.ts +31 -80
  122. package/devkitUtils.test.ts +48 -20
  123. package/executors.linkFallback.test.ts +103 -0
  124. package/executors.test.ts +132 -106
  125. package/executors.ts +194 -484
  126. package/fileEditor.ts +9 -37
  127. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  128. package/fileSys.ts +6 -8
  129. package/formSetterScanner.ts +3 -16
  130. package/frontendBuild/allRoutesBuilder.ts +7 -21
  131. package/frontendBuild/autoImportSync.test.ts +27 -38
  132. package/frontendBuild/autoImportSync.ts +31 -65
  133. package/frontendBuild/buildRouteClient.test.ts +62 -37
  134. package/frontendBuild/bundleDefine.ts +12 -0
  135. package/frontendBuild/clientBuildTypes.ts +5 -36
  136. package/frontendBuild/clientEntriesBundler.ts +9 -40
  137. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  138. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  139. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  140. package/frontendBuild/cssCandidateCache.ts +6 -41
  141. package/frontendBuild/cssCompiler.ts +29 -78
  142. package/frontendBuild/cssImportResolver.ts +6 -19
  143. package/frontendBuild/devChangePlanner.ts +7 -10
  144. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  145. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  146. package/frontendBuild/fontOptimizer.test.ts +6 -14
  147. package/frontendBuild/fontOptimizer.ts +15 -72
  148. package/frontendBuild/fontPruner.test.ts +5 -10
  149. package/frontendBuild/fontPruner.ts +5 -20
  150. package/frontendBuild/frontendBuild.test.ts +46 -69
  151. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  152. package/frontendBuild/hmrWatcher.test.ts +8 -32
  153. package/frontendBuild/hmrWatcher.ts +14 -71
  154. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  155. package/frontendBuild/precompressArtifacts.ts +5 -18
  156. package/frontendBuild/routeClientBuilder.ts +23 -46
  157. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  158. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  159. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  160. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  161. package/frontendBuild/styleContract.ts +1 -8
  162. package/frontendBuild/themeValidator.ts +4 -26
  163. package/frontendBuild/watchRootResolver.test.ts +23 -42
  164. package/frontendBuild/watchRootResolver.ts +5 -27
  165. package/getRelatedCnsts.ts +12 -92
  166. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  167. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  168. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  169. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  170. package/incrementalBuilder/builderChannel.test.ts +24 -49
  171. package/incrementalBuilder/builderChannel.ts +6 -37
  172. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  173. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  174. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  175. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  176. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  177. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  178. package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
  179. package/index.ts +5 -17
  180. package/integration/devResourceProbe.ts +50 -56
  181. package/integration/devStability.integration.test.ts +47 -194
  182. package/integration/devStabilityHarness.test.ts +13 -28
  183. package/integration/devStabilityHarness.ts +65 -330
  184. package/integration/ssrMemoryProbe.ts +41 -167
  185. package/libSource.test.ts +4 -15
  186. package/libSource.ts +19 -31
  187. package/lint/gritRules.test.ts +8 -24
  188. package/lint/no-deep-internal-import.grit +2 -2
  189. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  190. package/linter.test.ts +1 -2
  191. package/linter.ts +26 -116
  192. package/mobile/mobileTarget.ts +14 -13
  193. package/package.json +10 -5
  194. package/packageExports.test.ts +3 -18
  195. package/packageExportsMap.ts +3 -19
  196. package/piDependencyPins.test.ts +46 -0
  197. package/platformTest/BunTestReport.test.ts +60 -0
  198. package/platformTest/BunTestReport.ts +65 -0
  199. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  200. package/platformTest/IdleWatchedProcess.ts +73 -0
  201. package/platformTest/LinuxDockerTarget.ts +105 -0
  202. package/platformTest/PlatformTestRun.test.ts +64 -0
  203. package/platformTest/PlatformTestRun.ts +141 -0
  204. package/platformTest/PlatformTestTarget.ts +142 -0
  205. package/platformTest/TestSnapshot.test.ts +72 -0
  206. package/platformTest/TestSnapshot.ts +103 -0
  207. package/platformTest/WindowsSshTarget.ts +151 -0
  208. package/prompter.ts +3 -13
  209. package/qualityScanner.test.ts +9 -26
  210. package/qualityScanner.ts +197 -285
  211. package/recipeScanner.test.ts +7 -12
  212. package/recipeScanner.ts +9 -38
  213. package/repoIdentity.ts +2 -9
  214. package/routeSourceValidator.test.ts +0 -1
  215. package/routeSourceValidator.ts +7 -42
  216. package/scanInfo.ts +87 -216
  217. package/selectModel.ts +1 -2
  218. package/semver.ts +3 -5
  219. package/slicePlanner.test.ts +6 -24
  220. package/slicePlanner.ts +11 -32
  221. package/spinner.test.ts +3 -8
  222. package/spinner.ts +4 -16
  223. package/sshTunnel.ts +1 -6
  224. package/ssrScanner.test.ts +1 -6
  225. package/ssrScanner.ts +103 -113
  226. package/stripAnsi.ts +3 -0
  227. package/subspace.test.ts +30 -25
  228. package/subspace.ts +81 -115
  229. package/subspaceConfig.ts +11 -9
  230. package/testHelpers.ts +194 -0
  231. package/transforms/asyncDefaultExportDetector.ts +3 -11
  232. package/transforms/barrelAnalyzer.ts +43 -135
  233. package/transforms/barrelImportsPlugin.ts +38 -175
  234. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  235. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  236. package/transforms/moduleSyntax.ts +32 -0
  237. package/transforms/rscUseClientTransform.ts +35 -20
  238. package/transforms/transforms.test.ts +153 -44
  239. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  240. package/transforms/useClientBundlePlugin.ts +2 -22
  241. package/typeChecker.ts +7 -63
  242. package/types.ts +1 -0
  243. package/uploadRelease.ts +10 -25
  244. package/workflow/artifacts.ts +18 -0
  245. package/workflow/executor.test.ts +1 -12
  246. package/workflow/executor.ts +101 -110
  247. package/workflow/index.ts +0 -1
  248. package/workflow/moduleIndex.test.ts +4 -15
  249. package/workflow/moduleIndex.ts +9 -49
  250. package/workflow/plan.ts +18 -25
  251. package/workflow/render.ts +80 -96
  252. package/workflow/rolloutGate.test.ts +1 -1
  253. package/workflow/source.ts +52 -69
  254. package/workflow/sourceAst.ts +50 -0
  255. package/workspaceLayout.ts +4 -19
  256. package/abstractCompactor.ts +0 -112
  257. package/aiEditor.test.ts +0 -60
  258. package/aiEditor.ts +0 -387
  259. package/commandDecorators/commandMeta.ts +0 -7
  260. package/commandDecorators/types.ts +0 -10
  261. package/getDirname.ts +0 -4
  262. package/guideline.ts +0 -19
  263. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  264. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  265. package/workflow/primitive.ts +0 -59
package/executors.ts CHANGED
@@ -7,10 +7,11 @@ import {
7
7
  type SpawnOptions,
8
8
  spawn,
9
9
  } from "node:child_process";
10
- import { readFileSync } from "node:fs";
10
+ import { existsSync, readFileSync } from "node:fs";
11
11
  import {
12
12
  copyFile,
13
13
  cp as cpEntry,
14
+ lstat,
14
15
  mkdir,
15
16
  readdir as readDirEntries,
16
17
  realpath,
@@ -42,14 +43,12 @@ import {
42
43
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
43
44
  import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
44
45
  import { CodegenLock } from "./codegenLock";
45
- import { FileSys } from "./fileSys";
46
- import { getDirname } from "./getDirname";
46
+ import { FileSys, getDirname } from "./fileSys";
47
47
  import { Linter } from "./linter";
48
48
  import { resolveRepoName } from "./repoIdentity";
49
49
  import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
50
50
  import { Spinner } from "./spinner";
51
- // Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
52
- // the resident module graph.
51
+ // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
53
52
  import type { TypeChecker } from "./typeChecker";
54
53
  import type { FileContent, PackageJson, TsConfigJson } from "./types";
55
54
 
@@ -62,41 +61,15 @@ export interface PageRoot {
62
61
  keyPrefix: string;
63
62
  }
64
63
 
65
- const staticTemplateFileExtensions = new Set([
66
- ".avif",
67
- ".bmp",
68
- ".cjs",
69
- ".css",
70
- ".eot",
71
- ".gif",
72
- ".html",
73
- ".ico",
74
- ".jpeg",
75
- ".jpg",
76
- ".js",
77
- ".json",
78
- ".map",
79
- ".md",
80
- ".mjs",
81
- ".mp3",
82
- ".mp4",
83
- ".ogg",
84
- ".otf",
85
- ".pdf",
86
- ".png",
87
- ".svg",
88
- ".ttf",
89
- ".txt",
90
- ".wasm",
91
- ".wav",
92
- ".webm",
93
- ".webp",
94
- ".woff",
95
- ".woff2",
96
- ".xml",
97
- ]);
64
+ const staticTemplateFileExtensions = new Set(
65
+ ".avif .bmp .cjs .css .eot .gif .html .ico .jpeg .jpg .js .json .map .md .mjs .mp3 .mp4 .ogg .otf .pdf .png .svg"
66
+ .split(" ")
67
+ .concat(".ttf .txt .wasm .wav .webm .webp .woff .woff2 .xml".split(" ")),
68
+ );
98
69
 
99
- const formatCommandArg = (value: string) => (/^[\w@%+=:,./-]+$/.test(value) ? value : JSON.stringify(value));
70
+ //? A backslash is a path separator on Windows but an escape in a POSIX shell, so only there does it force quoting.
71
+ const plainCommandArgPattern = process.platform === "win32" ? /^[\w@%+=:,./\\-]+$/ : /^[\w@%+=:,./-]+$/;
72
+ const formatCommandArg = (value: string) => (plainCommandArgPattern.test(value) ? value : JSON.stringify(value));
100
73
 
101
74
  const formatCommandForDisplay = (command: string, args: string[] = []) =>
102
75
  [command, ...args].map(formatCommandArg).join(" ");
@@ -155,7 +128,7 @@ export const execEmoji = {
155
128
  pkg: "📦",
156
129
  dist: "💿",
157
130
  module: "⚙️",
158
- default: "✈️", // for sys executor
131
+ default: "✈️",
159
132
  };
160
133
 
161
134
  const parseEnvFile = (envPath: string): Record<string, string> => {
@@ -183,6 +156,11 @@ const parseEnvFile = (envPath: string): Record<string, string> => {
183
156
  return env;
184
157
  };
185
158
 
159
+ const withCapitalizedDict = (dict: { [key: string]: string } = {}) => ({
160
+ ...dict,
161
+ ...Object.fromEntries(Object.entries(dict).map(([key, value]) => [capitalize(key), capitalize(value)])),
162
+ });
163
+
186
164
  export class Executor {
187
165
  static verbose = false;
188
166
  static setVerbose(verbose: boolean) {
@@ -208,158 +186,53 @@ export class Executor {
208
186
  #stderr(data: Buffer) {
209
187
  Logger.raw(chalk.red(data.toString()));
210
188
  }
211
- exec(command: string, options: ExecOptions = {}) {
212
- const cwd = options.cwd?.toString() ?? this.cwdPath;
213
- const proc = exec(command, { cwd: this.cwdPath, ...options });
189
+ #settle(
190
+ proc: ChildProcess,
191
+ target: { command: string; args?: string[]; cwd: string },
192
+ { onClose = false, keepLogs = false, redStderr = false } = {},
193
+ ): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string }> {
214
194
  let stdout = "";
215
195
  let stderr = "";
216
196
  proc.stdout?.on("data", (data: Buffer) => {
217
197
  stdout += data.toString();
198
+ if (keepLogs) this.logs.push(data.toString());
218
199
  this.#stdout(data);
219
200
  });
220
201
  proc.stderr?.on("data", (data: Buffer) => {
221
202
  stderr += data.toString();
222
- this.#stdout(data); // 정상로그도 stderr로 나옴
203
+ if (keepLogs) this.logs.push(data.toString());
204
+ if (redStderr) this.#stderr(data);
205
+ else this.#stdout(data); // 정상로그도 stderr로 나옴
223
206
  });
224
207
  return new Promise((resolve, reject) => {
225
- proc.on("error", (error) => {
226
- reject(
227
- new CommandExecutionError({
228
- command,
229
- cwd,
230
- code: null,
231
- signal: null,
232
- stdout,
233
- stderr,
234
- cause: error,
235
- }),
236
- );
237
- });
238
- proc.on("exit", (code, signal) => {
239
- if (code || signal)
240
- reject(
241
- new CommandExecutionError({
242
- command,
243
- cwd,
244
- code,
245
- signal,
246
- stdout,
247
- stderr,
248
- }),
249
- );
250
- else resolve({ code, signal });
208
+ proc.on("error", (cause) =>
209
+ reject(new CommandExecutionError({ ...target, code: null, signal: null, stdout, stderr, cause })),
210
+ );
211
+ proc.on(onClose ? "close" : "exit", (code: number | null, signal: NodeJS.Signals | null) => {
212
+ if ((onClose ? code !== 0 : code) || signal)
213
+ reject(new CommandExecutionError({ ...target, code, signal, stdout, stderr }));
214
+ else resolve({ code, signal, stdout });
251
215
  });
252
216
  });
253
217
  }
218
+ exec(command: string, options: ExecOptions = {}): Promise<unknown> {
219
+ const target = { command, cwd: options.cwd?.toString() ?? this.cwdPath };
220
+ const proc = exec(command, { cwd: this.cwdPath, ...options });
221
+ return this.#settle(proc, target).then(({ code, signal }) => ({ code, signal }));
222
+ }
254
223
 
255
224
  spawn(command: string, args: string[] = [], options: SpawnOptions = {}): Promise<string> {
256
- const cwd = options.cwd?.toString() ?? this.cwdPath;
257
- const proc = spawn(command, args, {
258
- cwd: this.cwdPath,
259
- // stdio: "inherit",
260
- ...options,
261
- });
262
- let stdout = "";
263
- let stderr = "";
264
-
265
- proc.stdout?.on("data", (data: Buffer) => {
266
- stdout += data.toString();
267
- this.logs.push(data.toString());
268
- this.#stdout(data);
269
- });
270
- proc.stderr?.on("data", (data: Buffer) => {
271
- stderr += data.toString();
272
- this.logs.push(data.toString());
273
- this.#stdout(data); // 정상로그도 stderr로 나옴
274
- });
275
- return new Promise((resolve, reject) => {
276
- proc.on("error", (error) => {
277
- reject(
278
- new CommandExecutionError({
279
- command,
280
- args,
281
- cwd,
282
- code: null,
283
- signal: null,
284
- stdout,
285
- stderr,
286
- cause: error,
287
- }),
288
- );
289
- });
290
- proc.on("close", (code, signal) => {
291
- if (code !== 0 || signal)
292
- reject(
293
- new CommandExecutionError({
294
- command,
295
- args,
296
- cwd,
297
- code,
298
- signal,
299
- stdout,
300
- stderr,
301
- }),
302
- );
303
- else resolve(stdout);
304
- });
305
- });
225
+ const target = { command, args, cwd: options.cwd?.toString() ?? this.cwdPath };
226
+ const proc = spawn(command, args, { cwd: this.cwdPath, ...options });
227
+ return this.#settle(proc, target, { onClose: true, keepLogs: true }).then(({ stdout }) => stdout);
306
228
  }
307
229
  spawnSync(command: string, args: string[] = [], options: SpawnOptions = {}): ChildProcess {
308
- const proc = spawn(command, args, {
309
- cwd: this.cwdPath,
310
- // stdio: "inherit",
311
- ...options,
312
- });
313
- return proc;
230
+ return spawn(command, args, { cwd: this.cwdPath, ...options });
314
231
  }
315
- fork(modulePath: string, args: string[] = [], options: ForkOptions = {}) {
316
- const cwd = options.cwd?.toString() ?? this.cwdPath;
317
- const proc = fork(modulePath, args, {
318
- cwd: this.cwdPath,
319
- // stdio: ["ignore", "inherit", "inherit", "ipc"],
320
- ...options,
321
- });
322
- let stdout = "";
323
- let stderr = "";
324
- proc.stdout?.on("data", (data: Buffer) => {
325
- stdout += data.toString();
326
- this.#stdout(data);
327
- });
328
- proc.stderr?.on("data", (data: Buffer) => {
329
- stderr += data.toString();
330
- this.#stderr(data);
331
- });
332
- return new Promise((resolve, reject) => {
333
- proc.on("error", (error) => {
334
- reject(
335
- new CommandExecutionError({
336
- command: modulePath,
337
- args,
338
- cwd,
339
- code: null,
340
- signal: null,
341
- stdout,
342
- stderr,
343
- cause: error,
344
- }),
345
- );
346
- });
347
- proc.on("exit", (code, signal) => {
348
- if (code || signal)
349
- reject(
350
- new CommandExecutionError({
351
- command: modulePath,
352
- args,
353
- cwd,
354
- code,
355
- signal,
356
- stdout,
357
- stderr,
358
- }),
359
- );
360
- else resolve({ code, signal });
361
- });
362
- });
232
+ fork(modulePath: string, args: string[] = [], options: ForkOptions = {}): Promise<unknown> {
233
+ const target = { command: modulePath, args, cwd: options.cwd?.toString() ?? this.cwdPath };
234
+ const proc = fork(modulePath, args, { cwd: this.cwdPath, ...options });
235
+ return this.#settle(proc, target, { redStderr: true }).then(({ code, signal }) => ({ code, signal }));
363
236
  }
364
237
  getPath(filePath: string) {
365
238
  if (path.isAbsolute(filePath)) return filePath;
@@ -400,16 +273,14 @@ export class Executor {
400
273
  }
401
274
  async getFilesAndDirs(dirPath: string): Promise<{ files: string[]; dirs: string[] }> {
402
275
  const fullDirPath = this.getPath(dirPath);
403
- const fileGlob = new Bun.Glob("*");
404
- const files = Array.from(fileGlob.scanSync({ cwd: fullDirPath, onlyFiles: true }));
405
- const dirGlob = new Bun.Glob("*");
406
- const allEntries = Array.from(dirGlob.scanSync({ cwd: fullDirPath, onlyFiles: false }));
276
+ const glob = new Bun.Glob("*");
277
+ const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true }));
278
+ const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false }));
407
279
  const dirs = allEntries.filter((entry) => !files.includes(entry));
408
280
  return { files, dirs };
409
281
  }
410
282
  async exists(filePath: string) {
411
- const readPath = this.getPath(filePath);
412
- return await FileSys.exists(readPath);
283
+ return await FileSys.exists(this.getPath(filePath));
413
284
  }
414
285
  async remove(filePath: string) {
415
286
  const readPath = this.getPath(filePath);
@@ -418,8 +289,7 @@ export class Executor {
418
289
  return this;
419
290
  }
420
291
  async removeDir(dirPath: string) {
421
- //* XXX: `path.join(…, ".")` drops a trailing separator — `rm -rf link/` resolves through a symlink
422
- //* and wipes its target, while `rm -rf link` only unlinks it.
292
+ //* XXX: `path.join(…, ".")` drops a trailing slash: `rm -rf link/` wipes the target, `rm -rf link` only unlinks.
423
293
  const readPath = path.join(this.getPath(dirPath), ".");
424
294
  if (await FileSys.entryExists(readPath)) await rm(readPath, { recursive: true, force: true });
425
295
  this.logger.verbose(`Remove directory ${readPath}`);
@@ -433,8 +303,7 @@ export class Executor {
433
303
  const writePath = this.getPath(filePath);
434
304
  const dir = path.dirname(writePath);
435
305
  if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
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.
306
+ //? Biome ends every .json with a newline; without one, `akan lint` and `akan sync` would flip-flop the file.
438
307
  let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
439
308
 
440
309
  if (await FileSys.fileExists(writePath)) {
@@ -461,12 +330,10 @@ export class Executor {
461
330
  return { filePath, content };
462
331
  }
463
332
  async readFile(filePath: string) {
464
- const readPath = this.getPath(filePath);
465
- return await FileSys.readText(readPath);
333
+ return await FileSys.readText(this.getPath(filePath));
466
334
  }
467
335
  async readJson(filePath: string) {
468
- const readPath = this.getPath(filePath);
469
- return await FileSys.readJson<object>(readPath);
336
+ return await FileSys.readJson<object>(this.getPath(filePath));
470
337
  }
471
338
  async cp(srcPath: string, destPath: string, { dereference = false }: { dereference?: boolean } = {}) {
472
339
  const src = this.getPath(srcPath);
@@ -474,9 +341,10 @@ export class Executor {
474
341
  if (!(await FileSys.exists(src))) return;
475
342
  const isDirectory = (await stat(src)).isDirectory();
476
343
  if (!(await FileSys.exists(dest)) && isDirectory) await mkdir(dest, { recursive: true });
477
- //* `cp -r` keeps symlinks on GNU coreutils but follows them on macOS, so anything that must land as
478
- //* real files regardless of platform has to say so explicitly.
479
- if (dereference) await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
344
+ //* `cp -r` keeps symlinks on GNU but follows them on macOS; Windows has no `cp -r <dir>/.` and rarely the
345
+ //* privilege a symlink needs, so it always copies real files.
346
+ if (dereference || process.platform === "win32")
347
+ await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
480
348
  else await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
481
349
  }
482
350
  log(msg: string) {
@@ -498,19 +366,10 @@ export class Executor {
498
366
  #tsconfig: TsConfigJson | null = null;
499
367
  async getTsConfig(pathname = "tsconfig.json", { refresh }: { refresh?: boolean } = {}): Promise<TsConfigJson> {
500
368
  if (this.#tsconfig && !refresh) return this.#tsconfig;
501
- const tsconfig = (await this.readJson(pathname)) as TsConfigJson;
369
+ let tsconfig = (await this.readJson(pathname)) as TsConfigJson;
502
370
  if (tsconfig.extends) {
503
- const extendsTsconfig = await this.getTsConfig(tsconfig.extends);
504
- const result = {
505
- ...extendsTsconfig,
506
- ...tsconfig,
507
- compilerOptions: {
508
- ...extendsTsconfig.compilerOptions,
509
- ...tsconfig.compilerOptions,
510
- },
511
- } as TsConfigJson;
512
- this.#tsconfig = result;
513
- return result;
371
+ const base = await this.getTsConfig(tsconfig.extends, { refresh });
372
+ tsconfig = { ...base, ...tsconfig, compilerOptions: { ...base.compilerOptions, ...tsconfig.compilerOptions } };
514
373
  }
515
374
  this.#tsconfig = tsconfig;
516
375
  return tsconfig;
@@ -558,6 +417,11 @@ export class Executor {
558
417
  dict: { [key: string]: string } = {},
559
418
  options: { [key: string]: unknown } = {},
560
419
  ): Promise<FileContent | null> {
420
+ const convertPath = (target: string) =>
421
+ Object.entries(dict).reduce(
422
+ (converted, [key, value]) => converted.replace(new RegExp(`__${key}__`, "g"), value),
423
+ target,
424
+ );
561
425
  if (targetPath.endsWith(".ts") || targetPath.endsWith(".tsx")) {
562
426
  const getContent = (await import(templatePath)) as {
563
427
  default: (
@@ -570,32 +434,20 @@ export class Executor {
570
434
  if (result === null) return null;
571
435
  const filename = typeof result === "object" ? result.filename : path.basename(targetPath).replace(".js", ".ts");
572
436
  const content = typeof result === "object" ? result.content : result;
573
- const dirname = path.dirname(targetPath);
574
- const convertedTargetPath = Object.entries(dict).reduce(
575
- (path, [key, value]) => path.replace(new RegExp(`__${key}__`, "g"), value),
576
- `${dirname}/${filename}`,
577
- );
437
+ const convertedTargetPath = convertPath(`${path.dirname(targetPath)}/${filename}`);
578
438
  this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
579
439
  return this.writeFile(convertedTargetPath, content, { overwrite });
580
440
  } else if (targetPath.endsWith(".template")) {
581
441
  const content = await FileSys.readText(templatePath);
582
- const convertedTargetPath = Object.entries(dict).reduce(
583
- (path, [key, value]) => path.replace(new RegExp(`__${key}__`, "g"), value),
584
- targetPath.slice(0, -9),
585
- );
442
+ const convertedTargetPath = convertPath(targetPath.slice(0, -9));
586
443
  const convertedContent = Object.entries(dict).reduce(
587
444
  (data, [key, value]) => data.replace(new RegExp(`<%= ${key} %>`, "g"), value),
588
445
  content,
589
446
  );
590
447
  this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
591
- return this.writeFile(convertedTargetPath, convertedContent, {
592
- overwrite,
593
- });
448
+ return this.writeFile(convertedTargetPath, convertedContent, { overwrite });
594
449
  } else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
595
- const convertedTargetPath = Object.entries(dict).reduce(
596
- (path, [key, value]) => path.replace(new RegExp(`__${key}__`, "g"), value),
597
- targetPath,
598
- );
450
+ const convertedTargetPath = convertPath(targetPath);
599
451
  const writePath = this.getPath(convertedTargetPath);
600
452
  const dirname = path.dirname(writePath);
601
453
  if (!(await FileSys.dirExists(dirname))) await mkdir(dirname, { recursive: true });
@@ -621,13 +473,11 @@ export class Executor {
621
473
  }): Promise<FileContent[]> {
622
474
  const templateRoot = await this.#resolveTemplateRoot();
623
475
  const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
624
- const prefixTemplatePath = templatePath; // templatePath.endsWith(".tsx") ? templatePath : templatePath.replace(".ts", ".js");
625
- if ((await stat(prefixTemplatePath)).isFile()) {
626
- const filename = path.basename(prefixTemplatePath);
476
+ if ((await stat(templatePath)).isFile()) {
627
477
  const fileContent = await this.#applyTemplateFile(
628
478
  {
629
- templatePath: prefixTemplatePath,
630
- targetPath: path.join(basePath, filename),
479
+ templatePath,
480
+ targetPath: path.join(basePath, path.basename(templatePath)),
631
481
  scanInfo,
632
482
  overwrite,
633
483
  },
@@ -685,25 +535,13 @@ export class Executor {
685
535
  options?: { [key: string]: unknown };
686
536
  overwrite?: boolean;
687
537
  }): Promise<FileContent[]> {
688
- const dict = {
689
- ...(options.dict ?? {}),
690
- ...Object.fromEntries(
691
- Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
692
- ),
693
- };
694
- const fileContents = await this._applyTemplate({ ...options, dict });
538
+ const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
695
539
  await this.#formatAppliedTemplate(fileContents);
696
540
  return fileContents;
697
541
  }
698
542
 
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
- */
543
+ // A template cannot sort the identifiers it emits (`Task` vs `Zoo`). Best-effort: `create-akan-workspace`
544
+ // scaffolds before `bun install`, with no local Biome binary yet.
707
545
  async #formatAppliedTemplate(fileContents: FileContent[]) {
708
546
  const filePaths = fileContents
709
547
  .map((fileContent) => fileContent.filePath)
@@ -716,9 +554,7 @@ export class Executor {
716
554
  this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
717
555
  }
718
556
  }
719
- // Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
720
- // not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
721
- // never touches this path.
557
+ // Async so `typescript` (~65MB resident) loads only in the commands that typecheck.
722
558
  async getTypeChecker() {
723
559
  const { TypeChecker } = await import("./typeChecker");
724
560
  this.typeChecker ??= new TypeChecker(this);
@@ -784,10 +620,10 @@ export class Executor {
784
620
  filePath: string,
785
621
  { fix = false, dryRun = false }: { fix?: boolean; dryRun?: boolean } = {},
786
622
  ): Promise<{
787
- results: unknown[]; // ESLint.LintResult[];
623
+ results: unknown[];
788
624
  message: string;
789
- errors: unknown[]; // ESLintLinter.LintMessage[];
790
- warnings: unknown[]; // ESLintLinter.LintMessage[];
625
+ errors: unknown[];
626
+ warnings: unknown[];
791
627
  }> {
792
628
  const path = this.getPath(filePath);
793
629
  const linter = this.getLinter();
@@ -814,7 +650,6 @@ export class WorkspaceExecutor extends Executor {
814
650
  this.repoName = repoName;
815
651
  }
816
652
 
817
- static #execs = new Map<string, WorkspaceExecutor>();
818
653
  static fromRoot({
819
654
  workspaceRoot = process.cwd(),
820
655
  repoName = resolveRepoName(workspaceRoot),
@@ -822,7 +657,7 @@ export class WorkspaceExecutor extends Executor {
822
657
  workspaceRoot?: string;
823
658
  repoName?: string;
824
659
  } = {}) {
825
- return WorkspaceExecutor.#execs.get(repoName) ?? new WorkspaceExecutor({ workspaceRoot, repoName });
660
+ return new WorkspaceExecutor({ workspaceRoot, repoName });
826
661
  }
827
662
  static getBaseDevEnv(envPath?: string) {
828
663
  // Bun auto-loads .env, so we use process.env directly
@@ -871,11 +706,9 @@ export class WorkspaceExecutor extends Executor {
871
706
  return await WorkspaceInfo.fromExecutor(this);
872
707
  }
873
708
  async getApps() {
874
- if (!(await FileSys.dirExists(`${this.workspaceRoot}/apps`))) return [];
875
709
  return await this.#getDirHasFile(`${this.workspaceRoot}/apps`, "akan.config.ts");
876
710
  }
877
711
  async getLibs() {
878
- if (!(await FileSys.dirExists(`${this.workspaceRoot}/libs`))) return [];
879
712
  return await this.#getDirHasFile(`${this.workspaceRoot}/libs`, "akan.config.ts");
880
713
  }
881
714
  async getSyss() {
@@ -883,7 +716,6 @@ export class WorkspaceExecutor extends Executor {
883
716
  return [appNames, libNames] as [string[], string[]];
884
717
  }
885
718
  async getPkgs() {
886
- if (!(await FileSys.dirExists(`${this.workspaceRoot}/pkgs`))) return [];
887
719
  return await this.#getDirHasFile(`${this.workspaceRoot}/pkgs`, "package.json");
888
720
  }
889
721
  async getExecs() {
@@ -946,13 +778,7 @@ export class WorkspaceExecutor extends Executor {
946
778
  async hasChanges() {
947
779
  return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
948
780
  }
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
- */
781
+ /** Sorted workspace-relative paths; `untracked` adds uncommitted files, still honoring `.gitignore`. */
956
782
  async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
957
783
  if (!paths.length) return [];
958
784
  const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
@@ -963,6 +789,7 @@ export class WorkspaceExecutor extends Executor {
963
789
  .sort();
964
790
  }
965
791
  async #getDirHasFile(basePath: string, targetFilename: string) {
792
+ if (!(await FileSys.dirExists(basePath))) return [];
966
793
  const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
967
794
  const getDirs = async (dirname: string, maxDepth = 3, results: string[] = [], prefix = "") => {
968
795
  const dirs = await this.readdir(dirname);
@@ -970,8 +797,7 @@ export class WorkspaceExecutor extends Executor {
970
797
  dirs.map(async (dir) => {
971
798
  if (AVOID_DIRS.includes(dir)) return;
972
799
  const dirPath = path.join(dirname, dir);
973
- //* A dangling symlink (e.g. a synced lib page whose source was deleted) must not fail the walk —
974
- //* this runs for `getApps`, so throwing here would break every command until it is repaired.
800
+ //* Not `stat`: a dangling symlink would throw, and this walk backs `getApps`, i.e. every command.
975
801
  if (await FileSys.dirExists(dirPath)) {
976
802
  const hasTargetFile = await FileSys.fileExists(path.join(dirPath, targetFilename));
977
803
  if (hasTargetFile) results.push(`${prefix}${dir}`);
@@ -981,50 +807,34 @@ export class WorkspaceExecutor extends Executor {
981
807
  );
982
808
  return results;
983
809
  };
984
- return await getDirs(basePath);
810
+ return (await getDirs(basePath)).sort((a, b) => a.localeCompare(b));
985
811
  }
986
812
 
987
- async getScalarConstantFiles() {
813
+ async #fromEverySys<T>(read: (sys: SysExecutor) => Promise<T[]>): Promise<T[]> {
988
814
  const [appNames, libNames] = await this.getSyss();
989
- const scalarConstantExampleFiles = [
990
- ...(
991
- await Promise.all(appNames.map((appName) => AppExecutor.from(this, appName).getScalarConstantFiles()))
992
- ).flat(),
993
- ...(
994
- await Promise.all(libNames.map((libName) => LibExecutor.from(this, libName).getScalarConstantFiles()))
995
- ).flat(),
996
- ];
997
- return scalarConstantExampleFiles;
815
+ const fromApps = await Promise.all(appNames.map((appName) => read(AppExecutor.from(this, appName))));
816
+ const fromLibs = await Promise.all(libNames.map((libName) => read(LibExecutor.from(this, libName))));
817
+ return [...fromApps.flat(), ...fromLibs.flat()];
818
+ }
819
+ async getScalarConstantFiles() {
820
+ return await this.#fromEverySys((sys) => sys.getScalarConstantFiles());
998
821
  }
999
822
  async getConstantFiles() {
1000
- const [appNames, libNames] = await this.getSyss();
1001
- const moduleConstantExampleFiles = [
1002
- ...(await Promise.all(appNames.map((appName) => AppExecutor.from(this, appName).getConstantFiles()))).flat(),
1003
- ...(await Promise.all(libNames.map((libName) => LibExecutor.from(this, libName).getConstantFiles()))).flat(),
1004
- ];
1005
- return moduleConstantExampleFiles;
823
+ return await this.#fromEverySys((sys) => sys.getConstantFiles());
1006
824
  }
1007
825
  async getDictionaryFiles() {
1008
- const [appNames, libNames] = await this.getSyss();
1009
- const moduleDictionaryExampleFiles = [
1010
- ...(await Promise.all(appNames.map((appName) => AppExecutor.from(this, appName).getDictionaryFiles()))).flat(),
1011
- ...(await Promise.all(libNames.map((libName) => LibExecutor.from(this, libName).getDictionaryFiles()))).flat(),
1012
- ];
1013
- return moduleDictionaryExampleFiles;
826
+ return await this.#fromEverySys((sys) => sys.getDictionaryFiles());
1014
827
  }
1015
828
  async getViewFiles() {
1016
- const [appNames, libNames] = await this.getSyss();
1017
- const viewExampleFiles = [
1018
- ...(await Promise.all(appNames.map((appName) => AppExecutor.from(this, appName).getViewsSourceCode()))).flat(),
1019
- ...(await Promise.all(libNames.map((libName) => LibExecutor.from(this, libName).getViewsSourceCode()))).flat(),
1020
- ];
1021
- return viewExampleFiles;
829
+ return await this.#fromEverySys((sys) => sys.getViewsSourceCode());
1022
830
  }
1023
831
  }
1024
832
 
1025
- interface SysExecutorOptions {
833
+ interface NamedExecutorOptions {
1026
834
  workspace?: WorkspaceExecutor;
1027
835
  name: string;
836
+ }
837
+ interface SysExecutorOptions extends NamedExecutorOptions {
1028
838
  type: "app" | "lib";
1029
839
  }
1030
840
 
@@ -1093,29 +903,12 @@ export class SysExecutor extends Executor {
1093
903
  options: { exec: this, facet },
1094
904
  }),
1095
905
  ),
1096
- ...scanInfo.getDatabaseModules().map((model) =>
1097
- this._applyTemplate({
1098
- basePath: `lib/${model}`,
1099
- template: "moduleRoot",
1100
- scanInfo,
1101
- dict: { model, Model: capitalize(model) },
1102
- }),
1103
- ),
1104
- ...scanInfo.getServiceModules().map((model) =>
1105
- this._applyTemplate({
1106
- basePath: `lib/_${model}`,
1107
- template: "moduleRoot",
1108
- scanInfo,
1109
- dict: { model, Model: capitalize(model) },
1110
- }),
1111
- ),
1112
- ...scanInfo.getScalarModules().map((model) =>
1113
- this._applyTemplate({
1114
- basePath: `lib/__scalar/${model}`,
1115
- template: "moduleRoot",
1116
- scanInfo,
1117
- dict: { model, Model: capitalize(model) },
1118
- }),
906
+ ...[
907
+ ...scanInfo.getDatabaseModules().map((model) => [model, `lib/${model}`]),
908
+ ...scanInfo.getServiceModules().map((model) => [model, `lib/_${model}`]),
909
+ ...scanInfo.getScalarModules().map((model) => [model, `lib/__scalar/${model}`]),
910
+ ].map(([model, basePath]) =>
911
+ this._applyTemplate({ basePath, template: "moduleRoot", scanInfo, dict: { model, Model: capitalize(model) } }),
1119
912
  ),
1120
913
  ];
1121
914
  }
@@ -1137,9 +930,7 @@ export class SysExecutor extends Executor {
1137
930
  : await LibInfo.fromExecutor(this as unknown as LibExecutor, {
1138
931
  refresh,
1139
932
  });
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.
933
+ //* Locked: `writeLib` rewrites dependency-lib barrels that other dev servers regenerate and watch too.
1143
934
  if (write)
1144
935
  await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
1145
936
  await Promise.all(this.#getScanTemplateTasks(scanInfo));
@@ -1156,11 +947,7 @@ export class SysExecutor extends Executor {
1156
947
  this.#scanInfo = scanInfo;
1157
948
  return scanInfo;
1158
949
  }
1159
- /**
1160
- * 스코프 에이전트 색인(apps|libs/<name>/AGENTS.md) 재생성 — own + 의존 lib 레시피만 싣는다(프레임워크
1161
- * 레시피는 루트 AGENTS.md 소관). scan(write) 경로에 물려 있어 sync/build/start 어디를 지나도 갱신되고,
1162
- * `akan lint` 가 같은 렌더 결과와 비교해 신선도를 강제한다. 마커 밖 내용은 사용자 소유라 보존한다.
1163
- */
950
+ /** Rewrites only the marker block of the scoped AGENTS.md; content outside the markers belongs to the user. */
1164
951
  async syncAgentsIndex(scanInfo?: AppInfo | LibInfo) {
1165
952
  const info = scanInfo ?? (await this.scan({ write: false }));
1166
953
  const scope = { type: this.type, name: this.name };
@@ -1215,69 +1002,49 @@ export class SysExecutor extends Executor {
1215
1002
  }
1216
1003
 
1217
1004
  async getDatabaseModules() {
1218
- const databaseModules = (await this.readdir("lib"))
1219
- .filter((name) => !name.startsWith("_") && !name.startsWith("__") && !name.endsWith(".ts"))
1220
- .filter((name) => Bun.file(`${this.cwdPath}/lib/${name}/${name}.constant.ts`).exists());
1221
- return databaseModules;
1005
+ return (await this.readdir("lib"))
1006
+ .filter((name) => !name.startsWith("_") && !name.endsWith(".ts"))
1007
+ .filter((name) => existsSync(`${this.cwdPath}/lib/${name}/${name}.constant.ts`));
1222
1008
  }
1223
1009
 
1224
1010
  async getServiceModules() {
1225
- const serviceModules = (await this.readdir("lib"))
1011
+ return (await this.readdir("lib"))
1226
1012
  .filter((name) => name.startsWith("_") && !name.startsWith("__"))
1227
- .filter((name) => Bun.file(`${this.cwdPath}/lib/${name}/${name}.service.ts`).exists());
1228
- return serviceModules;
1013
+ .filter((name) => existsSync(`${this.cwdPath}/lib/${name}/${name.slice(1)}.service.ts`));
1229
1014
  }
1230
1015
 
1231
1016
  async getScalarModules() {
1232
- const scalarModules = (await this.readdir("lib/__scalar"))
1017
+ return (await this.readdir("lib/__scalar"))
1233
1018
  .filter((name) => !name.startsWith("_"))
1234
- .filter((name) => Bun.file(`${this.cwdPath}/lib/__scalar/${name}/${name}.constant.ts`).exists());
1235
- return scalarModules;
1019
+ .filter((name) => existsSync(`${this.cwdPath}/lib/__scalar/${name}/${name}.constant.ts`));
1236
1020
  }
1237
1021
 
1022
+ async #getComponentModules(role: "View" | "Unit" | "Template") {
1023
+ return (await this.readdir("lib"))
1024
+ .filter((name) => !name.startsWith("_") && !name.endsWith(".ts"))
1025
+ .filter((name) => existsSync(`${this.cwdPath}/lib/${name}/${capitalize(name)}.${role}.tsx`));
1026
+ }
1027
+ async #getComponentSources(role: "View" | "Unit" | "Template") {
1028
+ const modules = await this.#getComponentModules(role);
1029
+ return Promise.all(modules.map((name) => this.getLocalFile(`lib/${name}/${capitalize(name)}.${role}.tsx`)));
1030
+ }
1238
1031
  async getViewComponents() {
1239
- const viewComponents = (await this.readdir("lib"))
1240
- .filter((name) => !name.startsWith("_") && !name.startsWith("__") && !name.endsWith(".ts"))
1241
- .filter((name) => Bun.file(`${this.cwdPath}/lib/${name}/${capitalize(name)}.View.tsx`).exists());
1242
- return viewComponents;
1032
+ return await this.#getComponentModules("View");
1243
1033
  }
1244
-
1245
1034
  async getUnitComponents() {
1246
- const unitComponents = (await this.readdir("lib"))
1247
- .filter((name) => !name.startsWith("_") && !name.startsWith("__") && !name.endsWith(".ts"))
1248
- .filter((name) => Bun.file(`${this.cwdPath}/lib/${name}/${capitalize(name)}.Unit.tsx`).exists());
1249
- return unitComponents;
1035
+ return await this.#getComponentModules("Unit");
1250
1036
  }
1251
1037
  async getTemplateComponents() {
1252
- const templateComponents = (await this.readdir("lib"))
1253
- .filter((name) => !name.startsWith("_") && !name.startsWith("__") && !name.endsWith(".ts"))
1254
- .filter((name) => Bun.file(`${this.cwdPath}/lib/${name}/${capitalize(name)}.Template.tsx`).exists());
1255
- return templateComponents;
1038
+ return await this.#getComponentModules("Template");
1256
1039
  }
1257
-
1258
1040
  async getViewsSourceCode() {
1259
- const viewComponents = await this.getViewComponents();
1260
- return Promise.all(
1261
- viewComponents.map((viewComponent) =>
1262
- this.getLocalFile(`lib/${viewComponent}/${capitalize(viewComponent)}.View.tsx`),
1263
- ),
1264
- );
1041
+ return await this.#getComponentSources("View");
1265
1042
  }
1266
1043
  async getUnitsSourceCode() {
1267
- const unitComponents = await this.getUnitComponents();
1268
- return Promise.all(
1269
- unitComponents.map((unitComponent) =>
1270
- this.getLocalFile(`lib/${unitComponent}/${capitalize(unitComponent)}.Unit.tsx`),
1271
- ),
1272
- );
1044
+ return await this.#getComponentSources("Unit");
1273
1045
  }
1274
1046
  async getTemplatesSourceCode() {
1275
- const templateComponents = await this.getTemplateComponents();
1276
- return Promise.all(
1277
- templateComponents.map((templateComponent) =>
1278
- this.getLocalFile(`lib/${templateComponent}/${capitalize(templateComponent)}.Template.tsx`),
1279
- ),
1280
- );
1047
+ return await this.#getComponentSources("Template");
1281
1048
  }
1282
1049
 
1283
1050
  async getScalarConstantFiles() {
@@ -1292,7 +1059,9 @@ export class SysExecutor extends Executor {
1292
1059
  async getScalarDictionaryFiles() {
1293
1060
  const scalarModules = await this.getScalarModules();
1294
1061
  return Promise.all(
1295
- scalarModules.map((scalarModule) => this.getLocalFile(`lib/${scalarModule}/${scalarModule}.dictionary.ts`)),
1062
+ scalarModules.map((scalarModule) =>
1063
+ this.getLocalFile(`lib/__scalar/${scalarModule}/${scalarModule}.dictionary.ts`),
1064
+ ),
1296
1065
  );
1297
1066
  }
1298
1067
 
@@ -1326,30 +1095,15 @@ export class SysExecutor extends Executor {
1326
1095
  dict?: { [key: string]: string };
1327
1096
  overwrite?: boolean;
1328
1097
  }): Promise<FileContent[]> {
1329
- const dict = {
1330
- ...(options.dict ?? {}),
1331
- ...Object.fromEntries(
1332
- Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
1333
- ),
1334
- };
1335
1098
  const scanInfo = await this.scan();
1336
- const fileContents = await this._applyTemplate({
1337
- ...options,
1338
- scanInfo,
1339
- dict,
1340
- });
1099
+ const fileContents = await this._applyTemplate({ ...options, scanInfo, dict: withCapitalizedDict(options.dict) });
1341
1100
  await this.scan();
1342
1101
  return fileContents;
1343
1102
  }
1344
1103
  }
1345
1104
 
1346
- interface AppExecutorOptions {
1347
- workspace?: WorkspaceExecutor;
1348
- name: string;
1349
- }
1350
1105
  export class AppExecutor extends SysExecutor {
1351
- // `typescript` costs ~65MB resident, so keep it out of the module graph of every process that only
1352
- // ever imports an executor. Route validation is the sole consumer here and is already async.
1106
+ // Lazy: the validator pulls in `typescript` (~65MB resident).
1353
1107
  static #routeSourceValidator: typeof import("./routeSourceValidator").RouteSourceValidator | null = null;
1354
1108
  static async #getRouteSourceValidator() {
1355
1109
  AppExecutor.#routeSourceValidator ??= (await import("./routeSourceValidator")).RouteSourceValidator;
@@ -1357,29 +1111,17 @@ export class AppExecutor extends SysExecutor {
1357
1111
  }
1358
1112
  dist: Executor;
1359
1113
  override emoji = execEmoji.app;
1360
- constructor({ workspace, name }: AppExecutorOptions) {
1114
+ constructor({ workspace, name }: NamedExecutorOptions) {
1361
1115
  super({ workspace, name, type: "app" });
1362
1116
  this.dist = new Executor(`dist/${name}`, `${this.workspace.workspaceRoot}/dist/apps/${name}`);
1363
1117
  }
1364
- static #execs = new Map<string, AppExecutor>();
1365
1118
  static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
1366
- const exec = AppExecutor.#execs.get(name);
1367
- if (exec) return exec;
1368
- else if (executor instanceof WorkspaceExecutor) return new AppExecutor({ workspace: executor, name });
1369
- else return new AppExecutor({ workspace: executor.workspace, name });
1119
+ return new AppExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1370
1120
  }
1371
1121
  getEnv() {
1372
1122
  return WorkspaceExecutor.getBaseDevEnv().env;
1373
1123
  }
1374
- /**
1375
- * This app's dev port, derived from its position in the sorted `apps/` listing so several apps can run
1376
- * at once without colliding.
1377
- *
1378
- * `AKAN_DEV_PORT` pins it instead, because the derived value *moves*: the index shifts whenever any other
1379
- * app directory appears or disappears, and a dev host recomputes this on every restart. So a session that
1380
- * adds an app relands its dev server on a different port, and anything that reserved a port relative to
1381
- * the old one is left pointing at nothing.
1382
- */
1124
+ /** `AKAN_DEV_PORT` pins it; the derived port moves whenever an app directory appears or disappears. */
1383
1125
  async getDevPort() {
1384
1126
  const pinned = Number(process.env.AKAN_DEV_PORT);
1385
1127
  if (Number.isInteger(pinned) && pinned > 0 && pinned <= 65_535) return pinned;
@@ -1390,10 +1132,8 @@ export class AppExecutor extends SysExecutor {
1390
1132
  return basePort + appIndex + portOffset;
1391
1133
  }
1392
1134
  getCommandEnv(env: Record<string, string> = {}): Record<string, string> {
1393
- const basePort = 8282;
1394
- const portOffset = WorkspaceExecutor.getBaseDevEnv().portOffset;
1395
- const PORT = (basePort + portOffset).toString();
1396
- const AKAN_PUBLIC_SERVER_PORT = portOffset ? (8282 + portOffset).toString() : undefined;
1135
+ const { portOffset } = WorkspaceExecutor.getBaseDevEnv();
1136
+ const PORT = (8282 + portOffset).toString();
1397
1137
  return {
1398
1138
  ...process.env,
1399
1139
  AKAN_PUBLIC_APP_NAME: this.name,
@@ -1401,48 +1141,48 @@ export class AppExecutor extends SysExecutor {
1401
1141
  NODE_NO_WARNINGS: "1",
1402
1142
  PORT,
1403
1143
  AKAN_PUBLIC_CLIENT_PORT: PORT,
1404
- ...(AKAN_PUBLIC_SERVER_PORT ? { AKAN_PUBLIC_SERVER_PORT } : {}),
1144
+ ...(portOffset ? { AKAN_PUBLIC_SERVER_PORT: PORT } : {}),
1405
1145
  ...env,
1406
1146
  };
1407
1147
  }
1148
+ async getDatabaseModeEnv() {
1149
+ const akanConfig = await this.getConfig();
1150
+ return {
1151
+ AKAN_DATABASE_MODE: akanConfig.resolveDatabaseMode(),
1152
+ AKAN_DATABASE_MODES: akanConfig.database.modes.join(","),
1153
+ };
1154
+ }
1408
1155
  async prepareCommand(type: "build" | "start") {
1409
1156
  const akanConfig = await this.getConfig();
1410
- const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
1411
1157
  const routeEnv = {
1412
1158
  AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
1413
1159
  AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
1414
1160
  AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
1415
- AKAN_DATABASE_MODE: databaseMode,
1161
+ ...(await this.getDatabaseModeEnv()),
1416
1162
  };
1417
1163
  Object.assign(process.env, routeEnv);
1418
1164
  if (type === "build") {
1419
- //* `scanSync` already read the route set, and it reads it unfiltered — dev-only routes are still
1420
- //* generated against and typechecked. Drop the cache so the build phases re-read it without them.
1165
+ //* `scanSync` read the route set unfiltered; drop the cache so build phases re-read it without dev-only routes.
1421
1166
  this.#excludeDevOnlyPages = true;
1422
1167
  this.#pageKeys = null;
1423
1168
  await this.dist.removeDir(this.dist.cwdPath);
1424
1169
  await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
1425
- //* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
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.
1170
+ //* Lib assets are symlinks, which neither the docker context nor the release tarball follows out of dist.
1171
+ //* Only the web router reads `public/`, so an api-only build leaves it out.
1428
1172
  await Promise.all([
1429
1173
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1430
1174
  ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1431
1175
  ]);
1432
1176
  } else {
1433
1177
  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.
1178
+ //? `akan start` keeps the full dev surface: the incremental builder is also the file watcher.
1436
1179
  if (!akanConfig.web.ssr || !akanConfig.web.csr)
1437
1180
  this.logger.verbose(
1438
1181
  `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1439
1182
  );
1440
1183
  }
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.
1184
+ //? Bun auto-loads `.env`, so NODE_ENV=production can arrive unexported and send the dev server to the production
1185
+ //? route cache. Set on `process.env` because the builder runs here and bakes NODE_ENV into its bundles.
1446
1186
  if (type === "start") process.env.NODE_ENV = "development";
1447
1187
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1448
1188
  const env = this.getCommandEnv({
@@ -1450,18 +1190,14 @@ export class AppExecutor extends SysExecutor {
1450
1190
  ...routeEnv,
1451
1191
  ...(devPort ? { PORT: devPort, AKAN_PUBLIC_CLIENT_PORT: devPort, AKAN_PUBLIC_SERVER_PORT: devPort } : {}),
1452
1192
  });
1453
- // `start` spawns subprocesses that carry `env`, but `build` runs its phases in this same process and
1454
- // reads `process.env` directly. Publish the resolved env here so SSR/CSR bundling (fed by getPublicEnv,
1455
- // which filters to AKAN_PUBLIC_*) sees AKAN_PUBLIC_APP_NAME — otherwise SSR throws
1456
- // "environment variable AKAN_PUBLIC_APP_NAME is required". Only AKAN_PUBLIC_* is baked into bundles, so
1457
- // this does not leak non-public 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.
1193
+ // `build` runs its phases in this process, so the env is published here (only AKAN_PUBLIC_* reaches bundles).
1194
+ // Ports and the operation mode are dropped: `define` would bake them in and outrank the container's own values.
1461
1195
  if (type === "build") {
1462
1196
  const buildEnv = { ...env };
1463
1197
  delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1464
1198
  delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1199
+ delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
1200
+ delete process.env.AKAN_PUBLIC_OPERATION_MODE;
1465
1201
  Object.assign(process.env, buildEnv);
1466
1202
  }
1467
1203
  return { env };
@@ -1470,14 +1206,15 @@ export class AppExecutor extends SysExecutor {
1470
1206
  getPublicEnv(...patterns: string[]) {
1471
1207
  if (this.#publicEnv) return this.#publicEnv;
1472
1208
  const searchPatterns = [...patterns, "AKAN_PUBLIC_*"];
1473
- const regexes = searchPatterns.map((pattern) => {
1474
- let body = "";
1475
- for (const ch of pattern) {
1476
- if (ch === "*") body += ".*";
1477
- else body += ch.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
1478
- }
1479
- return new RegExp(`^${body}$`);
1480
- });
1209
+ const regexes = searchPatterns.map(
1210
+ (pattern) =>
1211
+ new RegExp(
1212
+ `^${pattern
1213
+ .split("*")
1214
+ .map((part) => part.replace(/[.+^${}()|[\]\\?]/g, "\\$&"))
1215
+ .join(".*")}$`,
1216
+ ),
1217
+ );
1481
1218
  const publicEnv: Record<string, string> = {};
1482
1219
  for (const [k, v] of Object.entries(process.env)) {
1483
1220
  if (typeof v !== "string") continue;
@@ -1490,14 +1227,11 @@ export class AppExecutor extends SysExecutor {
1490
1227
  #akanConfig: AkanAppConfig | null = null;
1491
1228
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1492
1229
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1493
- // A refresh means the config file may have been edited; bust the import cache so the fresh
1494
- // module is evaluated instead of Bun's cached instance.
1495
1230
  this.#akanConfig = await AkanAppConfig.from(this, { bustImportCache: refresh });
1496
1231
  return this.#akanConfig;
1497
1232
  }
1498
1233
 
1499
1234
  #pageKeys: string[] | null = null;
1500
- /** Set once `prepareCommand("build")` runs, so every later consumer of the route set agrees on it. */
1501
1235
  #excludeDevOnlyPages = false;
1502
1236
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1503
1237
  if (this.#pageKeys && !refresh) return this.#pageKeys;
@@ -1507,7 +1241,9 @@ export class AppExecutor extends SysExecutor {
1507
1241
  const owners = new Map<string, { absPath: string; fromLib: boolean }>();
1508
1242
  const devOnlyKeys = new Set<string>();
1509
1243
  const devOnlyDirs: string[] = [];
1510
- for (const root of await this.getPageRoots()) {
1244
+ const pageRoots = await this.getPageRoots();
1245
+ const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1246
+ for (const root of pageRoots) {
1511
1247
  if (!(await FileSys.dirExists(root.dir))) continue;
1512
1248
  for await (const rel of glob.scan({
1513
1249
  cwd: root.dir,
@@ -1517,6 +1253,8 @@ export class AppExecutor extends SysExecutor {
1517
1253
  const segments = rel.split(path.sep);
1518
1254
  if (segments.some((s) => s === "node_modules")) continue;
1519
1255
  const posix = `${root.keyPrefix}${segments.join("/")}`;
1256
+ //* A lib folder that is a copy rather than a link (the Windows fallback) is walked into from `page` too.
1257
+ if (!root.keyPrefix && libKeyPrefixes.some((prefix) => posix.startsWith(prefix))) continue;
1520
1258
  const absPath = path.join(root.dir, ...segments);
1521
1259
  validatePageSourceFile(posix, { filePath: absPath });
1522
1260
  if (!isRouteSourceFile(posix)) continue;
@@ -1532,8 +1270,7 @@ export class AppExecutor extends SysExecutor {
1532
1270
  const fromLib = !!root.keyPrefix;
1533
1271
  const routeId = `${parsed.kind}:${parsed.pattern}`;
1534
1272
  const owner = owners.get(routeId);
1535
- //* App-owned routes have always been allowed to collide (two groups, one pattern); only report a
1536
- //* collision once a synced lib is involved, where neither side can see the other.
1273
+ //* App-owned routes may collide (two groups, one pattern); a synced lib cannot see the other side.
1537
1274
  if (owner && (owner.fromLib || fromLib)) {
1538
1275
  throw new Error(
1539
1276
  `[route-convention] duplicate ${parsed.kind} route "${parsed.pattern}" in app "${this.name}":\n- ${owner.absPath}\n- ${absPath}`,
@@ -1552,8 +1289,7 @@ export class AppExecutor extends SysExecutor {
1552
1289
  });
1553
1290
  if (info.devOnly) {
1554
1291
  devOnlyKeys.add(key);
1555
- //* A layout owns its directory, so a dev-only one takes the whole subtree with it — leaving its
1556
- //* pages behind would ship them stripped of the chrome they were written under.
1292
+ //* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
1557
1293
  if (parsed.kind === "layout") devOnlyDirs.push(key.replace(/[^/]+$/, ""));
1558
1294
  }
1559
1295
  }
@@ -1571,12 +1307,7 @@ export class AppExecutor extends SysExecutor {
1571
1307
  this.verbose(`[route] excluded ${dropped.length} dev-only route file(s) from the build: ${dropped.join(", ")}`);
1572
1308
  return pageKeys.filter((key) => !isDevOnly(key));
1573
1309
  }
1574
- /**
1575
- * Every directory that contributes route files, as `page`-relative key prefixes. Lib roots are the
1576
- * symlinks `syncPages` created, so route keys stay app-relative while `realDir` is what a file watcher
1577
- * reports. Enumeration never crosses a symlink (neither Bun's glob nor TypeScript's `include` does),
1578
- * which is why linked page folders have to be listed here instead of found by walking `page`.
1579
- */
1310
+ /** Lib roots are the `syncPages` symlinks, listed because neither Bun's glob nor TS `include` crosses one. */
1580
1311
  async getPageRoots(): Promise<PageRoot[]> {
1581
1312
  const akanConfig = await this.getConfig();
1582
1313
  const pageDir = `${this.cwdPath}/page`;
@@ -1597,18 +1328,16 @@ export class AppExecutor extends SysExecutor {
1597
1328
  }
1598
1329
 
1599
1330
  static readonly #pageLibsDir = "(libs)";
1600
- /** Where `(libs)` may live: once at the page root, or once per basePath when the app declares subRoutes. */
1601
1331
  static #pageLibParents(basePaths: Iterable<string>): string[] {
1602
1332
  const parents = [...basePaths].map((basePath) => `${basePath}/`);
1603
1333
  return parents.length ? parents : [""];
1604
1334
  }
1605
- /** Returns whether the linked page set changed, which is what makes the app's route keys stale. */
1335
+ /** Whether the linked page set changed, which makes the app's route keys stale. */
1606
1336
  async syncPages(libDeps: string[]): Promise<boolean> {
1607
1337
  const akanConfig = await this.getConfig();
1608
1338
  const parents = AppExecutor.#pageLibParents(akanConfig.basePaths);
1609
1339
  const libs = await this.#resolvePageLibs(akanConfig.syncPageLibs, libDeps);
1610
- //* Listed rather than taken from `getPageRoots`, which drops links whose target is gone — those are
1611
- //* exactly the ones a sync has to clean up.
1340
+ //* Not `getPageRoots`: it drops links whose target is gone, exactly the ones a sync must clean up.
1612
1341
  const linked = (
1613
1342
  await Promise.all(
1614
1343
  parents.map(async (parent) => {
@@ -1619,7 +1348,11 @@ export class AppExecutor extends SysExecutor {
1619
1348
  )
1620
1349
  ).flat();
1621
1350
  const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
1622
- if (linked.sort().join(",") === wanted.sort().join(",")) return false;
1351
+ //* A copy — the Windows fallback when a junction is refused — does not follow its lib, so only links are current.
1352
+ const isLink = async (key: string) =>
1353
+ (await lstat(`${this.cwdPath}/page/${key.slice(0, -1)}`).catch(() => null))?.isSymbolicLink() ?? false;
1354
+ if (linked.sort().join(",") === wanted.sort().join(",") && (await Promise.all(linked.map(isLink))).every(Boolean))
1355
+ return false;
1623
1356
  await Promise.all(
1624
1357
  parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
1625
1358
  );
@@ -1674,8 +1407,7 @@ export class AppExecutor extends SysExecutor {
1674
1407
  );
1675
1408
  }
1676
1409
  static async #linkLibAsset(targetPath: string, linkPath: string) {
1677
- //* A relative link keeps working when the workspace is mounted at another path (containers, CI);
1678
- //* Windows junctions are the exception and resolve their target as an absolute path.
1410
+ //* Relative, so it survives a remount (containers, CI); a Windows junction needs an absolute target.
1679
1411
  const isWindows = process.platform === "win32";
1680
1412
  try {
1681
1413
  const target = isWindows ? targetPath : path.relative(path.dirname(linkPath), targetPath);
@@ -1699,8 +1431,6 @@ export class AppExecutor extends SysExecutor {
1699
1431
  if (write) await this.#runPluginSyncAssets();
1700
1432
  return scanInfo;
1701
1433
  }
1702
- //* build-time asset generation is delegated to plugins (e.g. the push plugin writes
1703
- //* public/firebase-messaging-sw.js). The framework itself no longer knows about firebase.
1704
1434
  async #runPluginSyncAssets() {
1705
1435
  const plugins = await this.collectPlugins();
1706
1436
  if (!plugins.some((plugin) => plugin.syncAssets)) return;
@@ -1714,6 +1444,7 @@ export class AppExecutor extends SysExecutor {
1714
1444
  executor: this,
1715
1445
  getPath: (rel) => this.getPath(rel),
1716
1446
  fileExists: (rel) => FileSys.fileExists(this.getPath(rel)),
1447
+ readFile: (rel) => this.readFile(rel),
1717
1448
  writeFile: async (rel, content, opts) => {
1718
1449
  await this.writeFile(rel, content, opts);
1719
1450
  },
@@ -1731,8 +1462,6 @@ export class AppExecutor extends SysExecutor {
1731
1462
  },
1732
1463
  };
1733
1464
  }
1734
- //* Aggregate plugins declared by this app's akan.config plus each of its lib dependencies'
1735
- //* akan.config. This is how a lib (e.g. libs/util) opts an app into a feature turnkey.
1736
1465
  async collectPlugins(): Promise<AkanPlugin[]> {
1737
1466
  const scanInfo = (await this.scan({ write: false })) as AppInfo;
1738
1467
  const libDeps = scanInfo.getLibs();
@@ -1769,23 +1498,15 @@ export class AppExecutor extends SysExecutor {
1769
1498
  await decreaseBuildNum(this);
1770
1499
  }
1771
1500
  }
1772
- interface LibExecutorOptions {
1773
- workspace?: WorkspaceExecutor;
1774
- name: string;
1775
- }
1776
1501
  export class LibExecutor extends SysExecutor {
1777
1502
  dist: Executor;
1778
1503
  override emoji = execEmoji.lib;
1779
- constructor({ workspace, name }: LibExecutorOptions) {
1504
+ constructor({ workspace, name }: NamedExecutorOptions) {
1780
1505
  super({ workspace, name, type: "lib" });
1781
1506
  this.dist = new Executor(`dist/${name}`, `${this.workspace.workspaceRoot}/dist/libs/${name}`);
1782
1507
  }
1783
- static #execs = new Map<string, LibExecutor>();
1784
1508
  static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
1785
- const exec = LibExecutor.#execs.get(name);
1786
- if (exec) return exec;
1787
- else if (executor instanceof WorkspaceExecutor) return new LibExecutor({ workspace: executor, name });
1788
- else return new LibExecutor({ workspace: executor.workspace, name });
1509
+ return new LibExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1789
1510
  }
1790
1511
 
1791
1512
  #akanConfig: AkanLibConfig | null = null;
@@ -1796,31 +1517,25 @@ export class LibExecutor extends SysExecutor {
1796
1517
  }
1797
1518
  }
1798
1519
 
1799
- interface PkgExecutorOptions {
1800
- workspace?: WorkspaceExecutor;
1801
- name: string;
1802
- }
1803
1520
  export class PkgExecutor extends Executor {
1804
1521
  workspace: WorkspaceExecutor;
1805
1522
  override name: string;
1806
1523
  dist: Executor;
1807
1524
  override emoji = execEmoji.pkg;
1808
- constructor({ workspace = WorkspaceExecutor.fromRoot(), name }: PkgExecutorOptions) {
1525
+ constructor({ workspace = WorkspaceExecutor.fromRoot(), name }: NamedExecutorOptions) {
1809
1526
  super(name, `${workspace.workspaceRoot}/pkgs/${name}`);
1810
1527
  this.workspace = workspace;
1811
1528
  this.name = name;
1812
1529
  this.dist = new Executor(`dist/${name}`, `${this.workspace.workspaceRoot}/dist/pkgs/${name}`);
1813
1530
  }
1814
1531
  static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
1815
- if (executor instanceof WorkspaceExecutor) return new PkgExecutor({ workspace: executor, name });
1816
- return new PkgExecutor({ workspace: executor.workspace, name });
1532
+ return new PkgExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1817
1533
  }
1818
1534
 
1819
1535
  #scanInfo: PkgInfo | null = null;
1820
1536
  async scan({ refresh }: { refresh?: boolean } = {}): Promise<PkgInfo> {
1821
1537
  if (this.#scanInfo && !refresh) return this.#scanInfo;
1822
1538
  const scanInfo = await PkgInfo.fromExecutor(this, { refresh });
1823
- // this.writeJson("akan.pkg.json", pkgScanResult);
1824
1539
  this.#scanInfo = scanInfo;
1825
1540
  return scanInfo;
1826
1541
  }
@@ -1831,6 +1546,9 @@ export class PkgExecutor extends Executor {
1831
1546
  };
1832
1547
  const rootVersion = rootDeps[dep];
1833
1548
  if (rootVersion) return rootVersion;
1549
+ // Overrides never reach a consumer's install, so a pinned transitive dep is published as a dependency.
1550
+ const overrideVersion = rootPackageJson.overrides?.[dep];
1551
+ if (typeof overrideVersion === "string") return overrideVersion;
1834
1552
 
1835
1553
  try {
1836
1554
  const packageJsonPath = `pkgs/${dep}/package.json`;
@@ -1858,17 +1576,9 @@ export class PkgExecutor extends Executor {
1858
1576
  if (missingDeps.length > 0)
1859
1577
  throw new Error(`Missing dependency versions in root package.json: ${missingDeps.join(", ")}`);
1860
1578
 
1861
- const toDependencyEntries = (names: string[]) =>
1862
- names.map((dep) => {
1863
- const version = dependencyVersions.get(dep);
1864
- if (!version) throw new Error(`Missing dependency versions in root package.json: ${dep}`);
1865
- return [dep, version] as const;
1866
- });
1867
-
1868
- return {
1869
- dependencies: Object.fromEntries(toDependencyEntries(dependencyNames)),
1870
- devDependencies: Object.fromEntries(toDependencyEntries(devDependencyNames)),
1871
- };
1579
+ const toDependencyMap = (names: string[]) =>
1580
+ Object.fromEntries(names.map((dep) => [dep, dependencyVersions.get(dep) as string]));
1581
+ return { dependencies: toDependencyMap(dependencyNames), devDependencies: toDependencyMap(devDependencyNames) };
1872
1582
  }
1873
1583
  async updatePackageJsonDependencies(
1874
1584
  dependencies: string[] = [],