@akanjs/devkit 3.0.0-beta.9 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. 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,
@@ -20,7 +21,7 @@ import {
20
21
  } from "node:fs/promises";
21
22
  import path from "node:path";
22
23
  import { pathToFileURL } from "node:url";
23
- import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
24
+ import type { AkanPlugin, AkanSyncContext } from "akanjs";
24
25
  import {
25
26
  capitalize,
26
27
  getPageSourceFileViolation,
@@ -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,15 +789,16 @@ export class WorkspaceExecutor extends Executor {
963
789
  .sort();
964
790
  }
965
791
  async #getDirHasFile(basePath: string, targetFilename: string) {
966
- const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
792
+ if (!(await FileSys.dirExists(basePath))) return [];
793
+ //? `local/` is gitignored scratch: a test's temporary workspace there has a package.json of its own.
794
+ const AVOID_DIRS = ["node_modules", "dist", "public", "webkit", "local"];
967
795
  const getDirs = async (dirname: string, maxDepth = 3, results: string[] = [], prefix = "") => {
968
796
  const dirs = await this.readdir(dirname);
969
797
  await Promise.all(
970
798
  dirs.map(async (dir) => {
971
799
  if (AVOID_DIRS.includes(dir)) return;
972
800
  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.
801
+ //* Not `stat`: a dangling symlink would throw, and this walk backs `getApps`, i.e. every command.
975
802
  if (await FileSys.dirExists(dirPath)) {
976
803
  const hasTargetFile = await FileSys.fileExists(path.join(dirPath, targetFilename));
977
804
  if (hasTargetFile) results.push(`${prefix}${dir}`);
@@ -981,50 +808,34 @@ export class WorkspaceExecutor extends Executor {
981
808
  );
982
809
  return results;
983
810
  };
984
- return await getDirs(basePath);
811
+ return (await getDirs(basePath)).sort((a, b) => a.localeCompare(b));
985
812
  }
986
813
 
987
- async getScalarConstantFiles() {
814
+ async #fromEverySys<T>(read: (sys: SysExecutor) => Promise<T[]>): Promise<T[]> {
988
815
  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;
816
+ const fromApps = await Promise.all(appNames.map((appName) => read(AppExecutor.from(this, appName))));
817
+ const fromLibs = await Promise.all(libNames.map((libName) => read(LibExecutor.from(this, libName))));
818
+ return [...fromApps.flat(), ...fromLibs.flat()];
819
+ }
820
+ async getScalarConstantFiles() {
821
+ return await this.#fromEverySys((sys) => sys.getScalarConstantFiles());
998
822
  }
999
823
  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;
824
+ return await this.#fromEverySys((sys) => sys.getConstantFiles());
1006
825
  }
1007
826
  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;
827
+ return await this.#fromEverySys((sys) => sys.getDictionaryFiles());
1014
828
  }
1015
829
  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;
830
+ return await this.#fromEverySys((sys) => sys.getViewsSourceCode());
1022
831
  }
1023
832
  }
1024
833
 
1025
- interface SysExecutorOptions {
834
+ interface NamedExecutorOptions {
1026
835
  workspace?: WorkspaceExecutor;
1027
836
  name: string;
837
+ }
838
+ interface SysExecutorOptions extends NamedExecutorOptions {
1028
839
  type: "app" | "lib";
1029
840
  }
1030
841
 
@@ -1093,29 +904,12 @@ export class SysExecutor extends Executor {
1093
904
  options: { exec: this, facet },
1094
905
  }),
1095
906
  ),
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
- }),
907
+ ...[
908
+ ...scanInfo.getDatabaseModules().map((model) => [model, `lib/${model}`]),
909
+ ...scanInfo.getServiceModules().map((model) => [model, `lib/_${model}`]),
910
+ ...scanInfo.getScalarModules().map((model) => [model, `lib/__scalar/${model}`]),
911
+ ].map(([model, basePath]) =>
912
+ this._applyTemplate({ basePath, template: "moduleRoot", scanInfo, dict: { model, Model: capitalize(model) } }),
1119
913
  ),
1120
914
  ];
1121
915
  }
@@ -1137,9 +931,7 @@ export class SysExecutor extends Executor {
1137
931
  : await LibInfo.fromExecutor(this as unknown as LibExecutor, {
1138
932
  refresh,
1139
933
  });
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.
934
+ //* Locked: `writeLib` rewrites dependency-lib barrels that other dev servers regenerate and watch too.
1143
935
  if (write)
1144
936
  await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
1145
937
  await Promise.all(this.#getScanTemplateTasks(scanInfo));
@@ -1156,11 +948,7 @@ export class SysExecutor extends Executor {
1156
948
  this.#scanInfo = scanInfo;
1157
949
  return scanInfo;
1158
950
  }
1159
- /**
1160
- * 스코프 에이전트 색인(apps|libs/<name>/AGENTS.md) 재생성 — own + 의존 lib 레시피만 싣는다(프레임워크
1161
- * 레시피는 루트 AGENTS.md 소관). scan(write) 경로에 물려 있어 sync/build/start 어디를 지나도 갱신되고,
1162
- * `akan lint` 가 같은 렌더 결과와 비교해 신선도를 강제한다. 마커 밖 내용은 사용자 소유라 보존한다.
1163
- */
951
+ /** Rewrites only the marker block of the scoped AGENTS.md; content outside the markers belongs to the user. */
1164
952
  async syncAgentsIndex(scanInfo?: AppInfo | LibInfo) {
1165
953
  const info = scanInfo ?? (await this.scan({ write: false }));
1166
954
  const scope = { type: this.type, name: this.name };
@@ -1215,69 +1003,49 @@ export class SysExecutor extends Executor {
1215
1003
  }
1216
1004
 
1217
1005
  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;
1006
+ return (await this.readdir("lib"))
1007
+ .filter((name) => !name.startsWith("_") && !name.endsWith(".ts"))
1008
+ .filter((name) => existsSync(`${this.cwdPath}/lib/${name}/${name}.constant.ts`));
1222
1009
  }
1223
1010
 
1224
1011
  async getServiceModules() {
1225
- const serviceModules = (await this.readdir("lib"))
1012
+ return (await this.readdir("lib"))
1226
1013
  .filter((name) => name.startsWith("_") && !name.startsWith("__"))
1227
- .filter((name) => Bun.file(`${this.cwdPath}/lib/${name}/${name}.service.ts`).exists());
1228
- return serviceModules;
1014
+ .filter((name) => existsSync(`${this.cwdPath}/lib/${name}/${name.slice(1)}.service.ts`));
1229
1015
  }
1230
1016
 
1231
1017
  async getScalarModules() {
1232
- const scalarModules = (await this.readdir("lib/__scalar"))
1018
+ return (await this.readdir("lib/__scalar"))
1233
1019
  .filter((name) => !name.startsWith("_"))
1234
- .filter((name) => Bun.file(`${this.cwdPath}/lib/__scalar/${name}/${name}.constant.ts`).exists());
1235
- return scalarModules;
1020
+ .filter((name) => existsSync(`${this.cwdPath}/lib/__scalar/${name}/${name}.constant.ts`));
1236
1021
  }
1237
1022
 
1023
+ async #getComponentModules(role: "View" | "Unit" | "Template") {
1024
+ return (await this.readdir("lib"))
1025
+ .filter((name) => !name.startsWith("_") && !name.endsWith(".ts"))
1026
+ .filter((name) => existsSync(`${this.cwdPath}/lib/${name}/${capitalize(name)}.${role}.tsx`));
1027
+ }
1028
+ async #getComponentSources(role: "View" | "Unit" | "Template") {
1029
+ const modules = await this.#getComponentModules(role);
1030
+ return Promise.all(modules.map((name) => this.getLocalFile(`lib/${name}/${capitalize(name)}.${role}.tsx`)));
1031
+ }
1238
1032
  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;
1033
+ return await this.#getComponentModules("View");
1243
1034
  }
1244
-
1245
1035
  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;
1036
+ return await this.#getComponentModules("Unit");
1250
1037
  }
1251
1038
  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;
1039
+ return await this.#getComponentModules("Template");
1256
1040
  }
1257
-
1258
1041
  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
- );
1042
+ return await this.#getComponentSources("View");
1265
1043
  }
1266
1044
  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
- );
1045
+ return await this.#getComponentSources("Unit");
1273
1046
  }
1274
1047
  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
- );
1048
+ return await this.#getComponentSources("Template");
1281
1049
  }
1282
1050
 
1283
1051
  async getScalarConstantFiles() {
@@ -1292,7 +1060,9 @@ export class SysExecutor extends Executor {
1292
1060
  async getScalarDictionaryFiles() {
1293
1061
  const scalarModules = await this.getScalarModules();
1294
1062
  return Promise.all(
1295
- scalarModules.map((scalarModule) => this.getLocalFile(`lib/${scalarModule}/${scalarModule}.dictionary.ts`)),
1063
+ scalarModules.map((scalarModule) =>
1064
+ this.getLocalFile(`lib/__scalar/${scalarModule}/${scalarModule}.dictionary.ts`),
1065
+ ),
1296
1066
  );
1297
1067
  }
1298
1068
 
@@ -1326,30 +1096,15 @@ export class SysExecutor extends Executor {
1326
1096
  dict?: { [key: string]: string };
1327
1097
  overwrite?: boolean;
1328
1098
  }): 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
1099
  const scanInfo = await this.scan();
1336
- const fileContents = await this._applyTemplate({
1337
- ...options,
1338
- scanInfo,
1339
- dict,
1340
- });
1100
+ const fileContents = await this._applyTemplate({ ...options, scanInfo, dict: withCapitalizedDict(options.dict) });
1341
1101
  await this.scan();
1342
1102
  return fileContents;
1343
1103
  }
1344
1104
  }
1345
1105
 
1346
- interface AppExecutorOptions {
1347
- workspace?: WorkspaceExecutor;
1348
- name: string;
1349
- }
1350
1106
  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.
1107
+ // Lazy: the validator pulls in `typescript` (~65MB resident).
1353
1108
  static #routeSourceValidator: typeof import("./routeSourceValidator").RouteSourceValidator | null = null;
1354
1109
  static async #getRouteSourceValidator() {
1355
1110
  AppExecutor.#routeSourceValidator ??= (await import("./routeSourceValidator")).RouteSourceValidator;
@@ -1357,29 +1112,31 @@ export class AppExecutor extends SysExecutor {
1357
1112
  }
1358
1113
  dist: Executor;
1359
1114
  override emoji = execEmoji.app;
1360
- constructor({ workspace, name }: AppExecutorOptions) {
1115
+ constructor({ workspace, name }: NamedExecutorOptions) {
1361
1116
  super({ workspace, name, type: "app" });
1362
1117
  this.dist = new Executor(`dist/${name}`, `${this.workspace.workspaceRoot}/dist/apps/${name}`);
1363
1118
  }
1364
- static #execs = new Map<string, AppExecutor>();
1365
1119
  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 });
1120
+ return new AppExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1121
+ }
1122
+ //* Not dev output: native builds and the update releases waiting to be uploaded, and the downloaded `bin` sources.
1123
+ static readonly #keptOnStart = [path.join(".akan", "native"), path.join(".akan", "cache", "bin")];
1124
+ async #removeDevOutput(dir = ".akan") {
1125
+ const entries = await readDirEntries(this.getPath(dir)).catch(() => [] as string[]);
1126
+ await Promise.all(
1127
+ entries.map(async (name) => {
1128
+ const entry = path.join(dir, name);
1129
+ if (AppExecutor.#keptOnStart.includes(entry)) return;
1130
+ if (AppExecutor.#keptOnStart.some((kept) => kept.startsWith(`${entry}${path.sep}`)))
1131
+ return await this.#removeDevOutput(entry);
1132
+ await this.removeDir(entry);
1133
+ }),
1134
+ );
1370
1135
  }
1371
1136
  getEnv() {
1372
1137
  return WorkspaceExecutor.getBaseDevEnv().env;
1373
1138
  }
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
- */
1139
+ /** `AKAN_DEV_PORT` pins it; the derived port moves whenever an app directory appears or disappears. */
1383
1140
  async getDevPort() {
1384
1141
  const pinned = Number(process.env.AKAN_DEV_PORT);
1385
1142
  if (Number.isInteger(pinned) && pinned > 0 && pinned <= 65_535) return pinned;
@@ -1390,10 +1147,8 @@ export class AppExecutor extends SysExecutor {
1390
1147
  return basePort + appIndex + portOffset;
1391
1148
  }
1392
1149
  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;
1150
+ const { portOffset } = WorkspaceExecutor.getBaseDevEnv();
1151
+ const PORT = (8282 + portOffset).toString();
1397
1152
  return {
1398
1153
  ...process.env,
1399
1154
  AKAN_PUBLIC_APP_NAME: this.name,
@@ -1401,48 +1156,48 @@ export class AppExecutor extends SysExecutor {
1401
1156
  NODE_NO_WARNINGS: "1",
1402
1157
  PORT,
1403
1158
  AKAN_PUBLIC_CLIENT_PORT: PORT,
1404
- ...(AKAN_PUBLIC_SERVER_PORT ? { AKAN_PUBLIC_SERVER_PORT } : {}),
1159
+ ...(portOffset ? { AKAN_PUBLIC_SERVER_PORT: PORT } : {}),
1405
1160
  ...env,
1406
1161
  };
1407
1162
  }
1163
+ async getDatabaseModeEnv() {
1164
+ const akanConfig = await this.getConfig();
1165
+ return {
1166
+ AKAN_DATABASE_MODE: akanConfig.resolveDatabaseMode(),
1167
+ AKAN_DATABASE_MODES: akanConfig.database.modes.join(","),
1168
+ };
1169
+ }
1408
1170
  async prepareCommand(type: "build" | "start") {
1409
1171
  const akanConfig = await this.getConfig();
1410
- const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
1411
1172
  const routeEnv = {
1412
1173
  AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
1413
1174
  AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
1414
1175
  AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
1415
- AKAN_DATABASE_MODE: databaseMode,
1176
+ ...(await this.getDatabaseModeEnv()),
1416
1177
  };
1417
1178
  Object.assign(process.env, routeEnv);
1418
1179
  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.
1180
+ //* `scanSync` read the route set unfiltered; drop the cache so build phases re-read it without dev-only routes.
1421
1181
  this.#excludeDevOnlyPages = true;
1422
1182
  this.#pageKeys = null;
1423
1183
  await this.dist.removeDir(this.dist.cwdPath);
1424
1184
  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.
1185
+ //* Lib assets are symlinks, which neither the docker context nor the release tarball follows out of dist.
1186
+ //* Only the web router reads `public/`, so an api-only build leaves it out.
1428
1187
  await Promise.all([
1429
1188
  this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
1430
1189
  ...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
1431
1190
  ]);
1432
1191
  } else {
1433
- await this.removeDir(".akan");
1434
- //? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
1435
- //? builder is also the file watcher, so switching it off would take server-code HMR with it.
1192
+ await this.#removeDevOutput();
1193
+ //? `akan start` keeps the full dev surface: the incremental builder is also the file watcher.
1436
1194
  if (!akanConfig.web.ssr || !akanConfig.web.csr)
1437
1195
  this.logger.verbose(
1438
1196
  `akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
1439
1197
  );
1440
1198
  }
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.
1199
+ //? Bun auto-loads `.env`, so NODE_ENV=production can arrive unexported and send the dev server to the production
1200
+ //? route cache. Set on `process.env` because the builder runs here and bakes NODE_ENV into its bundles.
1446
1201
  if (type === "start") process.env.NODE_ENV = "development";
1447
1202
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1448
1203
  const env = this.getCommandEnv({
@@ -1450,18 +1205,14 @@ export class AppExecutor extends SysExecutor {
1450
1205
  ...routeEnv,
1451
1206
  ...(devPort ? { PORT: devPort, AKAN_PUBLIC_CLIENT_PORT: devPort, AKAN_PUBLIC_SERVER_PORT: devPort } : {}),
1452
1207
  });
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.
1208
+ // `build` runs its phases in this process, so the env is published here (only AKAN_PUBLIC_* reaches bundles).
1209
+ // Ports and the operation mode are dropped: `define` would bake them in and outrank the container's own values.
1461
1210
  if (type === "build") {
1462
1211
  const buildEnv = { ...env };
1463
1212
  delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1464
1213
  delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1214
+ delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
1215
+ delete process.env.AKAN_PUBLIC_OPERATION_MODE;
1465
1216
  Object.assign(process.env, buildEnv);
1466
1217
  }
1467
1218
  return { env };
@@ -1470,14 +1221,15 @@ export class AppExecutor extends SysExecutor {
1470
1221
  getPublicEnv(...patterns: string[]) {
1471
1222
  if (this.#publicEnv) return this.#publicEnv;
1472
1223
  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
- });
1224
+ const regexes = searchPatterns.map(
1225
+ (pattern) =>
1226
+ new RegExp(
1227
+ `^${pattern
1228
+ .split("*")
1229
+ .map((part) => part.replace(/[.+^${}()|[\]\\?]/g, "\\$&"))
1230
+ .join(".*")}$`,
1231
+ ),
1232
+ );
1481
1233
  const publicEnv: Record<string, string> = {};
1482
1234
  for (const [k, v] of Object.entries(process.env)) {
1483
1235
  if (typeof v !== "string") continue;
@@ -1490,14 +1242,11 @@ export class AppExecutor extends SysExecutor {
1490
1242
  #akanConfig: AkanAppConfig | null = null;
1491
1243
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1492
1244
  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
1245
  this.#akanConfig = await AkanAppConfig.from(this, { bustImportCache: refresh });
1496
1246
  return this.#akanConfig;
1497
1247
  }
1498
1248
 
1499
1249
  #pageKeys: string[] | null = null;
1500
- /** Set once `prepareCommand("build")` runs, so every later consumer of the route set agrees on it. */
1501
1250
  #excludeDevOnlyPages = false;
1502
1251
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1503
1252
  if (this.#pageKeys && !refresh) return this.#pageKeys;
@@ -1507,7 +1256,9 @@ export class AppExecutor extends SysExecutor {
1507
1256
  const owners = new Map<string, { absPath: string; fromLib: boolean }>();
1508
1257
  const devOnlyKeys = new Set<string>();
1509
1258
  const devOnlyDirs: string[] = [];
1510
- for (const root of await this.getPageRoots()) {
1259
+ const pageRoots = await this.getPageRoots();
1260
+ const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1261
+ for (const root of pageRoots) {
1511
1262
  if (!(await FileSys.dirExists(root.dir))) continue;
1512
1263
  for await (const rel of glob.scan({
1513
1264
  cwd: root.dir,
@@ -1517,6 +1268,8 @@ export class AppExecutor extends SysExecutor {
1517
1268
  const segments = rel.split(path.sep);
1518
1269
  if (segments.some((s) => s === "node_modules")) continue;
1519
1270
  const posix = `${root.keyPrefix}${segments.join("/")}`;
1271
+ //* A lib folder that is a copy rather than a link (the Windows fallback) is walked into from `page` too.
1272
+ if (!root.keyPrefix && libKeyPrefixes.some((prefix) => posix.startsWith(prefix))) continue;
1520
1273
  const absPath = path.join(root.dir, ...segments);
1521
1274
  validatePageSourceFile(posix, { filePath: absPath });
1522
1275
  if (!isRouteSourceFile(posix)) continue;
@@ -1532,8 +1285,7 @@ export class AppExecutor extends SysExecutor {
1532
1285
  const fromLib = !!root.keyPrefix;
1533
1286
  const routeId = `${parsed.kind}:${parsed.pattern}`;
1534
1287
  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.
1288
+ //* App-owned routes may collide (two groups, one pattern); a synced lib cannot see the other side.
1537
1289
  if (owner && (owner.fromLib || fromLib)) {
1538
1290
  throw new Error(
1539
1291
  `[route-convention] duplicate ${parsed.kind} route "${parsed.pattern}" in app "${this.name}":\n- ${owner.absPath}\n- ${absPath}`,
@@ -1552,8 +1304,7 @@ export class AppExecutor extends SysExecutor {
1552
1304
  });
1553
1305
  if (info.devOnly) {
1554
1306
  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.
1307
+ //* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
1557
1308
  if (parsed.kind === "layout") devOnlyDirs.push(key.replace(/[^/]+$/, ""));
1558
1309
  }
1559
1310
  }
@@ -1571,12 +1322,7 @@ export class AppExecutor extends SysExecutor {
1571
1322
  this.verbose(`[route] excluded ${dropped.length} dev-only route file(s) from the build: ${dropped.join(", ")}`);
1572
1323
  return pageKeys.filter((key) => !isDevOnly(key));
1573
1324
  }
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
- */
1325
+ /** Lib roots are the `syncPages` symlinks, listed because neither Bun's glob nor TS `include` crosses one. */
1580
1326
  async getPageRoots(): Promise<PageRoot[]> {
1581
1327
  const akanConfig = await this.getConfig();
1582
1328
  const pageDir = `${this.cwdPath}/page`;
@@ -1597,18 +1343,16 @@ export class AppExecutor extends SysExecutor {
1597
1343
  }
1598
1344
 
1599
1345
  static readonly #pageLibsDir = "(libs)";
1600
- /** Where `(libs)` may live: once at the page root, or once per basePath when the app declares subRoutes. */
1601
1346
  static #pageLibParents(basePaths: Iterable<string>): string[] {
1602
1347
  const parents = [...basePaths].map((basePath) => `${basePath}/`);
1603
1348
  return parents.length ? parents : [""];
1604
1349
  }
1605
- /** Returns whether the linked page set changed, which is what makes the app's route keys stale. */
1350
+ /** Whether the linked page set changed, which makes the app's route keys stale. */
1606
1351
  async syncPages(libDeps: string[]): Promise<boolean> {
1607
1352
  const akanConfig = await this.getConfig();
1608
1353
  const parents = AppExecutor.#pageLibParents(akanConfig.basePaths);
1609
1354
  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.
1355
+ //* Not `getPageRoots`: it drops links whose target is gone, exactly the ones a sync must clean up.
1612
1356
  const linked = (
1613
1357
  await Promise.all(
1614
1358
  parents.map(async (parent) => {
@@ -1619,7 +1363,11 @@ export class AppExecutor extends SysExecutor {
1619
1363
  )
1620
1364
  ).flat();
1621
1365
  const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
1622
- if (linked.sort().join(",") === wanted.sort().join(",")) return false;
1366
+ //* A copy — the Windows fallback when a junction is refused — does not follow its lib, so only links are current.
1367
+ const isLink = async (key: string) =>
1368
+ (await lstat(`${this.cwdPath}/page/${key.slice(0, -1)}`).catch(() => null))?.isSymbolicLink() ?? false;
1369
+ if (linked.sort().join(",") === wanted.sort().join(",") && (await Promise.all(linked.map(isLink))).every(Boolean))
1370
+ return false;
1623
1371
  await Promise.all(
1624
1372
  parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
1625
1373
  );
@@ -1674,8 +1422,7 @@ export class AppExecutor extends SysExecutor {
1674
1422
  );
1675
1423
  }
1676
1424
  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.
1425
+ //* Relative, so it survives a remount (containers, CI); a Windows junction needs an absolute target.
1679
1426
  const isWindows = process.platform === "win32";
1680
1427
  try {
1681
1428
  const target = isWindows ? targetPath : path.relative(path.dirname(linkPath), targetPath);
@@ -1699,8 +1446,6 @@ export class AppExecutor extends SysExecutor {
1699
1446
  if (write) await this.#runPluginSyncAssets();
1700
1447
  return scanInfo;
1701
1448
  }
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
1449
  async #runPluginSyncAssets() {
1705
1450
  const plugins = await this.collectPlugins();
1706
1451
  if (!plugins.some((plugin) => plugin.syncAssets)) return;
@@ -1714,6 +1459,7 @@ export class AppExecutor extends SysExecutor {
1714
1459
  executor: this,
1715
1460
  getPath: (rel) => this.getPath(rel),
1716
1461
  fileExists: (rel) => FileSys.fileExists(this.getPath(rel)),
1462
+ readFile: (rel) => this.readFile(rel),
1717
1463
  writeFile: async (rel, content, opts) => {
1718
1464
  await this.writeFile(rel, content, opts);
1719
1465
  },
@@ -1731,8 +1477,6 @@ export class AppExecutor extends SysExecutor {
1731
1477
  },
1732
1478
  };
1733
1479
  }
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
1480
  async collectPlugins(): Promise<AkanPlugin[]> {
1737
1481
  const scanInfo = (await this.scan({ write: false })) as AppInfo;
1738
1482
  const libDeps = scanInfo.getLibs();
@@ -1751,17 +1495,6 @@ export class AppExecutor extends SysExecutor {
1751
1495
  return true;
1752
1496
  });
1753
1497
  }
1754
- async getPluginRuntimePackages(): Promise<string[]> {
1755
- const plugins = await this.collectPlugins();
1756
- const appConfig = await this.getConfig();
1757
- const ctx: PluginRuntimeContext = {
1758
- appName: this.name,
1759
- mobile: appConfig.mobile,
1760
- hasMobilePermission: (permission) =>
1761
- Object.values(appConfig.mobile.targets).some((target) => target.permissions?.includes(permission) ?? false),
1762
- };
1763
- return [...new Set(plugins.flatMap((plugin) => plugin.runtimePackages?.(ctx) ?? []))];
1764
- }
1765
1498
  async increaseBuildNum() {
1766
1499
  await increaseBuildNum(this);
1767
1500
  }
@@ -1769,23 +1502,15 @@ export class AppExecutor extends SysExecutor {
1769
1502
  await decreaseBuildNum(this);
1770
1503
  }
1771
1504
  }
1772
- interface LibExecutorOptions {
1773
- workspace?: WorkspaceExecutor;
1774
- name: string;
1775
- }
1776
1505
  export class LibExecutor extends SysExecutor {
1777
1506
  dist: Executor;
1778
1507
  override emoji = execEmoji.lib;
1779
- constructor({ workspace, name }: LibExecutorOptions) {
1508
+ constructor({ workspace, name }: NamedExecutorOptions) {
1780
1509
  super({ workspace, name, type: "lib" });
1781
1510
  this.dist = new Executor(`dist/${name}`, `${this.workspace.workspaceRoot}/dist/libs/${name}`);
1782
1511
  }
1783
- static #execs = new Map<string, LibExecutor>();
1784
1512
  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 });
1513
+ return new LibExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1789
1514
  }
1790
1515
 
1791
1516
  #akanConfig: AkanLibConfig | null = null;
@@ -1796,31 +1521,25 @@ export class LibExecutor extends SysExecutor {
1796
1521
  }
1797
1522
  }
1798
1523
 
1799
- interface PkgExecutorOptions {
1800
- workspace?: WorkspaceExecutor;
1801
- name: string;
1802
- }
1803
1524
  export class PkgExecutor extends Executor {
1804
1525
  workspace: WorkspaceExecutor;
1805
1526
  override name: string;
1806
1527
  dist: Executor;
1807
1528
  override emoji = execEmoji.pkg;
1808
- constructor({ workspace = WorkspaceExecutor.fromRoot(), name }: PkgExecutorOptions) {
1529
+ constructor({ workspace = WorkspaceExecutor.fromRoot(), name }: NamedExecutorOptions) {
1809
1530
  super(name, `${workspace.workspaceRoot}/pkgs/${name}`);
1810
1531
  this.workspace = workspace;
1811
1532
  this.name = name;
1812
1533
  this.dist = new Executor(`dist/${name}`, `${this.workspace.workspaceRoot}/dist/pkgs/${name}`);
1813
1534
  }
1814
1535
  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 });
1536
+ return new PkgExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1817
1537
  }
1818
1538
 
1819
1539
  #scanInfo: PkgInfo | null = null;
1820
1540
  async scan({ refresh }: { refresh?: boolean } = {}): Promise<PkgInfo> {
1821
1541
  if (this.#scanInfo && !refresh) return this.#scanInfo;
1822
1542
  const scanInfo = await PkgInfo.fromExecutor(this, { refresh });
1823
- // this.writeJson("akan.pkg.json", pkgScanResult);
1824
1543
  this.#scanInfo = scanInfo;
1825
1544
  return scanInfo;
1826
1545
  }
@@ -1831,6 +1550,9 @@ export class PkgExecutor extends Executor {
1831
1550
  };
1832
1551
  const rootVersion = rootDeps[dep];
1833
1552
  if (rootVersion) return rootVersion;
1553
+ // Overrides never reach a consumer's install, so a pinned transitive dep is published as a dependency.
1554
+ const overrideVersion = rootPackageJson.overrides?.[dep];
1555
+ if (typeof overrideVersion === "string") return overrideVersion;
1834
1556
 
1835
1557
  try {
1836
1558
  const packageJsonPath = `pkgs/${dep}/package.json`;
@@ -1858,17 +1580,9 @@ export class PkgExecutor extends Executor {
1858
1580
  if (missingDeps.length > 0)
1859
1581
  throw new Error(`Missing dependency versions in root package.json: ${missingDeps.join(", ")}`);
1860
1582
 
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
- };
1583
+ const toDependencyMap = (names: string[]) =>
1584
+ Object.fromEntries(names.map((dep) => [dep, dependencyVersions.get(dep) as string]));
1585
+ return { dependencies: toDependencyMap(dependencyNames), devDependencies: toDependencyMap(devDependencyNames) };
1872
1586
  }
1873
1587
  async updatePackageJsonDependencies(
1874
1588
  dependencies: string[] = [],