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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +1 -0
  2. package/abstractDoc.test.ts +6 -15
  3. package/abstractDoc.ts +6 -8
  4. package/agentsIndex.test.ts +4 -7
  5. package/agentsIndex.ts +6 -22
  6. package/akanApp/BackendImportGraph.test.ts +8 -22
  7. package/akanApp/BackendImportGraph.ts +1 -20
  8. package/akanApp/akanApp.host.test.ts +1 -3
  9. package/akanApp/akanApp.host.ts +82 -254
  10. package/akanApp/devHostPolicy.test.ts +10 -34
  11. package/akanApp/devHostPolicy.ts +12 -98
  12. package/akanConfig/akanConfig.test.ts +85 -69
  13. package/akanConfig/akanConfig.ts +69 -153
  14. package/akanConfig/types.ts +1 -0
  15. package/akanContext.ts +106 -159
  16. package/akanMcpContract.ts +28 -73
  17. package/appSelectionMemory.ts +1 -4
  18. package/applicationBuildReporter.ts +37 -5
  19. package/applicationBuildRunner.test.ts +5 -3
  20. package/applicationBuildRunner.ts +54 -61
  21. package/applicationReleasePackager.ts +24 -44
  22. package/applicationTestPreload.ts +3 -12
  23. package/artifact/implicitRootLayout.test.ts +9 -30
  24. package/artifact/implicitRootLayout.ts +34 -106
  25. package/artifact/routeSeedIndex.test.ts +7 -6
  26. package/artifact/routeSeedIndex.ts +11 -44
  27. package/biome.base.json +6 -1
  28. package/biomeBase.ts +2 -5
  29. package/capacitorApp.test.ts +32 -16
  30. package/capacitorApp.ts +110 -238
  31. package/cloud/cloudApi.test.ts +202 -0
  32. package/cloud/cloudApi.ts +77 -39
  33. package/cloud/configLock.test.ts +64 -0
  34. package/cloud/configLock.ts +96 -0
  35. package/cloud/constants.ts +26 -5
  36. package/cloud/globalConfig.ts +70 -32
  37. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  38. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  39. package/codeAgent/agent/CodeAgent.ts +551 -0
  40. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  41. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  42. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  43. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  44. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  45. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  46. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  47. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  48. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  49. package/codeAgent/agent/CodeMailbox.ts +148 -0
  50. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  51. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  52. package/codeAgent/agent/SubagentPool.ts +214 -0
  53. package/codeAgent/agent/akanCodeModel.ts +47 -0
  54. package/codeAgent/agent/akanCodePaths.ts +31 -0
  55. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  56. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  57. package/codeAgent/agent/codeAgent.test.ts +178 -0
  58. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  59. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  60. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  61. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  62. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  63. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  64. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  65. package/codeAgent/agent/consoleToStderr.ts +8 -0
  66. package/codeAgent/agent/jsonLines.ts +29 -0
  67. package/codeAgent/agent/stringArg.ts +5 -0
  68. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  69. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  70. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  71. package/codeAgent/feedback/PreviewView.ts +95 -0
  72. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  73. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  74. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  75. package/codeAgent/index.ts +34 -0
  76. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  77. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  78. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  79. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  80. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  81. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  82. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  83. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  84. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  85. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  86. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  87. package/codeAgent/tools/AkanCli.ts +50 -0
  88. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  89. package/codeAgent/tools/AkanEditScope.ts +48 -0
  90. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  91. package/codeAgent/tools/AkanToolPack.ts +92 -0
  92. package/codeAgent/tools/AkanVerifier.ts +48 -0
  93. package/codeAgent/tools/AskToolPack.ts +99 -0
  94. package/codeAgent/tools/MailToolPack.ts +76 -0
  95. package/codeAgent/tools/McpClient.ts +187 -0
  96. package/codeAgent/tools/McpOAuth.ts +233 -0
  97. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  98. package/codeAgent/tools/McpServerConfig.ts +129 -0
  99. package/codeAgent/tools/McpSignIn.ts +158 -0
  100. package/codeAgent/tools/McpTokenStore.ts +81 -0
  101. package/codeAgent/tools/McpToolPack.ts +199 -0
  102. package/codeAgent/tools/SessionToolPack.ts +91 -0
  103. package/codeAgent/tools/WebToolPack.ts +147 -0
  104. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  105. package/codeAgent/tools/askToolPack.test.ts +116 -0
  106. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  107. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  108. package/codegenLock.test.ts +4 -13
  109. package/codegenLock.ts +3 -17
  110. package/commandDecorators/argMeta.ts +1 -5
  111. package/commandDecorators/camelToKebabCase.ts +1 -0
  112. package/commandDecorators/command.ts +46 -102
  113. package/commandDecorators/commandBuilder.ts +30 -61
  114. package/commandDecorators/commandDecorators.test.ts +2 -10
  115. package/commandDecorators/dependencyBuilder.ts +10 -1
  116. package/commandDecorators/helpFormatter.ts +68 -145
  117. package/commandDecorators/index.ts +0 -2
  118. package/commandDecorators/targetMeta.ts +9 -7
  119. package/createTunnel.ts +7 -14
  120. package/dependencyScanner.test.ts +4 -16
  121. package/dependencyScanner.ts +31 -80
  122. package/devkitUtils.test.ts +48 -20
  123. package/executors.linkFallback.test.ts +103 -0
  124. package/executors.test.ts +132 -106
  125. package/executors.ts +194 -484
  126. package/fileEditor.ts +9 -37
  127. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  128. package/fileSys.ts +6 -8
  129. package/formSetterScanner.ts +3 -16
  130. package/frontendBuild/allRoutesBuilder.ts +7 -21
  131. package/frontendBuild/autoImportSync.test.ts +27 -38
  132. package/frontendBuild/autoImportSync.ts +31 -65
  133. package/frontendBuild/buildRouteClient.test.ts +62 -37
  134. package/frontendBuild/bundleDefine.ts +12 -0
  135. package/frontendBuild/clientBuildTypes.ts +5 -36
  136. package/frontendBuild/clientEntriesBundler.ts +9 -40
  137. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  138. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  139. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  140. package/frontendBuild/cssCandidateCache.ts +6 -41
  141. package/frontendBuild/cssCompiler.ts +29 -78
  142. package/frontendBuild/cssImportResolver.ts +6 -19
  143. package/frontendBuild/devChangePlanner.ts +7 -10
  144. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  145. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  146. package/frontendBuild/fontOptimizer.test.ts +6 -14
  147. package/frontendBuild/fontOptimizer.ts +15 -72
  148. package/frontendBuild/fontPruner.test.ts +5 -10
  149. package/frontendBuild/fontPruner.ts +5 -20
  150. package/frontendBuild/frontendBuild.test.ts +46 -69
  151. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  152. package/frontendBuild/hmrWatcher.test.ts +8 -32
  153. package/frontendBuild/hmrWatcher.ts +14 -71
  154. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  155. package/frontendBuild/precompressArtifacts.ts +5 -18
  156. package/frontendBuild/routeClientBuilder.ts +23 -46
  157. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  158. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  159. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  160. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  161. package/frontendBuild/styleContract.ts +1 -8
  162. package/frontendBuild/themeValidator.ts +4 -26
  163. package/frontendBuild/watchRootResolver.test.ts +23 -42
  164. package/frontendBuild/watchRootResolver.ts +5 -27
  165. package/getRelatedCnsts.ts +12 -92
  166. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  167. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  168. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  169. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  170. package/incrementalBuilder/builderChannel.test.ts +24 -49
  171. package/incrementalBuilder/builderChannel.ts +6 -37
  172. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  173. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  174. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  175. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  176. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  177. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  178. package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
  179. package/index.ts +5 -17
  180. package/integration/devResourceProbe.ts +50 -56
  181. package/integration/devStability.integration.test.ts +47 -194
  182. package/integration/devStabilityHarness.test.ts +13 -28
  183. package/integration/devStabilityHarness.ts +65 -330
  184. package/integration/ssrMemoryProbe.ts +41 -167
  185. package/libSource.test.ts +4 -15
  186. package/libSource.ts +19 -31
  187. package/lint/gritRules.test.ts +8 -24
  188. package/lint/no-deep-internal-import.grit +2 -2
  189. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  190. package/linter.test.ts +1 -2
  191. package/linter.ts +26 -116
  192. package/mobile/mobileTarget.ts +14 -13
  193. package/package.json +10 -5
  194. package/packageExports.test.ts +3 -18
  195. package/packageExportsMap.ts +3 -19
  196. package/piDependencyPins.test.ts +46 -0
  197. package/platformTest/BunTestReport.test.ts +60 -0
  198. package/platformTest/BunTestReport.ts +65 -0
  199. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  200. package/platformTest/IdleWatchedProcess.ts +73 -0
  201. package/platformTest/LinuxDockerTarget.ts +105 -0
  202. package/platformTest/PlatformTestRun.test.ts +64 -0
  203. package/platformTest/PlatformTestRun.ts +141 -0
  204. package/platformTest/PlatformTestTarget.ts +142 -0
  205. package/platformTest/TestSnapshot.test.ts +72 -0
  206. package/platformTest/TestSnapshot.ts +103 -0
  207. package/platformTest/WindowsSshTarget.ts +151 -0
  208. package/prompter.ts +3 -13
  209. package/qualityScanner.test.ts +9 -26
  210. package/qualityScanner.ts +197 -285
  211. package/recipeScanner.test.ts +7 -12
  212. package/recipeScanner.ts +9 -38
  213. package/repoIdentity.ts +2 -9
  214. package/routeSourceValidator.test.ts +0 -1
  215. package/routeSourceValidator.ts +7 -42
  216. package/scanInfo.ts +87 -216
  217. package/selectModel.ts +1 -2
  218. package/semver.ts +3 -5
  219. package/slicePlanner.test.ts +6 -24
  220. package/slicePlanner.ts +11 -32
  221. package/spinner.test.ts +3 -8
  222. package/spinner.ts +4 -16
  223. package/sshTunnel.ts +1 -6
  224. package/ssrScanner.test.ts +1 -6
  225. package/ssrScanner.ts +103 -113
  226. package/stripAnsi.ts +3 -0
  227. package/subspace.test.ts +30 -25
  228. package/subspace.ts +81 -115
  229. package/subspaceConfig.ts +11 -9
  230. package/testHelpers.ts +194 -0
  231. package/transforms/asyncDefaultExportDetector.ts +3 -11
  232. package/transforms/barrelAnalyzer.ts +43 -135
  233. package/transforms/barrelImportsPlugin.ts +38 -175
  234. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  235. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  236. package/transforms/moduleSyntax.ts +32 -0
  237. package/transforms/rscUseClientTransform.ts +35 -20
  238. package/transforms/transforms.test.ts +153 -44
  239. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  240. package/transforms/useClientBundlePlugin.ts +2 -22
  241. package/typeChecker.ts +7 -63
  242. package/types.ts +1 -0
  243. package/uploadRelease.ts +10 -25
  244. package/workflow/artifacts.ts +18 -0
  245. package/workflow/executor.test.ts +1 -12
  246. package/workflow/executor.ts +101 -110
  247. package/workflow/index.ts +0 -1
  248. package/workflow/moduleIndex.test.ts +4 -15
  249. package/workflow/moduleIndex.ts +9 -49
  250. package/workflow/plan.ts +18 -25
  251. package/workflow/render.ts +80 -96
  252. package/workflow/rolloutGate.test.ts +1 -1
  253. package/workflow/source.ts +52 -69
  254. package/workflow/sourceAst.ts +50 -0
  255. package/workspaceLayout.ts +4 -19
  256. package/abstractCompactor.ts +0 -112
  257. package/aiEditor.test.ts +0 -60
  258. package/aiEditor.ts +0 -387
  259. package/commandDecorators/commandMeta.ts +0 -7
  260. package/commandDecorators/types.ts +0 -10
  261. package/getDirname.ts +0 -4
  262. package/guideline.ts +0 -19
  263. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  264. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  265. package/workflow/primitive.ts +0 -59
package/scanInfo.ts CHANGED
@@ -27,20 +27,13 @@ const serviceFileTypes = [
27
27
  "zone",
28
28
  ] as const;
29
29
  type ServiceFileType = (typeof serviceFileTypes)[number];
30
- const databaseFileTypes = [
31
- "constant",
32
- "dictionary",
33
- "document",
34
- "service",
35
- "signal",
36
- "store",
37
- "template",
38
- "unit",
39
- "util",
40
- "view",
41
- "zone",
42
- ] as const;
30
+ const databaseFileTypes = ["constant", "dictionary", "document", ...serviceFileTypes.slice(1)] as const;
43
31
  type DatabaseFileType = (typeof databaseFileTypes)[number];
32
+ const uiFileTypes = new Set<DatabaseFileType>(["template", "unit", "util", "view", "zone"]);
33
+ const fileTypeOf = (filename: string) =>
34
+ databaseFileTypes.find((type) =>
35
+ filename.endsWith(uiFileTypes.has(type) ? `.${type[0].toUpperCase()}${type.slice(1)}.tsx` : `.${type}.ts`),
36
+ );
44
37
 
45
38
  type ModuleKind = "database" | "service" | "scalar";
46
39
 
@@ -56,14 +49,11 @@ const moduleUiFileTypes = {
56
49
  service: new Set(["Util", "Zone"]),
57
50
  scalar: new Set(["Template", "Unit"]),
58
51
  } satisfies Record<ModuleKind, Set<string>>;
59
- const testFilePattern = /\.(test|spec)\.(ts|tsx)$/;
60
52
 
61
- // The dependency scanner needs `typescript` (~65MB resident). Only the scan/sync commands run it, so
62
- // load it on demand rather than through the module graph of every process that imports scan info.
53
+ // Lazy: the dependency scanner pulls in `typescript` (~65MB resident), which only scan and sync need.
63
54
  const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExecutor) =>
64
55
  (await import("./dependencyScanner")).TypeScriptDependencyScanner.from(exec);
65
56
 
66
- const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
67
57
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
68
58
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
69
59
  async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
@@ -85,33 +75,21 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
85
75
  const allowedRootFiles: ReadonlySet<string> = rootAllowedFiles[exec.type];
86
76
  const allowedRootDirs: ReadonlySet<string> = rootAllowedDirs[exec.type];
87
77
  const { files, dirs } = await exec.getFilesAndDirs(".");
88
- files
89
- .filter((filename) => !allowedRootFiles.has(filename))
90
- .forEach((filename) => {
91
- addViolation(filename, `unsupported ${exec.type} root file`);
92
- });
93
- dirs
94
- .filter((dirname) => !allowedRootDirs.has(dirname))
95
- .forEach((dirname) => {
96
- addViolation(dirname, `unsupported ${exec.type} root folder`);
97
- });
78
+ for (const filename of files)
79
+ if (!allowedRootFiles.has(filename)) addViolation(filename, `unsupported ${exec.type} root file`);
80
+ for (const dirname of dirs)
81
+ if (!allowedRootDirs.has(dirname)) addViolation(dirname, `unsupported ${exec.type} root folder`);
98
82
 
99
- //* An app validates its page tree in `getPageKeys`, which a lib has no equivalent of — so a lib's own
100
- //* routes went unchecked until whichever app opted into them with `syncPageLibs` blew up on the sync.
83
+ //* A lib has no `getPageKeys`, so its own route files are validated here.
101
84
  if (exec.type === "lib")
102
85
  for (const { relativePath, reason } of await exec.getPageConventionViolations()) addViolation(relativePath, reason);
103
86
 
104
- libRoot.files
105
- .filter((filename) => !isAllowedLibFacetRootFile(filename))
106
- .forEach((filename) => {
87
+ for (const filename of libRoot.files)
88
+ if (!isAllowedLibFacetRootFile(filename))
107
89
  addViolation(path.join("lib", filename), "unsupported lib facet root file");
108
- });
109
-
110
- libRoot.dirs
111
- .filter((dirname) => dirname.startsWith("__") && !internalLibDirs.has(dirname))
112
- .forEach((dirname) => {
90
+ for (const dirname of libRoot.dirs)
91
+ if (dirname.startsWith("__") && !internalLibDirs.has(dirname))
113
92
  addViolation(path.join("lib", dirname), "unsupported internal lib folder");
114
- });
115
93
 
116
94
  const databaseDirs = libRoot.dirs.filter((dirname) => !dirname.startsWith("_"));
117
95
  const serviceDirs = libRoot.dirs.filter((dirname) => dirname.startsWith("_") && !dirname.startsWith("__"));
@@ -146,43 +124,23 @@ async function validateModuleFiles(
146
124
 
147
125
  const uiModuleName = moduleName[0].toUpperCase() + moduleName.slice(1);
148
126
 
149
- files.forEach((filename) => {
150
- const filePath = path.join(modulePath, filename);
151
- if (filename === "index.ts" || filename === "index.tsx" || isAllowedTestFile(filename)) return;
152
- if (filename === `${moduleName}.abstract.md`) return;
153
-
127
+ for (const filename of files) {
128
+ const scanPath = getScanPath(exec, path.join(modulePath, filename));
129
+ if (filename === "index.ts" || filename === "index.tsx" || /\.(test|spec)\.(ts|tsx)$/.test(filename)) continue;
130
+ if (filename === `${moduleName}.abstract.md`) continue;
154
131
  const uiMatch = filename.match(/^([A-Z][A-Za-z0-9]+)\.([A-Z][A-Za-z0-9]*)\.tsx$/);
155
- if (uiMatch) {
156
- const fileModuleName = uiMatch[1];
157
- const fileType = uiMatch[2];
158
- if (fileModuleName !== uiModuleName) {
159
- violations.push(
160
- `${getScanPath(exec, filePath)}: module name mismatch: expected '${uiModuleName}', got '${fileModuleName}'`,
161
- );
162
- }
163
- if (!moduleUiFileTypes[kind].has(fileType)) {
164
- violations.push(`${getScanPath(exec, filePath)}: unsupported ${kind} UI file`);
165
- }
166
- return;
167
- }
168
-
169
- const nonUiMatch = filename.match(/^([a-z][a-zA-Z0-9]*)\.([a-z][a-z0-9]*)\.ts$/);
170
- if (nonUiMatch) {
171
- const fileModuleName = nonUiMatch[1];
172
- const fileType = nonUiMatch[2];
173
- if (fileModuleName !== moduleName) {
174
- violations.push(
175
- `${getScanPath(exec, filePath)}: module name mismatch: expected '${moduleName}', got '${fileModuleName}'`,
176
- );
177
- }
178
- if (!moduleNonUiFileTypes[kind].has(fileType)) {
179
- violations.push(`${getScanPath(exec, filePath)}: unsupported ${kind} file`);
180
- }
181
- return;
132
+ const match = uiMatch ?? filename.match(/^([a-z][a-zA-Z0-9]*)\.([a-z][a-z0-9]*)\.ts$/);
133
+ if (!match) {
134
+ violations.push(`${scanPath}: unsupported module file`);
135
+ continue;
182
136
  }
183
-
184
- violations.push(`${getScanPath(exec, filePath)}: unsupported module file`);
185
- });
137
+ const [, fileModuleName, fileType] = match;
138
+ const expectedName = uiMatch ? uiModuleName : moduleName;
139
+ if (fileModuleName !== expectedName)
140
+ violations.push(`${scanPath}: module name mismatch: expected '${expectedName}', got '${fileModuleName}'`);
141
+ if (!(uiMatch ? moduleUiFileTypes : moduleNonUiFileTypes)[kind].has(fileType))
142
+ violations.push(`${scanPath}: unsupported ${kind}${uiMatch ? " UI" : ""} file`);
143
+ }
186
144
  }
187
145
 
188
146
  class ScanInfo {
@@ -239,51 +197,16 @@ class ScanInfo {
239
197
  } else databaseDirs.push(name);
240
198
  });
241
199
 
200
+ const collect = async (dir: string, name: string, kind: "databases" | "services" | "scalars") => {
201
+ for (const filename of await exec.readdir(dir)) {
202
+ const type = fileTypeOf(filename);
203
+ if (type) (files[type] as { [key in typeof kind]?: string[] })[kind]?.push(name);
204
+ }
205
+ };
242
206
  await Promise.all([
243
- ...databaseDirs.map(async (name) => {
244
- const filenames = await exec.readdir(path.join("lib", name));
245
- filenames.forEach((filename) => {
246
- if (filename.endsWith(".constant.ts")) files.constant.databases.push(name);
247
- else if (filename.endsWith(".dictionary.ts")) files.dictionary.databases.push(name);
248
- else if (filename.endsWith(".document.ts")) files.document.databases.push(name);
249
- else if (filename.endsWith(".service.ts")) files.service.databases.push(name);
250
- else if (filename.endsWith(".signal.ts")) files.signal.databases.push(name);
251
- else if (filename.endsWith(".store.ts")) files.store.databases.push(name);
252
- else if (filename.endsWith(".Template.tsx")) files.template.databases.push(name);
253
- else if (filename.endsWith(".Unit.tsx")) files.unit.databases.push(name);
254
- else if (filename.endsWith(".Util.tsx")) files.util.databases.push(name);
255
- else if (filename.endsWith(".View.tsx")) files.view.databases.push(name);
256
- else if (filename.endsWith(".Zone.tsx")) files.zone.databases.push(name);
257
- });
258
- }),
259
- ...serviceDirs.map(async (dirname) => {
260
- const name = dirname.slice(1);
261
- const filenames = await exec.readdir(path.join("lib", dirname));
262
- filenames.forEach((filename) => {
263
- if (filename.endsWith(".dictionary.ts")) files.dictionary.services.push(name);
264
- else if (filename.endsWith(".service.ts")) files.service.services.push(name);
265
- else if (filename.endsWith(".signal.ts")) files.signal.services.push(name);
266
- else if (filename.endsWith(".store.ts")) files.store.services.push(name);
267
- else if (filename.endsWith(".Template.tsx")) files.template.services.push(name);
268
- else if (filename.endsWith(".Unit.tsx")) files.unit.services.push(name);
269
- else if (filename.endsWith(".Util.tsx")) files.util.services.push(name);
270
- else if (filename.endsWith(".View.tsx")) files.view.services.push(name);
271
- else if (filename.endsWith(".Zone.tsx")) files.zone.services.push(name);
272
- });
273
- }),
274
- ...scalarDirs.map(async (name) => {
275
- const filenames = await exec.readdir(path.join("lib/__scalar", name));
276
- filenames.forEach((filename) => {
277
- if (filename.endsWith(".constant.ts")) files.constant.scalars.push(name);
278
- else if (filename.endsWith(".dictionary.ts")) files.dictionary.scalars.push(name);
279
- else if (filename.endsWith(".document.ts")) files.document.scalars.push(name);
280
- else if (filename.endsWith(".Template.tsx")) files.template.scalars.push(name);
281
- else if (filename.endsWith(".Unit.tsx")) files.unit.scalars.push(name);
282
- else if (filename.endsWith(".Util.tsx")) files.util.scalars.push(name);
283
- else if (filename.endsWith(".View.tsx")) files.view.scalars.push(name);
284
- else if (filename.endsWith(".Zone.tsx")) files.zone.scalars.push(name);
285
- });
286
- }),
207
+ ...databaseDirs.map((name) => collect(path.join("lib", name), name, "databases")),
208
+ ...serviceDirs.map((dirname) => collect(path.join("lib", dirname), dirname.slice(1), "services")),
209
+ ...scalarDirs.map((name) => collect(path.join("lib/__scalar", name), name, "scalars")),
287
210
  ]);
288
211
  const routes = exec.type === "lib" ? [] : await (exec as AppExecutor).getPageKeys();
289
212
  const common = {
@@ -309,35 +232,16 @@ class ScanInfo {
309
232
  constructor(scanResult: ScanResult) {
310
233
  this.name = scanResult.name;
311
234
  this.scanResult = scanResult;
312
- Object.entries(scanResult.files).forEach(([_key, value]) => {
313
- const key = _key as DatabaseFileType;
314
- const { databases, services, scalars } = value as {
315
- databases: string[];
316
- services?: string[];
317
- scalars?: string[];
318
- };
319
- databases.forEach((modelName) => {
320
- const model = this.database.get(modelName) ?? new Set<DatabaseFileType>();
321
- model.add(key);
322
- this.database.set(modelName, model);
323
- this.file[key].all.add(modelName);
324
- this.file[key].databases.add(modelName);
325
- });
326
- services?.forEach((serviceName) => {
327
- const service = this.service.get(serviceName) ?? new Set<ServiceFileType>();
328
- service.add(key as ServiceFileType);
329
- this.service.set(serviceName, service);
330
- this.file[key].all.add(serviceName);
331
- this.file[key].services.add(serviceName);
332
- });
333
- scalars?.forEach((scalarName) => {
334
- const scalar = this.scalar.get(scalarName) ?? new Set<ScalarFileType>();
335
- scalar.add(key as ScalarFileType);
336
- this.scalar.set(scalarName, scalar);
337
- this.file[key].all.add(scalarName);
338
- this.file[key].scalars.add(scalarName);
339
- });
340
- });
235
+ const modulesOf = { databases: this.database, services: this.service, scalars: this.scalar } as {
236
+ [key in "databases" | "services" | "scalars"]: Map<string, Set<DatabaseFileType>>;
237
+ };
238
+ for (const [key, groups] of Object.entries(scanResult.files) as [DatabaseFileType, { [kind: string]: string[] }][])
239
+ for (const kind of ["databases", "services", "scalars"] as const)
240
+ for (const name of groups[kind] ?? []) {
241
+ modulesOf[kind].set(name, (modulesOf[kind].get(name) ?? new Set()).add(key));
242
+ this.file[key].all.add(name);
243
+ this.file[key][kind].add(name);
244
+ }
341
245
  }
342
246
  getScanResult() {
343
247
  return this.scanResult;
@@ -351,6 +255,29 @@ class ScanInfo {
351
255
  getScalarModules() {
352
256
  return [...this.scalar.keys()];
353
257
  }
258
+
259
+ #sortedLibs: string[] | null = null;
260
+ protected sortedLibs(libDeps: string[]) {
261
+ if (this.#sortedLibs) return this.#sortedLibs;
262
+ const libIndices = LibInfo.getSortedLibIndices();
263
+ this.#sortedLibs = libDeps.sort((libNameA, libNameB) => {
264
+ const indexA = libIndices.get(libNameA);
265
+ const indexB = libIndices.get(libNameB);
266
+ if (indexA === undefined || indexB === undefined)
267
+ throw new Error(`LibInfo not found: ${libNameA} or ${libNameB}`);
268
+ return indexA - indexB;
269
+ });
270
+ return this.#sortedLibs;
271
+ }
272
+ protected sortedLibInfos(libDeps: string[]) {
273
+ return new Map(
274
+ this.sortedLibs(libDeps).map((libName) => {
275
+ const libInfo = LibInfo.libInfos.get(libName);
276
+ if (!libInfo) throw new Error(`LibInfo not found: ${libName}`);
277
+ return [libName, libInfo];
278
+ }),
279
+ );
280
+ }
354
281
  }
355
282
 
356
283
  const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
@@ -362,7 +289,6 @@ export class AppInfo extends ScanInfo {
362
289
 
363
290
  static appInfos = new Map<string, AppInfo>();
364
291
  static async fromExecutor(exec: AppExecutor, options: { refresh?: boolean } = {}) {
365
- // cache check
366
292
  const existingAppInfo = AppInfo.appInfos.get(exec.name);
367
293
  if (existingAppInfo && !options.refresh) return existingAppInfo;
368
294
  const scanResult = await ScanInfo.getScanResult(exec);
@@ -408,30 +334,11 @@ export class AppInfo extends ScanInfo {
408
334
  return [...libSet];
409
335
  }
410
336
 
411
- #sortedLibs: string[] | null = null;
412
- #getSortedLibs() {
413
- if (this.#sortedLibs) return this.#sortedLibs;
414
- const libIndices = LibInfo.getSortedLibIndices();
415
- this.#sortedLibs = this.libDeps.sort((libNameA, libNameB) => {
416
- const indexA = libIndices.get(libNameA);
417
- const indexB = libIndices.get(libNameB);
418
- if (indexA === undefined || indexB === undefined)
419
- throw new Error(`LibInfo not found: ${libNameA} or ${libNameB}`);
420
- return indexA - indexB;
421
- });
422
- return this.#sortedLibs;
423
- }
424
337
  getLibs() {
425
- return this.#getSortedLibs();
338
+ return this.sortedLibs(this.libDeps);
426
339
  }
427
340
  getLibInfos() {
428
- return new Map(
429
- this.#getSortedLibs().map((libName) => {
430
- const libInfo = LibInfo.libInfos.get(libName);
431
- if (!libInfo) throw new Error(`LibInfo not found: ${libName}`);
432
- return [libName, libInfo];
433
- }),
434
- );
341
+ return this.sortedLibInfos(this.libDeps);
435
342
  }
436
343
  }
437
344
  export class LibInfo extends ScanInfo {
@@ -482,36 +389,16 @@ export class LibInfo extends ScanInfo {
482
389
  return this.scanResult as LibScanResult;
483
390
  }
484
391
 
485
- #sortedLibs: string[] | null = null;
486
- #getSortedLibs() {
487
- if (this.#sortedLibs) return this.#sortedLibs;
488
- const libs = LibInfo.getSortedLibIndices();
489
- this.#sortedLibs = this.scanResult.libDeps.sort((libNameA, libNameB) => {
490
- const indexA = libs.get(libNameA);
491
- const indexB = libs.get(libNameB);
492
- if (indexA === undefined || indexB === undefined)
493
- throw new Error(`LibInfo not found: ${libNameA} or ${libNameB}`);
494
- return indexA - indexB;
495
- });
496
- return this.#sortedLibs;
497
- }
498
392
  getLibs() {
499
- return this.#getSortedLibs();
393
+ return this.sortedLibs(this.scanResult.libDeps);
500
394
  }
501
395
  getLibInfo(libName: string) {
502
396
  if (!this.getScanResult().libDeps.includes(libName)) return undefined;
503
- const libSet = new Set(this.#getSortedLibs());
504
- if (!libSet.has(libName)) throw new Error(`LibInfo is invalid: ${libName}`);
397
+ if (!this.getLibs().includes(libName)) throw new Error(`LibInfo is invalid: ${libName}`);
505
398
  return LibInfo.libInfos.get(libName);
506
399
  }
507
400
  getLibInfos() {
508
- return new Map(
509
- this.#getSortedLibs().map((libName) => {
510
- const libInfo = LibInfo.libInfos.get(libName);
511
- if (!libInfo) throw new Error(`LibInfo not found: ${libName}`);
512
- return [libName, libInfo];
513
- }),
514
- );
401
+ return this.sortedLibInfos(this.scanResult.libDeps);
515
402
  }
516
403
  }
517
404
 
@@ -524,27 +411,25 @@ export class PkgInfo {
524
411
  const [tsconfig, rootPackageJson] = await Promise.all([exec.getTsConfig(), exec.workspace.getPackageJson()]);
525
412
  const scanner = await createDependencyScanner(exec);
526
413
  const npmSet = new Set(Object.keys({ ...rootPackageJson.dependencies, ...rootPackageJson.devDependencies }));
414
+ const workspacePathOf = (resolve: string) => resolve.replace(/^\.\//, "");
527
415
  const pkgPathSet = new Set(
528
416
  Object.keys(tsconfig.compilerOptions.paths ?? {})
529
- .filter((path) => tsconfig.compilerOptions.paths?.[path]?.some((resolve) => resolve.startsWith("pkgs/")))
417
+ .filter((path) =>
418
+ tsconfig.compilerOptions.paths?.[path]?.some((resolve) => workspacePathOf(resolve).startsWith("pkgs/")),
419
+ )
530
420
  .map((path) => path.replace("/*", "")),
531
421
  );
532
422
  const [npmDepSet, pkgPathDepSet] = await scanner.getImportSets([npmSet, pkgPathSet]);
533
423
  const pkgDeps = [...pkgPathDepSet]
534
424
  .map((path) => {
535
425
  const pathSplitLength = path.split("/").length;
536
- return (tsconfig.compilerOptions.paths?.[path]?.[0] ?? "*")
426
+ return workspacePathOf(tsconfig.compilerOptions.paths?.[path]?.[0] ?? "*")
537
427
  .split("/")
538
428
  .slice(1, 1 + pathSplitLength)
539
429
  .join("/");
540
430
  })
541
431
  .filter((pkg) => pkg !== exec.name);
542
- const pkgScanResult = {
543
- name: exec.name,
544
- pkgDeps,
545
- dependencies: [...npmDepSet],
546
- };
547
- return pkgScanResult;
432
+ return { name: exec.name, pkgDeps, dependencies: [...npmDepSet] };
548
433
  }
549
434
 
550
435
  static #pkgInfos = new Map<string, PkgInfo>();
@@ -582,23 +467,9 @@ export class WorkspaceInfo {
582
467
  const [appNames, libNames, pkgNames] = await Promise.all([exec.getApps(), exec.getLibs(), exec.getPkgs()]);
583
468
  // TODO: prevent duplicate scan by resolving the dependency graph
584
469
  const [appInfos, libInfos, pkgInfos] = await Promise.all([
585
- Promise.all(
586
- appNames.map(async (appName) => {
587
- const app = AppExecutor.from(exec, appName);
588
- return await app.scan();
589
- }),
590
- ),
591
- Promise.all(
592
- libNames.map(async (libName) => {
593
- const lib = LibExecutor.from(exec, libName);
594
- return await lib.scan();
595
- }),
596
- ),
597
- Promise.all(
598
- pkgNames.map(async (pkgName) => {
599
- return await PkgExecutor.from(exec, pkgName).scan();
600
- }),
601
- ),
470
+ Promise.all(appNames.map(async (appName) => await AppExecutor.from(exec, appName).scan())),
471
+ Promise.all(libNames.map(async (libName) => await LibExecutor.from(exec, libName).scan())),
472
+ Promise.all(pkgNames.map(async (pkgName) => await PkgExecutor.from(exec, pkgName).scan())),
602
473
  ]);
603
474
  const workspaceInfo = new WorkspaceInfo(
604
475
  new Map(appInfos.map((app) => [app.exec.name, app as AppInfo])),
package/selectModel.ts CHANGED
@@ -3,9 +3,8 @@ import { select } from "@inquirer/prompts";
3
3
 
4
4
  export const selectModel = async (modulePath: string) => {
5
5
  const modelNames = readdirSync(`${modulePath}/lib`).filter((dir) => !dir.includes(".") && !dir.startsWith("_"));
6
- const modelName = await select({
6
+ return await select({
7
7
  message: "Select the model to create the unit for",
8
8
  choices: modelNames.map((name) => ({ name, value: name })),
9
9
  });
10
- return modelName;
11
10
  };
package/semver.ts CHANGED
@@ -1,14 +1,12 @@
1
- // `Bun.semver.order` compares range strings loosely — `^3.1049.0` vs `^3.721.0` answers 0 — so the
2
- // operator prefix has to come off before it sees the version.
1
+ // `Bun.semver.order` compares ranges loosely (`^3.1049.0` vs `^3.721.0` answers 0), so the operator comes off first.
3
2
  const stripRangeOperator = (version: string) => version.replace(/^[\s^~>=<v]+/, "").trim();
4
3
 
5
- const parseVersion = (version: string): number[] => {
6
- return version
4
+ const parseVersion = (version: string): number[] =>
5
+ version
7
6
  .replace(/^[^\d]*/, "")
8
7
  .split(/[.-]/)
9
8
  .map((part) => Number.parseInt(part, 10))
10
9
  .map((part) => (Number.isFinite(part) ? part : 0));
11
- };
12
10
 
13
11
  const compareNumeric = (a: string, b: string): number => {
14
12
  const left = parseVersion(a);
@@ -1,30 +1,13 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, rm } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
6
5
  import { formatSlicePlan, SlicePlanner } from "./slicePlanner";
6
+ import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
7
7
  import type { PackageJson } from "./types";
8
8
 
9
- const tempRoots: string[] = [];
10
- const originalEnv = { ...process.env };
11
-
12
- beforeEach(() => {
13
- process.env = { ...originalEnv };
14
- process.env.AKAN_PUBLIC_REPO_NAME = "workspace";
15
- process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
16
- process.env.AKAN_PUBLIC_ENV = "local";
17
- });
18
-
19
- afterEach(async () => {
20
- process.env = { ...originalEnv };
21
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
22
- });
23
-
24
- const write = async (filePath: string, content: string) => {
25
- await mkdir(path.dirname(filePath), { recursive: true });
26
- await writeFile(filePath, content);
27
- };
9
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "workspace", AKAN_PUBLIC_SERVE_DOMAIN: "example.com", AKAN_PUBLIC_ENV: "local" });
10
+ const makeTempRoot = tempDirs("akan-slice-");
28
11
 
29
12
  const gitignore = [
30
13
  "node_modules",
@@ -36,8 +19,7 @@ const gitignore = [
36
19
 
37
20
  // `AppExecutor.from` and `AppInfo.fromExecutor` both memoise by name, so each fixture needs a fresh one.
38
21
  const makeWorkspace = async (appName: string, libName: string) => {
39
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-slice-"));
40
- tempRoots.push(root);
22
+ const root = await makeTempRoot();
41
23
  const rootPackageJson: PackageJson = {
42
24
  name: "workspace",
43
25
  version: "0.0.1",
package/slicePlanner.ts CHANGED
@@ -18,12 +18,6 @@ export interface SlicePlan {
18
18
  warnings: string[];
19
19
  }
20
20
 
21
- /**
22
- * The exact file set a single app needs to live in a workspace of its own: the app, its transitive lib
23
- * closure, the workspace shell around them, and a root manifest for the result.
24
- *
25
- * Consumed by `akan plan-slice`, and by anything that moves an app or a lib between workspaces.
26
- */
27
21
  export class SlicePlanner {
28
22
  /** Root entries owned by an app, a lib or an in-tree package rather than by the workspace shell. */
29
23
  static readonly memberDirs = ["apps", "libs", "pkgs"];
@@ -39,13 +33,8 @@ export class SlicePlanner {
39
33
  ];
40
34
  static readonly akanPackages = ["akanjs", "@akanjs/devkit", "@akanjs/cli"];
41
35
 
42
- /**
43
- * What the framework imports but does not install, so a workspace root has to declare it: `react`,
44
- * `scheduler` and the RSC runtime, but also `ioredis`, `postgres` and `bullmq` the moment an app uses
45
- * a database or a queue. No source file in the workspace names any of them, so no scan can find them
46
- * — they are the akan packages' own `peerDependencies`, read wherever those are installed. Anything
47
- * they declare as a plain dependency is installed with them and needs no root entry.
48
- */
36
+ // The akan packages' peerDependencies (react, the RSC runtime, ioredis, postgres, bullmq): the framework imports
37
+ // them but no workspace source names them, so no scan can find them.
49
38
  static async #frameworkPeers(workspace: WorkspaceExecutor) {
50
39
  const peers = new Set<string>();
51
40
  let found = false;
@@ -67,14 +56,8 @@ export class SlicePlanner {
67
56
  return dependency.startsWith("@") ? `@types/${dependency.slice(1).replace("/", "__")}` : `@types/${dependency}`;
68
57
  }
69
58
 
70
- /**
71
- * The root manifest a workspace holding only these dependencies would carry, with the workspace's own
72
- * version specs kept — that is what makes one branch resolve to one dependency tree everywhere.
73
- *
74
- * A `@types/x` package is kept alongside `x`: nothing imports it by name, so no scan can see it, and
75
- * dropping it breaks the typecheck of every file that imports `x`. `workspaces` is dropped because it
76
- * names in-tree packages, which a slice never carries — it consumes akanjs from the registry.
77
- */
59
+ // `@types/x` is kept alongside `x` since nothing imports it by name; `workspaces` is dropped because a slice
60
+ // consumes akanjs from the registry.
78
61
  static async pruneDependencies(
79
62
  workspace: WorkspaceExecutor,
80
63
  rootPackageJson: PackageJson,
@@ -82,9 +65,8 @@ export class SlicePlanner {
82
65
  ) {
83
66
  const peers = await SlicePlanner.#frameworkPeers(workspace);
84
67
  const { workspaces: _workspaces, ...rest } = rootPackageJson;
85
- //* Without akanjs's peer list every peer looks unused, and pruning it produces an install that
86
- //* succeeds and a server that fails at its first redis or postgres call. Carry the dependencies
87
- //* whole instead and say why.
68
+ //* Without the peer list every peer looks unused, and pruning it breaks the server at its first redis or
69
+ //* postgres call.
88
70
  if (!peers)
89
71
  return {
90
72
  packageJson: rest as PackageJson,
@@ -121,10 +103,6 @@ export class SlicePlanner {
121
103
  this.#app = app;
122
104
  }
123
105
 
124
- async #trackedFiles(paths: string[]) {
125
- return await this.#app.workspace.listGitFiles(paths);
126
- }
127
-
128
106
  async #untrackedFiles(paths: string[]) {
129
107
  const [tracked, all] = await Promise.all([
130
108
  this.#app.workspace.listGitFiles(paths),
@@ -170,14 +148,16 @@ export class SlicePlanner {
170
148
  const slicePaths = [`apps/${this.#app.name}`, ...libs.map((lib) => `libs/${lib}`)];
171
149
 
172
150
  const [appFiles, allTracked, untracked, rootPackageJson] = await Promise.all([
173
- this.#trackedFiles([`apps/${this.#app.name}`]),
174
- this.#trackedFiles(["."]),
151
+ this.#app.workspace.listGitFiles([`apps/${this.#app.name}`]),
152
+ this.#app.workspace.listGitFiles(["."]),
175
153
  this.#untrackedFiles(slicePaths),
176
154
  this.#app.workspace.getPackageJson(),
177
155
  ]);
178
156
 
179
157
  const libFiles = Object.fromEntries(
180
- await Promise.all(libs.map(async (lib) => [lib, await this.#trackedFiles([`libs/${lib}`])] as const)),
158
+ await Promise.all(
159
+ libs.map(async (lib) => [lib, await this.#app.workspace.listGitFiles([`libs/${lib}`])] as const),
160
+ ),
181
161
  );
182
162
  const rootFiles = allTracked.filter((file) => !this.#isMemberFile(file));
183
163
 
@@ -211,7 +191,6 @@ export class SlicePlanner {
211
191
  }
212
192
  }
213
193
 
214
- /** Top-level entry each path sits under, so a long tree (`infra/**`) reads as one line. */
215
194
  const rootEntriesOf = (rootFiles: string[]) => [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
216
195
 
217
196
  export function formatSlicePlan(plan: SlicePlan) {
package/spinner.test.ts CHANGED
@@ -6,10 +6,7 @@ interface TtyStub {
6
6
  rawModeCalls: boolean[];
7
7
  }
8
8
 
9
- /**
10
- * ora only reaches for stdin when both streams look interactive, so the stub has to fake a tty on
11
- * stderr (which decides `isEnabled`) as well as on stdin.
12
- */
9
+ // ora only reaches for stdin when both streams look interactive, so stderr (which decides `isEnabled`) is faked too.
13
10
  const stubTty = (): TtyStub => {
14
11
  const rawModeCalls: boolean[] = [];
15
12
  const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => unknown };
@@ -41,8 +38,7 @@ const stubTty = (): TtyStub => {
41
38
  };
42
39
 
43
40
  describe("Spinner", () => {
44
- // A raw terminal at spawn time is what a Bun child snapshots and writes back when it exits, which is
45
- // how `akan start` used to leave a terminal that no longer turns Ctrl+C into SIGINT.
41
+ // A Bun child snapshots a raw terminal at spawn and writes it back on exit, leaving Ctrl+C no longer a SIGINT.
46
42
  test("never puts the terminal into raw mode while it spins", () => {
47
43
  const tty = stubTty();
48
44
  try {
@@ -59,8 +55,7 @@ describe("Spinner", () => {
59
55
  expect(Spinner.oraOptions.discardStdin).toBe(false);
60
56
  });
61
57
 
62
- // An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an
63
- // infinite one — the process then writes cursor moves until it is SIGKILLed.
58
+ // An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an infinite one.
64
59
  test("refuses to animate against a tty that reports no width", () => {
65
60
  expect(Spinner.canAnimate({ isTTY: true, columns: 0 } as NodeJS.WriteStream)).toBe(false);
66
61
  expect(Spinner.canAnimate({ isTTY: true, columns: 120 } as NodeJS.WriteStream)).toBe(true);
package/spinner.ts CHANGED
@@ -2,23 +2,11 @@ import ora, { type Ora } from "ora";
2
2
 
3
3
  export class Spinner {
4
4
  static padding = 12;
5
- /**
6
- * XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
7
- * spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
8
- * when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
9
- * the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
10
- * runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
11
- * `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
12
- */
5
+ // XXX: `discardStdin` must stay off: ora raw-modes the terminal while spinning, a Bun child spawned then writes that
6
+ // raw termios back when it exits, and Ctrl+C stops producing SIGINT.
13
7
  static oraOptions = { discardStdin: false } as const;
14
- /**
15
- * ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
16
- * makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
17
- * `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
18
- * cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
19
- * or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
20
- * animate anyway; fall back to plain lines.
21
- */
8
+ // ora's clear loop is `ceil(lineWidth / columns)`, so an unsized pty (`isTTY` with 0 columns: CI, `script`) never
9
+ // returns from `clear()`; such a terminal gets plain lines.
22
10
  static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
23
11
  return !stream.isTTY || stream.columns > 0;
24
12
  }