@akanjs/devkit 3.0.0-beta.3 → 3.0.0-beta.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +2 -2
  3. package/README.md +3 -2
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +427 -141
  19. package/akanConfig/akanConfig.ts +327 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +21 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +269 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +177 -0
  241. package/mobile/desktopBin.ts +143 -0
  242. package/mobile/desktopServerStage.test.ts +204 -0
  243. package/mobile/desktopServerStage.ts +138 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +177 -0
  250. package/mobile/nativeApp.ts +416 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +414 -0
  254. package/mobile/nativeConfig.ts +293 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +30 -25
  298. package/subspace.ts +81 -115
  299. package/subspaceConfig.ts +11 -9
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
package/subspace.ts CHANGED
@@ -55,6 +55,17 @@ export interface SubspacePushResult {
55
55
  prunedDependencies?: string[];
56
56
  }
57
57
 
58
+ export interface SubspaceEnvUploadResult {
59
+ name: string;
60
+ /** The cloud workspace whose env archive was replaced: the subspace's own, never this workspace's. */
61
+ workspaceId: string;
62
+ host: string;
63
+ outcome: "uploaded" | "cancelled";
64
+ apps: string[];
65
+ libs: string[];
66
+ files: string[];
67
+ }
68
+
58
69
  export interface SubspacePullResult {
59
70
  name: string;
60
71
  applied: string[];
@@ -64,15 +75,8 @@ export interface SubspacePullResult {
64
75
  incoming: SubspaceIncomingCommit[];
65
76
  }
66
77
 
67
- /**
68
- * One customer repo, mirrored from this workspace.
69
- *
70
- * Push is a squashed snapshot of the workspace's own tracked files, so a subspace's history never
71
- * carries the workspace's — which is also what keeps one customer's commit messages out of another's
72
- * repo. Pull is the reverse and rare: it compares the subspace against the last push it received rather
73
- * than against the workspace, so the diff is exactly the customer's own work however far the workspace
74
- * has moved on.
75
- */
78
+ // Push is a squashed snapshot, so no workspace history (or other customer's commit message) reaches a subspace;
79
+ // pull diffs against the last push received, so it is exactly the customer's own work.
76
80
  export class Subspace {
77
81
  static readonly anchorFile = "akan.subspace.json";
78
82
  /** Never reaches a subspace: it names every other customer's repo. */
@@ -100,6 +104,9 @@ export class Subspace {
100
104
  get apps() {
101
105
  return this.#declaration.apps;
102
106
  }
107
+ get workspaceId() {
108
+ return this.#declaration.workspaceId;
109
+ }
103
110
  get #remote() {
104
111
  return `subspace-${this.#declaration.name}`;
105
112
  }
@@ -119,7 +126,6 @@ export class Subspace {
119
126
  return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
120
127
  }
121
128
 
122
- /** Adds the subspace as a remote if absent, then fetches the branch into this workspace's object store. */
123
129
  async fetch(branch: string) {
124
130
  const remotes = (await this.#git(["remote"])).split("\n").map((line) => line.trim());
125
131
  if (remotes.includes(this.#remote)) await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
@@ -133,10 +139,7 @@ export class Subspace {
133
139
  }
134
140
  }
135
141
 
136
- /**
137
- * The last push, located by the one file only a push writes. A tag or a recorded sha would have to be
138
- * kept in step by hand; this file is already in the subspace's history and cannot drift out of it.
139
- */
142
+ //* Located by the one file only a push writes: unlike a tag or a recorded sha, it cannot drift out of the history.
140
143
  async anchor(branch: string): Promise<SubspaceAnchor> {
141
144
  const ref = `${this.#remote}/${branch}`;
142
145
  const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", Subspace.anchorFile])).trim();
@@ -170,11 +173,7 @@ export class Subspace {
170
173
  return Subspace.subspaceOwnedDirs.includes(segments[2] ?? "");
171
174
  }
172
175
 
173
- /**
174
- * A library manifest always differs: the subspace's copy carries the `akan.source` stamp a push writes and
175
- * the workspace's does not. Comparing it with that key removed is what keeps `status` and `diff` from
176
- * reporting every library as changed forever.
177
- */
176
+ // A library manifest always differs by the `akan.source` stamp a push writes, so it is compared without that key.
178
177
  #isStamped(file: string) {
179
178
  return /^libs\/[^/]+\/package\.json$/.test(file);
180
179
  }
@@ -187,9 +186,7 @@ export class Subspace {
187
186
  if (!theirs || !ours) return true;
188
187
  const normalize = (content: string) => {
189
188
  try {
190
- const manifest = JSON.parse(content) as Record<string, unknown>;
191
- delete manifest[LibSource.manifestKey];
192
- return JSON.stringify(manifest);
189
+ return LibSource.unstamped(content);
193
190
  } catch {
194
191
  return content;
195
192
  }
@@ -197,7 +194,10 @@ export class Subspace {
197
194
  return normalize(theirs) !== normalize(ours);
198
195
  }
199
196
 
200
- async #meaningfulFiles(ref: string, files: string[]) {
197
+ async #changedSliceFiles(ref: string, diffArgs: string[]) {
198
+ const files = (await this.#git(["diff", "--name-only", ...diffArgs]))
199
+ .split("\n")
200
+ .filter((file) => !!file.trim() && !this.#isSubspaceOwned(file));
201
201
  const kept = await Promise.all(
202
202
  files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
203
203
  );
@@ -212,14 +212,7 @@ export class Subspace {
212
212
  ]);
213
213
  if (!hasBranch) return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
214
214
  const ref = `${this.#remote}/${branch}`;
215
- const diff = await this.#git(["diff", "--name-only", "HEAD", ref, "--", ...paths]);
216
- const behindPaths = await this.#meaningfulFiles(
217
- ref,
218
- diff
219
- .split("\n")
220
- .filter((file) => !!file.trim())
221
- .filter((file) => !this.#isSubspaceOwned(file)),
222
- );
215
+ const behindPaths = await this.#changedSliceFiles(ref, ["HEAD", ref, "--", ...paths]);
223
216
  const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
224
217
  return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
225
218
  }
@@ -259,11 +252,7 @@ export class Subspace {
259
252
  return held;
260
253
  }
261
254
 
262
- /**
263
- * Puts back only the files the workspace does not ship. The workspace still owns the tracked env switch files
264
- * (`env.server.ts` / `env.client.ts`), which is what keeps them in step; everything else under `env/` —
265
- * the per-environment values, which the workspace gitignores and never had — belongs to the subspace and wins.
266
- */
255
+ // The workspace owns the tracked env switch files; the per-environment values it gitignores belong to the subspace.
267
256
  async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
268
257
  for (const { from, to } of held) await Subspace.#copyMissing(to, from);
269
258
  await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
@@ -279,25 +268,26 @@ export class Subspace {
279
268
  }
280
269
  }
281
270
 
282
- /**
283
- * `git archive` emits exactly the tracked files at HEAD, which is why the copy needs no exclude list of
284
- * its own: generated barrels, the `(libs)`/`public/libs` symlinks, env values, secrets and the lockfile
285
- * are all outside git and cannot enter the archive. Directory arguments only — a file list would put
286
- * route paths like `page/(docs)/…` through a shell.
287
- */
271
+ // `git archive` emits only tracked files, so generated barrels, lib symlinks, env values and secrets never enter.
288
272
  async #extract(paths: string[], excludes: string[] = []) {
289
- const pathspec = paths.map((entry) => `'${entry}'`).join(" ");
290
- const excludeArgs = excludes.map((entry) => `--exclude='${entry}'`).join(" ");
291
- await this.#workspace.exec(
292
- `git archive HEAD -- ${pathspec} | tar -x -C '${this.#clonePath}' ${excludeArgs}`.trim(),
293
- );
273
+ // Relative, because GNU tar reads a drive-letter archive name (`C:\…`) as `host:path` on a remote host.
274
+ const archivePath = path.relative(this.#workspace.workspaceRoot, `${this.#clonePath}.tar`);
275
+ await this.#workspace.spawn("git", ["archive", "-o", archivePath, "HEAD", "--", ...paths]);
276
+ try {
277
+ await this.#workspace.spawn("tar", [
278
+ "-x",
279
+ "-f",
280
+ archivePath,
281
+ "-C",
282
+ this.#clonePath,
283
+ ...excludes.map((entry) => `--exclude=${entry}`),
284
+ ]);
285
+ } finally {
286
+ await rm(path.join(this.#workspace.workspaceRoot, archivePath), { force: true });
287
+ }
294
288
  }
295
289
 
296
- /**
297
- * Verify-only, and an allowlist rather than a filter so no secret can reach a customer's clone even by
298
- * accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
299
- * by construction; the cloud workspace id is pinned to `local` rather than copied.
300
- */
290
+ // An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
301
291
  #localEnv() {
302
292
  const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
303
293
  return {
@@ -310,13 +300,8 @@ export class Subspace {
310
300
  };
311
301
  }
312
302
 
313
- /**
314
- * The CLI identifies a workspace root by `package.json` + `tsconfig.json` + `.env`, so a clone with no
315
- * `.env` cannot run `akan sync` at all — and one can never arrive with the slice, since every akan
316
- * workspace gitignores it and the subspace's real values are its own. Excluded through the clone's
317
- * `.git/info/exclude` rather than through the copied `.gitignore`: a workspace that omitted the pattern
318
- * would otherwise commit this file into the customer's repo.
319
- */
303
+ // The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
304
+ // `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
320
305
  async #writeLocalEnv() {
321
306
  const excludePath = path.join(this.#clonePath, ".git/info/exclude");
322
307
  const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
@@ -330,11 +315,7 @@ export class Subspace {
330
315
  await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
331
316
  }
332
317
 
333
- /**
334
- * A workspace's root manifest is the union of every app it holds, so shipping it verbatim installs
335
- * every other customer's dependency tree in this repo. It is rebuilt from this subspace's own slices
336
- * instead, keeping the workspace's exact version specs.
337
- */
318
+ // The root manifest is the union of every app's dependencies, so it is rebuilt from this subspace's slices.
338
319
  async #rewriteManifest(plans: SlicePlan[]) {
339
320
  const manifestPath = path.join(this.#clonePath, "package.json");
340
321
  if (!(await FileSys.fileExists(manifestPath))) return [];
@@ -359,8 +340,7 @@ export class Subspace {
359
340
  for (const member of Subspace.memberDirs)
360
341
  await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
361
342
 
362
- //* The shell is overlaid, never reconciled: a subspace owns its deployment (its own CI files and
363
- //* workflows live there), so a root entry the workspace does not have is left alone rather than deleted.
343
+ //* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
364
344
  await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
365
345
  await this.#extract(paths);
366
346
  await this.#restoreSubspaceOwned(held);
@@ -376,16 +356,13 @@ export class Subspace {
376
356
  return { libs, pruned };
377
357
  }
378
358
 
379
- /**
380
- * The `akan:secrets` block akan generates lists every app in the workspace by name, so it is filtered down to
381
- * this subspace's own apps. `bun.lock` is un-ignored because a subspace commits its lockfile — that is what
382
- * makes two subspaces on one branch resolve the same dependency tree rather than merely the same ranges.
383
- */
359
+ // `akan:secrets` names every workspace app, so it is filtered; `bun.lock` is un-ignored because a subspace commits it.
384
360
  async #rewriteGitignore() {
385
361
  const gitignorePath = path.join(this.#clonePath, ".gitignore");
386
362
  if (!(await FileSys.fileExists(gitignorePath))) return;
387
363
  const [begin = "", end = ""] = Subspace.secretsMarkers;
388
- const lines = (await FileSys.readText(gitignorePath)).split("\n");
364
+ // `git archive` checks text out as CRLF under Windows Git's default `core.autocrlf=true`.
365
+ const lines = (await FileSys.readText(gitignorePath)).split(/\r?\n/);
389
366
  const beginIdx = lines.indexOf(begin);
390
367
  const endIdx = lines.indexOf(end);
391
368
  const filtered =
@@ -417,12 +394,8 @@ export class Subspace {
417
394
  }
418
395
  }
419
396
 
420
- /**
421
- * Written onto the clone's manifest directly rather than through `LibSource`, which addresses a library
422
- * by its position in the *workspace's* workspace. The hash follows the same rule — the library's own git files
423
- * with `env/` and the stamp itself left out — and is only written when it would change, so an
424
- * up-to-date subspace stays clean and the push is skipped.
425
- */
397
+ // Not through `LibSource`, which addresses a library by its position in the workspace. The hash skips `env/` and
398
+ // the stamp itself.
426
399
  async #stampLib(clone: Executor, lib: string, branch: string) {
427
400
  const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
428
401
  if (!(await FileSys.fileExists(manifestPath))) return;
@@ -431,9 +404,8 @@ export class Subspace {
431
404
  const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
432
405
  const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
433
406
  const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
434
- //* The extraction overwrote the manifest with the workspace's, stamp and all, so the previous stamp has to
435
- //* come from the clone's HEAD. Reusing its `syncedAt` when nothing else moved is what leaves the file
436
- //* byte-identical to what is committed — otherwise every push is dirty and none is ever skipped.
407
+ //* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD; reusing its
408
+ //* `syncedAt` when nothing moved keeps the file byte-identical, or no push would ever be skipped.
437
409
  const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
438
410
  const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
439
411
  manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
@@ -466,26 +438,15 @@ export class Subspace {
466
438
  .split("\0")
467
439
  .filter((file) => !!file && !this.#isSubspaceOwned(file))
468
440
  .sort();
469
- //* `--cached` reads the clone's index, which the slice replaced the members without updating — so it still
470
- //* names every file the workspace has deleted or moved since the last push, and opening one throws. The
471
- //* index is only reconciled by the `git add -A` a push commits with, long after the stamp is written.
441
+ //* The index still lists files the workspace deleted since the last push (only the push's `git add -A`
442
+ //* reconciles it), and opening one throws.
472
443
  const present = await Promise.all(
473
444
  candidates.map(async (file) => await FileSys.entryExists(path.join(this.#clonePath, file))),
474
445
  );
475
446
  const files = candidates.filter((_, index) => present[index]);
476
- const hasher = new Bun.CryptoHasher("sha256");
477
- for (const file of files) {
478
- const content = await FileSys.readText(path.join(this.#clonePath, file));
479
- hasher.update(file);
480
- hasher.update("\0");
481
- if (file === `libs/${lib}/package.json`) {
482
- const manifest = JSON.parse(content) as Record<string, unknown>;
483
- delete manifest[LibSource.manifestKey];
484
- hasher.update(JSON.stringify(manifest));
485
- } else hasher.update(content);
486
- hasher.update("\0");
487
- }
488
- return hasher.digest("hex").slice(0, 32);
447
+ return await LibSource.hashFiles(files, `libs/${lib}/package.json`, (file) =>
448
+ FileSys.readText(path.join(this.#clonePath, file)),
449
+ );
489
450
  }
490
451
 
491
452
  /** Direction is subspace → workspace, so the patch reads as what a push would apply rather than its inverse. */
@@ -505,25 +466,14 @@ export class Subspace {
505
466
  const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
506
467
  if (!scoped.length) return { files: [], patch: "" };
507
468
  const from = `${this.#remote}/${branch}`;
508
- const pathspec = filter ? [filter] : scoped;
509
- const names = await this.#git(["diff", "--name-only", from, "HEAD", "--", ...pathspec]);
510
- const files = await this.#meaningfulFiles(
511
- from,
512
- names
513
- .split("\n")
514
- .filter((file) => !!file.trim())
515
- .filter((file) => !this.#isSubspaceOwned(file)),
516
- );
469
+ const files = await this.#changedSliceFiles(from, [from, "HEAD", "--", ...(filter ? [filter] : scoped)]);
517
470
  if (!files.length) return { files: [], patch: "" };
518
471
  //? argv, not a shell: a route path such as `page/(docs)/…` would need quoting through one.
519
472
  return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
520
473
  }
521
474
 
522
- /**
523
- * Refuses the subspace rather than throwing, so one customer repo that fails to install or sync does not
524
- * abort the push to the rest of the subspaces. The env is passed explicitly so the child sees the values
525
- * written into the clone instead of inheriting the workspace's own.
526
- */
475
+ // Refuses rather than throws, so one failing subspace does not abort the rest; the env is explicit so the child
476
+ // sees the clone's values, not the workspace's.
527
477
  async #verify(clone: Executor) {
528
478
  const env = { ...process.env, ...this.#localEnv() };
529
479
  try {
@@ -614,10 +564,7 @@ export class Subspace {
614
564
  await this.#git(["apply", "--3way", absolute]);
615
565
  }
616
566
 
617
- /**
618
- * A library hunk is never applied by default: the workspace is the one copy every other subspace is pushed from,
619
- * so adopting one customer's edit silently would ship it to all of them.
620
- */
567
+ // Not applied by default: every subspace is pushed from the workspace, so one customer's lib edit would ship to all.
621
568
  async #savePatch(range: string, files: string[], label: string) {
622
569
  const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/subspace", `${this.name}-${label}.patch`);
623
570
  await mkdir(path.dirname(patchPath), { recursive: true });
@@ -700,6 +647,25 @@ export function formatSubspacePullResult(result: SubspacePullResult) {
700
647
  return sections.join("\n");
701
648
  }
702
649
 
650
+ export function formatSubspaceEnvUpload(result: SubspaceEnvUploadResult) {
651
+ const sections = [
652
+ `Akan Subspace Env Upload — ${result.name}`,
653
+ `cloud workspace: ${result.workspaceId} · host: ${result.host}`,
654
+ "",
655
+ ...(result.outcome === "cancelled"
656
+ ? [" cancelled — the subspace's env archive is untouched."]
657
+ : [
658
+ `Uploaded (${result.files.length}), replacing that workspace's whole archive:`,
659
+ "",
660
+ ...result.files.map((file) => ` ${file}`),
661
+ "",
662
+ ` apps: ${result.apps.join(", ")}`,
663
+ ` libs: ${result.libs.join(", ") || "(none)"}`,
664
+ ]),
665
+ ];
666
+ return sections.join("\n");
667
+ }
668
+
703
669
  export function formatSubspaceDiff(result: SubspaceDiffResult) {
704
670
  const total = result.app.files.length + result.libs.files.length;
705
671
  const sections = [
package/subspaceConfig.ts CHANGED
@@ -5,13 +5,7 @@ import { FileSys } from "./fileSys";
5
5
 
6
6
  export type { SubspaceConfigInput, SubspaceDeclaration };
7
7
 
8
- /**
9
- * `akan.subspace.ts` at the workspace root: which customer repo each app is mirrored to.
10
- *
11
- * There is no branch field. The branch is whichever one the workspace is on, so the same declaration
12
- * serves `develop` and `main`, and every subspace on one branch holds the same akanjs version and the
13
- * same library source.
14
- */
8
+ // No branch field: the workspace's current branch is used, so every subspace on it holds the same akanjs and libs.
15
9
  export class SubspaceConfig {
16
10
  static readonly fileName = "akan.subspace.ts";
17
11
  static readonly defaultPushableBranches = ["main", "develop", "debug"];
@@ -37,6 +31,7 @@ export class SubspaceConfig {
37
31
  #assertValid() {
38
32
  const seenNames = new Set<string>();
39
33
  const appOwners = new Map<string, string>();
34
+ const idOwners = new Map<string, string>();
40
35
  for (const subspace of this.subspaces) {
41
36
  if (!subspace.name || !subspace.repo)
42
37
  throw new Error(`${SubspaceConfig.fileName}: every subspace needs a name and a repo`);
@@ -47,14 +42,21 @@ export class SubspaceConfig {
47
42
  throw new Error(`${SubspaceConfig.fileName}: subspace "${subspace.name}" declares no apps`);
48
43
  for (const app of subspace.apps) {
49
44
  const owner = appOwners.get(app);
50
- //* One app never goes to two subspaces: the two would need per-subspace domains and ids, which
51
- //* this declaration cannot express. Share code through a lib instead.
45
+ //* One app never goes to two subspaces: per-subspace domains and ids are not expressible. Share a lib instead.
52
46
  if (owner)
53
47
  throw new Error(
54
48
  `${SubspaceConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
55
49
  );
56
50
  appOwners.set(app, subspace.name);
57
51
  }
52
+ if (!subspace.workspaceId) continue;
53
+ const idOwner = idOwners.get(subspace.workspaceId);
54
+ //* An env upload replaces the cloud workspace's archive whole, so two subspaces would overwrite each other.
55
+ if (idOwner)
56
+ throw new Error(
57
+ `${SubspaceConfig.fileName}: subspaces "${idOwner}" and "${subspace.name}" declare the same workspaceId`,
58
+ );
59
+ idOwners.set(subspace.workspaceId, subspace.name);
58
60
  }
59
61
  }
60
62
 
package/testHelpers.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { afterEach, beforeEach } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ // Executors are imported on first use: they load akanjs/base, which patches the String/Boolean/Date globals.
7
+ const executors = () => import("@akanjs/devkit/executors");
8
+
9
+ export interface CallRecord {
10
+ name: string;
11
+ args: unknown[];
12
+ }
13
+
14
+ export const createCallRecorder = () => {
15
+ const calls: CallRecord[] = [];
16
+ return {
17
+ calls,
18
+ /** `Returns` only types the stub: it always returns `undefined`, so use it where the result is not read. */
19
+ record<Returns = void>(name: string, ...args: unknown[]): Returns {
20
+ calls.push({ name, args });
21
+ return undefined as unknown as Returns;
22
+ },
23
+ names() {
24
+ return calls.map((call) => call.name);
25
+ },
26
+ };
27
+ };
28
+
29
+ export const createFakeSpinner = (recorder = createCallRecorder()) => ({
30
+ succeed: (message?: string) => recorder.record("spinner.succeed", message),
31
+ fail: (message?: string) => recorder.record("spinner.fail", message),
32
+ });
33
+
34
+ export const createFakeExecutor = <Extra extends object = object>(
35
+ name: string,
36
+ extra: Extra = {} as Extra,
37
+ recorder = createCallRecorder(),
38
+ ) =>
39
+ ({
40
+ name,
41
+ cwdPath: `/workspace/${name}`,
42
+ workspaceRoot: "/workspace",
43
+ spinning: (message: string) => {
44
+ recorder.record(`${name}.spinning`, message);
45
+ return createFakeSpinner(recorder);
46
+ },
47
+ spawn: async (...args: unknown[]) => {
48
+ recorder.record(`${name}.spawn`, ...args);
49
+ return "";
50
+ },
51
+ exec: async (...args: unknown[]) => {
52
+ recorder.record(`${name}.exec`, ...args);
53
+ return "";
54
+ },
55
+ scan: async (...args: unknown[]) => {
56
+ recorder.record(`${name}.scan`, ...args);
57
+ return { name };
58
+ },
59
+ scanSync: async (...args: unknown[]) => {
60
+ recorder.record(`${name}.scanSync`, ...args);
61
+ return { name };
62
+ },
63
+ ...extra,
64
+ }) as Extra & {
65
+ name: string;
66
+ cwdPath: string;
67
+ workspaceRoot: string;
68
+ spinning: (message: string) => ReturnType<typeof createFakeSpinner>;
69
+ spawn: (...args: unknown[]) => Promise<string>;
70
+ exec: (...args: unknown[]) => Promise<string>;
71
+ scan: (...args: unknown[]) => Promise<{ name: string }>;
72
+ scanSync: (...args: unknown[]) => Promise<{ name: string }>;
73
+ };
74
+
75
+ export const makeCliTempWorkspace = async (parentDir = os.tmpdir()) => {
76
+ await mkdir(parentDir, { recursive: true });
77
+ const root = await mkdtemp(path.join(parentDir, "akan-cli-"));
78
+ await writeText(path.join(root, ".gitignore"), "");
79
+ await writeText(
80
+ path.join(root, ".env"),
81
+ ["AKAN_PUBLIC_REPO_NAME=repo", "AKAN_PUBLIC_SERVE_DOMAIN=localhost", "AKAN_PUBLIC_ENV=local", ""].join("\n"),
82
+ );
83
+ const { WorkspaceExecutor } = await executors();
84
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
85
+ return { root, workspace };
86
+ };
87
+
88
+ // Registers the afterEach that removes the root of every value the returned tracker was handed.
89
+ export const tempRoots = (remove = (root: string) => rm(root, { recursive: true, force: true })) => {
90
+ const roots: string[] = [];
91
+ afterEach(async () => {
92
+ await Promise.all(roots.splice(0).map((root) => remove(root)));
93
+ });
94
+ return <Temp extends { root: string }>(temp: Temp) => {
95
+ roots.push(temp.root);
96
+ return temp;
97
+ };
98
+ };
99
+
100
+ // Registers the afterEach that removes every directory the returned factory created.
101
+ export const tempDirs = (prefix: string, remove?: (root: string) => Promise<void>) => {
102
+ const track = tempRoots(remove);
103
+ return async () => track({ root: await mkdtemp(path.join(os.tmpdir(), prefix)) }).root;
104
+ };
105
+
106
+ export const isolateEnv = (env: Record<string, string> = {}) => {
107
+ const originalEnv = { ...process.env };
108
+ beforeEach(() => {
109
+ process.env = { ...originalEnv, ...env };
110
+ });
111
+ afterEach(() => {
112
+ process.env = { ...originalEnv };
113
+ });
114
+ };
115
+
116
+ export const writeText = async (filePath: string, content: string) => {
117
+ await mkdir(path.dirname(filePath), { recursive: true });
118
+ await writeFile(filePath, content);
119
+ };
120
+
121
+ export const writeJson = async (filePath: string, value: object) => {
122
+ await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
123
+ };
124
+
125
+ const tsconfigJson = { compilerOptions: { target: "ESNext", paths: {} } };
126
+
127
+ export const createTempApp = async (appName = "demo", parentDir?: string) => {
128
+ const { root, workspace } = await makeCliTempWorkspace(parentDir);
129
+ await writeJson(path.join(root, "package.json"), {
130
+ name: "repo",
131
+ version: "1.0.0",
132
+ description: "repo",
133
+ dependencies: {
134
+ react: "19.0.0",
135
+ "react-dom": "19.0.0",
136
+ "react-server-dom-webpack": "19.0.0",
137
+ },
138
+ });
139
+ await writeJson(path.join(root, "tsconfig.json"), { ...tsconfigJson, references: [] });
140
+ await writeJson(path.join(root, "apps", appName, "tsconfig.json"), tsconfigJson);
141
+ await writeJson(path.join(root, "apps", appName, "package.json"), {
142
+ name: appName,
143
+ version: "1.0.0",
144
+ description: appName,
145
+ dependencies: {},
146
+ devDependencies: {},
147
+ });
148
+ await writeText(path.join(root, "apps", appName, "akan.config.ts"), "export default {};\n");
149
+ await mkdir(path.join(root, "apps", appName, "lib", "__scalar"), { recursive: true });
150
+ const app = (await executors()).AppExecutor.from(workspace, appName);
151
+ return { root, workspace, app };
152
+ };
153
+
154
+ export const createTempLib = async (libName = "shared") => {
155
+ const { root, workspace } = await makeCliTempWorkspace();
156
+ await writeJson(path.join(root, "package.json"), {
157
+ name: "repo",
158
+ version: "1.0.0",
159
+ description: "repo",
160
+ dependencies: {},
161
+ devDependencies: {},
162
+ });
163
+ await writeJson(path.join(root, "tsconfig.json"), { ...tsconfigJson, references: [] });
164
+ const lib = (await executors()).LibExecutor.from(workspace, libName);
165
+ return { root, workspace, lib };
166
+ };
167
+
168
+ export const createTempPackage = async (pkgName = "@sample/tool") => {
169
+ const { root, workspace } = await makeCliTempWorkspace();
170
+ await writeJson(path.join(root, "package.json"), {
171
+ name: "repo",
172
+ version: "1.0.0",
173
+ description: "repo",
174
+ dependencies: { lodash: "4.0.0" },
175
+ devDependencies: { typescript: "6.0.0" },
176
+ });
177
+ await writeJson(path.join(root, "tsconfig.json"), tsconfigJson);
178
+ await writeJson(path.join(root, "pkgs", pkgName, "package.json"), {
179
+ name: pkgName,
180
+ version: "0.1.0",
181
+ description: "tool",
182
+ exports: {},
183
+ });
184
+ await writeJson(path.join(root, "pkgs", pkgName, "tsconfig.json"), tsconfigJson);
185
+ await writeText(path.join(root, "pkgs", pkgName, "index.ts"), 'import "lodash";\nexport const value = 1;\n');
186
+ const pkg = (await executors()).PkgExecutor.from(workspace, pkgName);
187
+ return { root, workspace, pkg };
188
+ };
189
+
190
+ export const createTempModule = async (moduleName = "post") => {
191
+ const { root, workspace, app } = await createTempApp("demo");
192
+ const module = (await executors()).ModuleExecutor.from(app, moduleName);
193
+ return { root, workspace, app, module };
194
+ };
@@ -7,8 +7,7 @@ type TypeScript = typeof ts;
7
7
  export class AsyncDefaultExportDetector {
8
8
  static #typescriptLoad: Promise<TypeScript> | undefined;
9
9
 
10
- // `typescript` costs ~70 MB resident and this detector is reached from the cli entry through the root
11
- // layout generator, so the compiler loads on first use rather than at import (`entryModuleGraph.test.ts`).
10
+ // Lazy: `typescript` is ~70 MB resident and the cli entry reaches this file (`entryModuleGraph.test.ts`).
12
11
  static #loadTypescript(): Promise<TypeScript> {
13
12
  AsyncDefaultExportDetector.#typescriptLoad ??= import("typescript").then(
14
13
  (mod) => (mod.default ?? mod) as TypeScript,
@@ -25,7 +24,7 @@ export class AsyncDefaultExportDetector {
25
24
  source,
26
25
  typescript.ScriptTarget.Latest,
27
26
  true,
28
- AsyncDefaultExportDetector.#scriptKind(typescript, moduleAbsPath),
27
+ /\.[tj]sx$/.test(moduleAbsPath) ? typescript.ScriptKind.TSX : typescript.ScriptKind.TS,
29
28
  );
30
29
  return new AsyncDefaultExportDetector(typescript).detectInSourceFile(sourceFile);
31
30
  } catch {
@@ -72,8 +71,7 @@ export class AsyncDefaultExportDetector {
72
71
  }
73
72
 
74
73
  if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
75
- const exportClause = statement.exportClause;
76
- for (const specifier of exportClause.elements) {
74
+ for (const specifier of statement.exportClause.elements) {
77
75
  if (specifier.name.text !== "default") continue;
78
76
  defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
79
77
  }
@@ -96,10 +94,4 @@ export class AsyncDefaultExportDetector {
96
94
  this.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
97
95
  );
98
96
  }
99
-
100
- static #scriptKind(typescript: TypeScript, moduleAbsPath: string): ts.ScriptKind {
101
- return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx")
102
- ? typescript.ScriptKind.TSX
103
- : typescript.ScriptKind.TS;
104
- }
105
97
  }