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