@akanjs/devkit 3.0.0-beta.9 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,141 @@
1
+ import path from "node:path";
2
+ import chalk from "chalk";
3
+ import type { TestTargetsConfig } from "../cloud/constants";
4
+ import { GlobalConfig } from "../cloud/globalConfig";
5
+ import { LinuxDockerTarget } from "./LinuxDockerTarget";
6
+ import { type PlatformTestResult, PlatformTestTarget, type RemoteTestPlatform } from "./PlatformTestTarget";
7
+ import { TestSnapshot } from "./TestSnapshot";
8
+ import { WindowsSshTarget } from "./WindowsSshTarget";
9
+
10
+ interface PlatformTestRunOptions {
11
+ workspaceRoot: string;
12
+ platforms: RemoteTestPlatform[];
13
+ pkgs: string[];
14
+ targets: TestTargetsConfig;
15
+ onProgress: (message: string) => void;
16
+ }
17
+
18
+ export interface PlatformVerdict {
19
+ blocking: PlatformTestResult[];
20
+ warning: PlatformTestResult[];
21
+ }
22
+
23
+ export class PlatformTestRun {
24
+ //? The CLI refuses a root without these, and the cli suite builds temp workspaces from them; none is a secret.
25
+ static readonly envKeys = [
26
+ "USE_AKANJS_PKGS",
27
+ "AKAN_PUBLIC_REPO_NAME",
28
+ "AKAN_PUBLIC_SERVE_DOMAIN",
29
+ "AKAN_PUBLIC_ENV",
30
+ "AKAN_PUBLIC_OPERATION_MODE",
31
+ "APP_OPERATION_MODE",
32
+ "SERVER_MODE",
33
+ ] as const;
34
+ static readonly promoteAfterGreenRuns = 3;
35
+
36
+ readonly runId: string;
37
+ readonly logDir: string;
38
+ readonly snapshot: TestSnapshot;
39
+ readonly #results: Promise<PlatformTestResult[]>;
40
+
41
+ constructor(runId: string, logDir: string, snapshot: TestSnapshot, results: Promise<PlatformTestResult[]>) {
42
+ this.runId = runId;
43
+ this.logDir = logDir;
44
+ this.snapshot = snapshot;
45
+ this.#results = results;
46
+ }
47
+
48
+ static async start({ workspaceRoot, platforms, pkgs, targets, onProgress }: PlatformTestRunOptions) {
49
+ const runId = PlatformTestRun.newRunId();
50
+ const logDir = path.join(workspaceRoot, "local", "deploy-test", runId);
51
+ const snapshot = await TestSnapshot.create(workspaceRoot, logDir);
52
+ const envPath = path.join(logDir, "env");
53
+ await Bun.write(envPath, await PlatformTestRun.allowlistedEnv(workspaceRoot));
54
+ const instances = platforms.map((platform) => {
55
+ if (platform === "linux") return new LinuxDockerTarget(targets.linux);
56
+ if (!targets.windows) throw new Error("the windows test target is not configured");
57
+ return new WindowsSshTarget(targets.windows);
58
+ });
59
+ const results = Promise.all(
60
+ instances.map((target) => target.run({ snapshot, envPath, runId, logDir, pkgs, onProgress })),
61
+ );
62
+ return new PlatformTestRun(runId, logDir, snapshot, results);
63
+ }
64
+
65
+ async results() {
66
+ return await this.#results;
67
+ }
68
+
69
+ static parsePlatforms(value: string): RemoteTestPlatform[] {
70
+ const names = value
71
+ .split(",")
72
+ .map((name) => name.trim().toLowerCase())
73
+ .filter((name) => name && name !== "none");
74
+ const unknown = names.filter((name) => name !== "linux" && name !== "windows");
75
+ if (unknown.length)
76
+ throw new Error(`Unknown test platform: ${unknown.join(", ")} (expected linux, windows, or none)`);
77
+ return [...new Set(names as RemoteTestPlatform[])];
78
+ }
79
+
80
+ static newRunId(now = new Date()) {
81
+ const pad = (value: number) => String(value).padStart(2, "0");
82
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
83
+ }
84
+
85
+ static async allowlistedEnv(workspaceRoot: string) {
86
+ const envFile = Bun.file(path.join(workspaceRoot, ".env"));
87
+ const lines = (await envFile.exists()) ? (await envFile.text()).split(/\r?\n/) : [];
88
+ const allowed = new Set<string>(PlatformTestRun.envKeys);
89
+ const kept = lines.filter((line) => allowed.has(line.split("=", 1)[0]?.trim() ?? ""));
90
+ return `${kept.join("\n")}\n`;
91
+ }
92
+
93
+ static failed(result: PlatformTestResult) {
94
+ return !!result.infraError || result.packages.some((pkg) => pkg.status !== "pass");
95
+ }
96
+
97
+ static verdict(results: PlatformTestResult[]): PlatformVerdict {
98
+ const failed = results.filter((result) => PlatformTestRun.failed(result));
99
+ return {
100
+ blocking: failed.filter((result) => result.policy === "gate"),
101
+ warning: failed.filter((result) => result.policy === "warn"),
102
+ };
103
+ }
104
+
105
+ format(results: PlatformTestResult[], { maxFailures = 15 }: { maxFailures?: number } = {}) {
106
+ const lines = [
107
+ chalk.bold(
108
+ `Platform tests · snapshot ${this.snapshot.digest} (${this.snapshot.fileCount} files) · ${path.relative(this.snapshot.root, this.logDir)}`,
109
+ ),
110
+ ];
111
+ for (const result of results) {
112
+ const mark = PlatformTestRun.failed(result) ? chalk.red("✖") : chalk.green("✔");
113
+ lines.push(`${mark} ${result.platform} (${result.policy}, ${Math.round(result.ms / 1000)}s)`);
114
+ if (result.infraError) lines.push(chalk.red(` setup: ${result.infraError}`));
115
+ for (const warning of result.envWarnings) lines.push(chalk.yellow(` env: ${warning}`));
116
+ for (const pkg of result.packages) {
117
+ const line = ` ${PlatformTestTarget.describe(pkg)}`;
118
+ lines.push(pkg.status === "pass" ? line : chalk.red(line));
119
+ for (const failure of pkg.report.failures.slice(0, maxFailures))
120
+ lines.push(chalk.dim(` ${failure.file ? `${failure.file} › ` : ""}${failure.name}`));
121
+ if (pkg.report.failures.length > maxFailures)
122
+ lines.push(chalk.dim(` … ${pkg.report.failures.length - maxFailures} more in ${pkg.logPath}`));
123
+ }
124
+ }
125
+ return lines.join("\n");
126
+ }
127
+
128
+ /** A `warn` platform becomes a gate once it has been green for enough deploys in a row. */
129
+ static async recordStreaks(results: PlatformTestResult[]) {
130
+ const targets = await GlobalConfig.getTestTargets();
131
+ const windows = targets.windows;
132
+ const result = results.find((one) => one.platform === "windows");
133
+ if (!windows || !result || (windows.policy ?? "warn") !== "warn") return null;
134
+ const greenStreak = PlatformTestRun.failed(result) ? 0 : (windows.greenStreak ?? 0) + 1;
135
+ const promoted = greenStreak >= PlatformTestRun.promoteAfterGreenRuns;
136
+ await GlobalConfig.setTestTargets({
137
+ windows: { ...windows, greenStreak, ...(promoted ? { policy: "gate" } : {}) },
138
+ });
139
+ return { platform: "windows" as const, greenStreak, promoted };
140
+ }
141
+ }
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import type { PlatformGatePolicy } from "../cloud/constants";
3
+ import { BunTestReport } from "./BunTestReport";
4
+ import type { IdleWatchedResult } from "./IdleWatchedProcess";
5
+ import type { TestSnapshot } from "./TestSnapshot";
6
+
7
+ export type RemoteTestPlatform = "linux" | "windows";
8
+ export type PackageTestStatus = "pass" | "fail" | "hung";
9
+
10
+ export interface PackageTestResult {
11
+ pkg: string;
12
+ status: PackageTestStatus;
13
+ report: BunTestReport;
14
+ ms: number;
15
+ logPath: string;
16
+ }
17
+
18
+ export interface PlatformTestResult {
19
+ platform: RemoteTestPlatform;
20
+ policy: PlatformGatePolicy;
21
+ packages: PackageTestResult[];
22
+ envWarnings: string[];
23
+ infraError: string | null;
24
+ ms: number;
25
+ }
26
+
27
+ export interface PlatformRunContext {
28
+ snapshot: TestSnapshot;
29
+ envPath: string;
30
+ runId: string;
31
+ logDir: string;
32
+ pkgs: string[];
33
+ onProgress: (message: string) => void;
34
+ }
35
+
36
+ export abstract class PlatformTestTarget {
37
+ static readonly installIdleMs = 5 * 60_000;
38
+ static readonly testIdleMs = 2 * 60_000;
39
+ protected static readonly gitIdentityEnv = {
40
+ GIT_AUTHOR_NAME: "akan-test",
41
+ GIT_AUTHOR_EMAIL: "akan-test@localhost",
42
+ GIT_COMMITTER_NAME: "akan-test",
43
+ GIT_COMMITTER_EMAIL: "akan-test@localhost",
44
+ GIT_CONFIG_COUNT: "1",
45
+ GIT_CONFIG_KEY_0: "init.defaultBranch",
46
+ GIT_CONFIG_VALUE_0: "main",
47
+ };
48
+
49
+ abstract readonly platform: RemoteTestPlatform;
50
+ abstract readonly policy: PlatformGatePolicy;
51
+ protected readonly tolerateScriptFailures: string[] = [];
52
+
53
+ protected abstract prepare(context: PlatformRunContext): Promise<void>;
54
+ protected abstract exec(command: string, logPath: string, idleMs: number): Promise<IdleWatchedResult>;
55
+ protected abstract cleanup(context: PlatformRunContext): Promise<void>;
56
+
57
+ async run(context: PlatformRunContext): Promise<PlatformTestResult> {
58
+ const startedAt = performance.now();
59
+ const envWarnings: string[] = [];
60
+ const packages: PackageTestResult[] = [];
61
+ const result = (infraError: string | null): PlatformTestResult => ({
62
+ platform: this.platform,
63
+ policy: this.policy,
64
+ packages,
65
+ envWarnings,
66
+ infraError,
67
+ ms: performance.now() - startedAt,
68
+ });
69
+ try {
70
+ await this.prepare(context);
71
+ await this.#install(context, envWarnings);
72
+ const build = await this.exec(
73
+ "bun run buildAkan",
74
+ this.#logPath(context, "build"),
75
+ PlatformTestTarget.installIdleMs,
76
+ );
77
+ if (build.exitCode !== 0) throw new Error(`buildAkan failed — see ${this.#logPath(context, "build")}`);
78
+ for (const pkg of context.pkgs) {
79
+ const logPath = this.#logPath(context, `test-${pkg.replaceAll("/", "__")}`);
80
+ const run = await this.exec(
81
+ `bun dist/pkgs/@akanjs/cli/index.js test ${pkg}`,
82
+ logPath,
83
+ PlatformTestTarget.testIdleMs,
84
+ );
85
+ const report = new BunTestReport(run.output);
86
+ const status: PackageTestStatus =
87
+ run.hung || run.timedOut ? "hung" : report.passed && run.exitCode === 0 ? "pass" : "fail";
88
+ packages.push({ pkg, status, report, ms: run.ms, logPath });
89
+ context.onProgress(`[${this.platform}] ${PlatformTestTarget.describe(packages.at(-1) as PackageTestResult)}`);
90
+ }
91
+ return result(null);
92
+ } catch (error) {
93
+ return result(error instanceof Error ? error.message : String(error));
94
+ } finally {
95
+ await this.cleanup(context).catch(() => undefined);
96
+ }
97
+ }
98
+
99
+ static describe({ pkg, status, report, ms }: PackageTestResult) {
100
+ const seconds = `${Math.round(ms / 1000)}s`;
101
+ if (status === "hung") return `${pkg}: hung${report.lastFile ? ` in ${report.lastFile}` : ""} (${seconds})`;
102
+ return `${pkg}: ${report.pass} pass / ${report.fail} fail${report.errors ? ` / ${report.errors} errors` : ""} (${seconds})`;
103
+ }
104
+
105
+ async #install(context: PlatformRunContext, envWarnings: string[]) {
106
+ const logPath = this.#logPath(context, "install");
107
+ const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
108
+ if (install.exitCode === 0) return;
109
+ const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
110
+ (match) => match[1] ?? "",
111
+ );
112
+ const tolerable =
113
+ failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
114
+ if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
115
+ //* A failed lifecycle script makes bun skip linking the root `.bin`; a second pass without scripts links it.
116
+ envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
117
+ const relink = await this.exec(
118
+ "bun install --frozen-lockfile --ignore-scripts",
119
+ this.#logPath(context, "install-relink"),
120
+ PlatformTestTarget.installIdleMs,
121
+ );
122
+ if (relink.exitCode !== 0) throw new Error(`bun install --ignore-scripts failed — see ${logPath}`);
123
+ }
124
+
125
+ #logPath(context: PlatformRunContext, name: string) {
126
+ return path.join(context.logDir, this.platform, `${name}.log`);
127
+ }
128
+
129
+ protected static async spawnText(command: string[], stdin?: string) {
130
+ const proc = Bun.spawn(command, {
131
+ stdin: stdin === undefined ? "ignore" : new Response(stdin),
132
+ stdout: "pipe",
133
+ stderr: "pipe",
134
+ });
135
+ const [stdout, stderr, exitCode] = await Promise.all([
136
+ new Response(proc.stdout).text(),
137
+ new Response(proc.stderr).text(),
138
+ proc.exited,
139
+ ]);
140
+ return { stdout, stderr, exitCode };
141
+ }
142
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText } from "../testHelpers";
5
+ import { TestSnapshot } from "./TestSnapshot";
6
+
7
+ const makeRoot = tempDirs("akan-snapshot-");
8
+ const git = async (cwd: string, ...args: string[]) => {
9
+ const proc = Bun.spawn(["git", ...args], {
10
+ cwd,
11
+ stdout: "ignore",
12
+ stderr: "ignore",
13
+ env: {
14
+ ...process.env,
15
+ GIT_AUTHOR_NAME: "t",
16
+ GIT_AUTHOR_EMAIL: "t@t",
17
+ GIT_COMMITTER_NAME: "t",
18
+ GIT_COMMITTER_EMAIL: "t@t",
19
+ },
20
+ });
21
+ expect(await proc.exited).toBe(0);
22
+ };
23
+ const write = async (root: string, file: string, content: string) => await writeText(path.join(root, file), content);
24
+ const makeRepo = async () => {
25
+ const root = await makeRoot();
26
+ await git(root, "init", "-q");
27
+ await write(root, ".gitignore", "ignored/\nlocal/\n.env\n");
28
+ await write(root, "tracked.ts", "export {};\n");
29
+ await write(root, "gone.ts", "export {};\n");
30
+ await git(root, "add", ".");
31
+ await git(root, "commit", "-q", "-m", "init");
32
+ await unlink(path.join(root, "gone.ts"));
33
+ await write(root, "untracked.ts", "export const a = 1;\n");
34
+ await write(root, "ignored/secret.ts", "export {};\n");
35
+ await write(root, ".env", "OPENAI_API_KEY=nope\n");
36
+ return root;
37
+ };
38
+ const tarEntries = async (tarPath: string) => {
39
+ const proc = Bun.spawn(["tar", "-tf", tarPath], { stdout: "pipe" });
40
+ return (await new Response(proc.stdout).text()).split(/\r?\n/).filter(Boolean).sort();
41
+ };
42
+
43
+ describe("TestSnapshot", () => {
44
+ test("packs what git would show — tracked and untracked, never ignored or deleted", async () => {
45
+ const root = await makeRepo();
46
+ const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
47
+
48
+ expect(await tarEntries(snapshot.tarPath)).toEqual([".gitignore", "tracked.ts", "untracked.ts"]);
49
+ expect(snapshot.fileCount).toBe(3);
50
+ });
51
+
52
+ test("reports what changed underneath it", async () => {
53
+ const root = await makeRepo();
54
+ const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
55
+ await write(root, "tracked.ts", "export const changed = true;\n");
56
+ await write(root, "added.ts", "export {};\n");
57
+ await unlink(path.join(root, "untracked.ts"));
58
+
59
+ expect(await snapshot.drift()).toEqual({ changed: ["tracked.ts"], added: ["added.ts"], removed: ["untracked.ts"] });
60
+ });
61
+
62
+ test("refuses an env file git does not ignore instead of shipping it to a test host", async () => {
63
+ const root = await makeRepo();
64
+ await write(root, "apps/demo/env/env.server.local.ts", "export default {};\n");
65
+ await write(root, "apps/demo/env/env.server.example.ts", "export default {};\n");
66
+ await write(root, "templates/.env.template", "KEY=\n");
67
+
68
+ await expect(TestSnapshot.create(root, path.join(root, "local", "run"))).rejects.toThrow(
69
+ "apps/demo/env/env.server.local.ts",
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,103 @@
1
+ import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface SnapshotDrift {
5
+ changed: string[];
6
+ added: string[];
7
+ removed: string[];
8
+ }
9
+
10
+ //? Per-file hashes are kept so the tree is re-checked before publishing: a run gates only if nothing changed under it.
11
+ export class TestSnapshot {
12
+ static readonly #secretPatterns = [
13
+ /(^|\/)\.env(\.(?!(template|example|sample)$)[^/]+)?$/,
14
+ /(^|\/)env\/env\.(client|server)\.(?!(type|example)\.ts$)[^/]+\.ts$/,
15
+ ];
16
+
17
+ readonly root: string;
18
+ readonly tarPath: string;
19
+ readonly digest: string;
20
+ readonly #hashes: Map<string, string>;
21
+
22
+ constructor(root: string, tarPath: string, hashes: Map<string, string>) {
23
+ this.root = root;
24
+ this.tarPath = tarPath;
25
+ this.#hashes = hashes;
26
+ this.digest = TestSnapshot.#digestOf(hashes);
27
+ }
28
+
29
+ get fileCount() {
30
+ return this.#hashes.size;
31
+ }
32
+
33
+ static async create(root: string, outDir: string) {
34
+ const hashes = await TestSnapshot.#hashTree(root);
35
+ //? A tracked file is already in the repository; only an untracked one nobody ignored can leak by being copied.
36
+ const untracked = await TestSnapshot.#gitList(root, ["--others", "--exclude-standard"]);
37
+ const secrets = untracked.filter((file) => TestSnapshot.#secretPatterns.some((re) => re.test(file)));
38
+ if (secrets.length)
39
+ throw new Error(
40
+ `Refusing to snapshot env files git does not ignore — they would be copied to every test target: ${secrets.join(", ")}`,
41
+ );
42
+ await mkdir(outDir, { recursive: true });
43
+ const listPath = path.join(outDir, "files.txt");
44
+ const tarPath = path.join(outDir, "src.tar");
45
+ await writeFile(listPath, `${[...hashes.keys()].join("\n")}\n`);
46
+ const tar = Bun.spawn(
47
+ ["tar", ...(process.platform === "darwin" ? ["--no-xattrs"] : []), "-cf", tarPath, "-T", listPath],
48
+ { cwd: root, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "ignore", stderr: "pipe" },
49
+ );
50
+ if ((await tar.exited) !== 0) throw new Error(`tar failed: ${await new Response(tar.stderr).text()}`);
51
+ return new TestSnapshot(root, tarPath, hashes);
52
+ }
53
+
54
+ async drift(): Promise<SnapshotDrift> {
55
+ const current = await TestSnapshot.#hashTree(this.root);
56
+ const changed = [...current].filter(([file, hash]) => this.#hashes.has(file) && this.#hashes.get(file) !== hash);
57
+ return {
58
+ changed: changed.map(([file]) => file),
59
+ added: [...current.keys()].filter((file) => !this.#hashes.has(file)),
60
+ removed: [...this.#hashes.keys()].filter((file) => !current.has(file)),
61
+ };
62
+ }
63
+
64
+ static async #gitList(root: string, args: string[]) {
65
+ const proc = Bun.spawn(["git", "ls-files", ...args, "-z"], { cwd: root, stdout: "pipe", stderr: "pipe" });
66
+ const [stdout, stderr, exitCode] = await Promise.all([
67
+ new Response(proc.stdout).text(),
68
+ new Response(proc.stderr).text(),
69
+ proc.exited,
70
+ ]);
71
+ if (exitCode !== 0) throw new Error(`git ls-files failed in ${root}: ${stderr.trim()}`);
72
+ return [...new Set(stdout.split("\0").filter(Boolean))].sort();
73
+ }
74
+
75
+ static async #listFiles(root: string) {
76
+ const listed = await TestSnapshot.#gitList(root, ["--cached", "--others", "--exclude-standard"]);
77
+ //? `--cached` still lists a tracked file deleted from the working tree, which tar would then fail on.
78
+ const present = await Promise.all(
79
+ listed.map(async (file) => ((await stat(path.join(root, file)).catch(() => null))?.isFile() ? file : null)),
80
+ );
81
+ return present.filter((file): file is string => file !== null);
82
+ }
83
+
84
+ static async #hashTree(root: string) {
85
+ const files = await TestSnapshot.#listFiles(root);
86
+ const hashes = new Map<string, string>();
87
+ for (let start = 0; start < files.length; start += 64) {
88
+ const batch = files.slice(start, start + 64);
89
+ const batchHashes = await Promise.all(
90
+ batch.map(async (file) => Bun.hash(await Bun.file(path.join(root, file)).bytes()).toString(16)),
91
+ );
92
+ for (const [idx, file] of batch.entries()) hashes.set(file, batchHashes[idx] ?? "");
93
+ }
94
+ return hashes;
95
+ }
96
+
97
+ static #digestOf(hashes: Map<string, string>) {
98
+ return Bun.hash([...hashes].map(([file, hash]) => `${file}:${hash}`).join("\n"))
99
+ .toString(16)
100
+ .padStart(16, "0")
101
+ .slice(0, 12);
102
+ }
103
+ }
@@ -0,0 +1,151 @@
1
+ import { existsSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { PlatformGatePolicy, WindowsTestTargetConfig } from "../cloud/constants";
5
+ import { IdleWatchedProcess } from "./IdleWatchedProcess";
6
+ import { type PlatformRunContext, PlatformTestTarget } from "./PlatformTestTarget";
7
+
8
+ export class WindowsSshTarget extends PlatformTestTarget {
9
+ static readonly utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
10
+ static readonly defaultWorkRoot = "C:\\akan-test";
11
+ //* arm64 has neither a sharp@0.32 prebuilt nor a Chrome for puppeteer's postinstall; x64 users get both.
12
+ static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer"];
13
+ static readonly #runDirPattern = "^\\d{8}-\\d{6}$";
14
+ static readonly #keptRuns = 2;
15
+
16
+ readonly platform = "windows";
17
+ readonly policy: PlatformGatePolicy;
18
+ protected override readonly tolerateScriptFailures: string[];
19
+ readonly #config: WindowsTestTargetConfig;
20
+ #host: string;
21
+ #runDir: string | null = null;
22
+
23
+ constructor(config: WindowsTestTargetConfig) {
24
+ super();
25
+ this.#config = config;
26
+ this.#host = process.env.AKAN_TEST_WINDOWS_HOST ?? config.host;
27
+ this.policy = config.policy ?? "warn";
28
+ this.tolerateScriptFailures = config.tolerateScriptFailures ?? WindowsSshTarget.defaultTolerateScriptFailures;
29
+ }
30
+
31
+ get #workRoot() {
32
+ return this.#config.workRoot ?? WindowsSshTarget.defaultWorkRoot;
33
+ }
34
+
35
+ protected async prepare(context: PlatformRunContext) {
36
+ await this.#connect();
37
+ const bunVersion = (await this.#ssh("bun --version")).stdout.trim();
38
+ if (bunVersion !== Bun.version)
39
+ throw new Error(
40
+ `the Windows host runs Bun ${bunVersion || "(none)"}, this host ${Bun.version} — align them first`,
41
+ );
42
+ if ((await this.#ssh("git --version")).exitCode !== 0) throw new Error("git is not installed on the Windows host");
43
+ const runDir = `${this.#workRoot}\\${context.runId}`;
44
+ const mkdir = await this.#ssh(`New-Item -ItemType Directory -Force -Path '${runDir}\\w' | Out-Null`);
45
+ if (mkdir.exitCode !== 0) throw new Error(`could not create ${runDir}: ${mkdir.stderr.trim()}`);
46
+ this.#runDir = runDir;
47
+ for (const [local, remoteName] of [
48
+ [context.snapshot.tarPath, "src.tar"],
49
+ [context.envPath, "env"],
50
+ ] as const) {
51
+ const copied = await WindowsSshTarget.spawnText([
52
+ ...this.#scpBase(),
53
+ local,
54
+ this.#remoteScpPath(`${runDir}\\${remoteName}`),
55
+ ]);
56
+ if (copied.exitCode !== 0) throw new Error(`scp ${remoteName} failed: ${copied.stderr.trim()}`);
57
+ }
58
+ const extract = await this.exec(
59
+ "tar -xf ..\\src.tar && copy /Y ..\\env .env >nul && git init -q",
60
+ path.join(context.logDir, this.platform, "extract.log"),
61
+ PlatformTestTarget.installIdleMs,
62
+ );
63
+ if (extract.exitCode !== 0) throw new Error("extracting the snapshot on the Windows host failed");
64
+ }
65
+
66
+ protected async exec(command: string, logPath: string, idleMs: number) {
67
+ const runDir = this.#runDir;
68
+ if (!runDir) throw new Error("the Windows run directory was not prepared");
69
+ let remotePid: string | null = null;
70
+ const script = [
71
+ "$ProgressPreference = 'SilentlyContinue'",
72
+ "[Console]::OutputEncoding = [Text.Encoding]::UTF8",
73
+ "'PID=' + $PID",
74
+ ...Object.entries(PlatformTestTarget.gitIdentityEnv).map(([key, value]) => `$env:${key} = '${value}'`),
75
+ `Set-Location -LiteralPath '${runDir}\\w'`,
76
+ `cmd /c "${command} 2>&1"`,
77
+ "exit $LASTEXITCODE",
78
+ ].join("; ");
79
+ return await new IdleWatchedProcess([...this.#sshBase(), script], {
80
+ logPath,
81
+ idleMs,
82
+ onOutput: (text) => {
83
+ remotePid ??= /PID=(\d+)/.exec(text)?.[1] ?? null;
84
+ },
85
+ onIdle: async () => {
86
+ if (remotePid) await this.#ssh(`taskkill /F /T /PID ${remotePid} | Out-Null`);
87
+ },
88
+ }).run();
89
+ }
90
+
91
+ protected async cleanup() {
92
+ this.#runDir = null;
93
+ await this.#ssh(
94
+ [
95
+ `Get-ChildItem -LiteralPath '${this.#workRoot}' -Directory`,
96
+ `Where-Object { $_.Name -match '${WindowsSshTarget.#runDirPattern}' }`,
97
+ "Sort-Object Name -Descending",
98
+ `Select-Object -Skip ${WindowsSshTarget.#keptRuns}`,
99
+ "ForEach-Object { cmd /c rmdir /s /q $_.FullName }",
100
+ ].join(" | "),
101
+ );
102
+ }
103
+
104
+ async #connect() {
105
+ if ((await this.#ssh("exit 0")).exitCode === 0) return;
106
+ const vm = this.#config.utmVm;
107
+ if (!vm || !existsSync(WindowsSshTarget.utmctlPath))
108
+ throw new Error(`cannot reach ${this.#config.user}@${this.#host} over ssh`);
109
+ const status = (await WindowsSshTarget.spawnText([WindowsSshTarget.utmctlPath, "status", vm])).stdout.trim();
110
+ if (status !== "started") await WindowsSshTarget.spawnText([WindowsSshTarget.utmctlPath, "start", vm]);
111
+ for (let attempt = 0; attempt < 24; attempt++) {
112
+ const addresses = (await WindowsSshTarget.spawnText([WindowsSshTarget.utmctlPath, "ip-address", vm])).stdout;
113
+ const ipv4 = addresses.split(/\s+/).find((address) => /^\d+\.\d+\.\d+\.\d+$/.test(address));
114
+ if (ipv4) this.#host = ipv4;
115
+ if ((await this.#ssh("exit 0")).exitCode === 0) return;
116
+ await Bun.sleep(5_000);
117
+ }
118
+ throw new Error(`UTM VM "${vm}" did not answer ssh at ${this.#host}`);
119
+ }
120
+
121
+ #sshOptions() {
122
+ const expand = (file: string) => (file.startsWith("~/") ? path.join(os.homedir(), file.slice(2)) : file);
123
+ return [
124
+ "-i",
125
+ expand(this.#config.identityFile),
126
+ ...(this.#config.knownHostsFile ? ["-o", `UserKnownHostsFile=${expand(this.#config.knownHostsFile)}`] : []),
127
+ "-o",
128
+ "BatchMode=yes",
129
+ "-o",
130
+ "ConnectTimeout=8",
131
+ "-o",
132
+ "ServerAliveInterval=30",
133
+ ];
134
+ }
135
+
136
+ #sshBase() {
137
+ return ["ssh", ...this.#sshOptions(), `${this.#config.user}@${this.#host}`];
138
+ }
139
+
140
+ #scpBase() {
141
+ return ["scp", "-q", ...this.#sshOptions()];
142
+ }
143
+
144
+ #remoteScpPath(windowsPath: string) {
145
+ return `${this.#config.user}@${this.#host}:/${windowsPath.replaceAll("\\", "/")}`;
146
+ }
147
+
148
+ async #ssh(script: string) {
149
+ return await WindowsSshTarget.spawnText([...this.#sshBase(), script]);
150
+ }
151
+ }
package/prompter.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import fsPromise from "node:fs/promises";
2
2
  import { input, select } from "@inquirer/prompts";
3
3
 
4
- import { getDirname } from "./getDirname";
5
- import type { GuideGenerateJson } from "./guideline";
4
+ import { getDirname } from "./fileSys";
6
5
 
7
6
  interface FileUpdateRequestProps {
8
7
  context: string;
@@ -34,17 +33,8 @@ export class Prompter {
34
33
  const guidelineRoot = await Prompter.#getGuidelineRoot();
35
34
  return (await fsPromise.readdir(guidelineRoot)).filter((name) => !name.startsWith("_")).sort();
36
35
  }
37
- static async getGuideJson(guideName: string): Promise<GuideGenerateJson> {
38
- const guidelineRoot = await Prompter.#getGuidelineRoot();
39
- const filePath = `${guidelineRoot}/${guideName}/${guideName}.generate.json`;
40
- const guideJson = await fsPromise.readFile(filePath, "utf-8");
41
- return JSON.parse(guideJson) as GuideGenerateJson;
42
- }
43
- /**
44
- * The name is checked against the directory listing rather than interpolated straight into a path:
45
- * it arrives from an MCP tool argument, and an unknown one has to name the valid set instead of
46
- * surfacing an ENOENT that carries the host's own paths.
47
- */
36
+ // Checked against the listing, not interpolated into a path: the name is an MCP tool argument, and an unknown one
37
+ // must not surface an ENOENT carrying the host's paths.
48
38
  static async getInstruction(guideName: string): Promise<string> {
49
39
  const guideNames = await Prompter.listGuidelines();
50
40
  if (!guideNames.includes(guideName))