@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,202 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { readFile, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs, writeJson, writeText } from "../testHelpers";
5
+
6
+ // The cloud rotates a refresh token on every use and treats a second use as theft, revoking every session.
7
+ class FakeCloud {
8
+ readonly server: ReturnType<typeof Bun.serve>;
9
+ readonly refreshes: string[] = [];
10
+ reused = false;
11
+ delayMs = 0;
12
+ hang: boolean = false;
13
+ readonly #refreshTokens = new Set(["refresh-0"]);
14
+ readonly #spent = new Set<string>();
15
+ readonly #jwts = new Set(["jwt-0"]);
16
+ #issued = 0;
17
+
18
+ constructor() {
19
+ this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
20
+ }
21
+
22
+ get host() {
23
+ return `http://127.0.0.1:${this.server.port}`;
24
+ }
25
+
26
+ async #route(request: Request) {
27
+ const url = new URL(request.url);
28
+ if (url.pathname === "/api/getRemoteSelf") {
29
+ const jwt = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
30
+ return Response.json(this.#jwts.has(jwt) ? { id: "u1", nickname: "akan" } : null);
31
+ }
32
+ if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
33
+ const { refreshToken } = (await request.json()) as { refreshToken: string };
34
+ this.refreshes.push(refreshToken);
35
+ if (this.#spent.has(refreshToken)) this.reused = true;
36
+ if (!this.#refreshTokens.delete(refreshToken)) return Response.json({ error: "invalid" }, { status: 401 });
37
+ this.#spent.add(refreshToken);
38
+ this.#issued += 1;
39
+ const next = { jwt: `jwt-${this.#issued}`, refreshToken: `refresh-${this.#issued}` };
40
+ this.#refreshTokens.add(next.refreshToken);
41
+ this.#jwts.add(next.jwt);
42
+ if (this.hang) await new Promise(() => undefined);
43
+ await Bun.sleep(this.delayMs);
44
+ return Response.json({ ...next, expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() });
45
+ }
46
+ }
47
+
48
+ const makeHome = tempDirs("akan-cloud-home-");
49
+ const cloudIndex = path.resolve(import.meta.dir, "index.ts");
50
+ let cloud: FakeCloud;
51
+
52
+ afterEach(() => cloud.server.stop(true));
53
+
54
+ // A separate `bun` per CLI process, with HOME pointing at a temp dir: the config path is fixed when the module loads.
55
+ const cliRun = async (home: string, { callers = 1 } = {}) => {
56
+ const script = path.join(home, `cli-${crypto.randomUUID()}.ts`);
57
+ await writeText(
58
+ script,
59
+ `import { CloudApi, configPath } from ${JSON.stringify(cloudIndex)};
60
+ if (!configPath.startsWith(${JSON.stringify(home)})) throw new Error("refusing to touch " + configPath);
61
+ const workspace = { workspaceRoot: ${JSON.stringify(home)} };
62
+ const selves = await Promise.all(
63
+ Array.from({ length: ${callers} }, async () => (await (await CloudApi.fromHost(workspace, ${JSON.stringify(cloud.host)})).getRemoteSelf())?.nickname ?? null),
64
+ );
65
+ process.stdout.write(JSON.stringify(selves));
66
+ `,
67
+ );
68
+ return Bun.spawn(["bun", script], { env: { ...process.env, HOME: home }, stdout: "pipe", stderr: "pipe" });
69
+ };
70
+ const output = async (proc: Bun.Subprocess<"ignore", "pipe", "pipe">) => {
71
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
72
+ expect(await proc.exited, stderr).toBe(0);
73
+ return JSON.parse(stdout) as (string | null)[];
74
+ };
75
+
76
+ const writerRun = async (home: string, waitTimeoutMs: number) => {
77
+ const script = path.join(home, `writer-${crypto.randomUUID()}.ts`);
78
+ await writeText(
79
+ script,
80
+ `import { GlobalConfig, configPath } from ${JSON.stringify(cloudIndex)};
81
+ import { ConfigLock } from ${JSON.stringify(path.resolve(import.meta.dir, "configLock.ts"))};
82
+ if (!configPath.startsWith(${JSON.stringify(home)})) throw new Error("refusing to touch " + configPath);
83
+ Object.defineProperty(ConfigLock, "waitTimeoutMs", { value: ${waitTimeoutMs} });
84
+ await GlobalConfig.setTestTargets({ linux: { cpus: 2 } });
85
+ `,
86
+ );
87
+ const proc = Bun.spawn(["bun", script], { env: { ...process.env, HOME: home }, stdout: "pipe", stderr: "pipe" });
88
+ return { code: await proc.exited, stderr: await new Response(proc.stderr).text() };
89
+ };
90
+
91
+ const storeSession = async (home: string, { expiresInMs = 30 * 60_000, refreshToken = "refresh-0" } = {}) =>
92
+ await writeJson(path.join(home, ".akan", "config.json"), {
93
+ cloudHost: {
94
+ [cloud.host]: {
95
+ host: cloud.host,
96
+ auth: {
97
+ accessToken: { jwt: "jwt-0", refreshToken, expiresAt: new Date(Date.now() + expiresInMs).toISOString() },
98
+ self: { id: "u1", nickname: "akan" },
99
+ },
100
+ },
101
+ },
102
+ });
103
+ const storedToken = async (home: string) => {
104
+ const config = JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8"));
105
+ return config.cloudHost[cloud.host].auth.accessToken as { jwt: string; refreshToken: string | null };
106
+ };
107
+
108
+ describe("CloudApi.fromHost token refresh", () => {
109
+ test("two CLI processes with a session in its last hour make exactly one refresh, and both use its result", async () => {
110
+ cloud = new FakeCloud();
111
+ cloud.delayMs = 300;
112
+ const home = await makeHome();
113
+ await storeSession(home);
114
+
115
+ const [first, second] = await Promise.all([
116
+ cliRun(home, { callers: 2 }).then(output),
117
+ cliRun(home, { callers: 2 }).then(output),
118
+ ]);
119
+
120
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
121
+ expect(cloud.reused).toBe(false);
122
+ expect([...first, ...second]).toEqual(["akan", "akan", "akan", "akan"]);
123
+ expect(await storedToken(home)).toMatchObject({ jwt: "jwt-1", refreshToken: "refresh-1" });
124
+ }, 30_000);
125
+
126
+ test("a CLI started while another is mid-refresh waits for that refresh and uses its session", async () => {
127
+ cloud = new FakeCloud();
128
+ cloud.delayMs = 1_500;
129
+ const home = await makeHome();
130
+ await storeSession(home);
131
+
132
+ const first = cliRun(home).then(output);
133
+ for (let tries = 0; tries < 300 && (await storedToken(home)).refreshToken !== null; tries++) await Bun.sleep(10);
134
+ expect((await storedToken(home)).refreshToken).toBeNull();
135
+ const second = await cliRun(home).then(output);
136
+
137
+ expect(await first).toEqual(["akan"]);
138
+ expect(second).toEqual(["akan"]);
139
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
140
+ }, 30_000);
141
+
142
+ test("a CLI killed while its refresh is in flight never lets the next one present that refresh token", async () => {
143
+ cloud = new FakeCloud();
144
+ cloud.hang = true;
145
+ const home = await makeHome();
146
+ await storeSession(home);
147
+
148
+ const killed = await cliRun(home);
149
+ for (let waited = 0; !cloud.refreshes.length && waited < 10_000; waited += 25) await Bun.sleep(25);
150
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
151
+ killed.kill("SIGKILL");
152
+ await killed.exited;
153
+ cloud.hang = false;
154
+
155
+ const started = Date.now();
156
+ expect(await output(await cliRun(home))).toEqual([null]);
157
+ expect(Date.now() - started).toBeLessThan(10_000);
158
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
159
+ expect(cloud.reused).toBe(false);
160
+ expect((await storedToken(home)).refreshToken).toBeNull();
161
+ }, 30_000);
162
+
163
+ test("a refresh the cloud refuses is not presented again", async () => {
164
+ cloud = new FakeCloud();
165
+ const home = await makeHome();
166
+ await storeSession(home, { refreshToken: "refresh-unknown" });
167
+
168
+ expect(await output(await cliRun(home))).toEqual([null]);
169
+ expect(await output(await cliRun(home))).toEqual([null]);
170
+ expect(cloud.refreshes).toEqual(["refresh-unknown"]);
171
+ expect((await storedToken(home)).refreshToken).toBeNull();
172
+ }, 30_000);
173
+
174
+ test("any other config write waits for the lock instead of writing around it", async () => {
175
+ cloud = new FakeCloud();
176
+ const home = await makeHome();
177
+ await storeSession(home);
178
+ const lockPath = path.join(home, ".akan", "config.json.lock");
179
+ await writeJson(lockPath, { pid: process.pid, at: Date.now() });
180
+
181
+ const blocked = await writerRun(home, 300);
182
+ expect(blocked.code).not.toBe(0);
183
+ expect(blocked.stderr).toContain(lockPath);
184
+ expect(JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8")).testTargets).toBeUndefined();
185
+
186
+ await rm(lockPath);
187
+ expect((await writerRun(home, 300)).code).toBe(0);
188
+ const config = JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8"));
189
+ expect(config.testTargets).toEqual({ linux: { cpus: 2 } });
190
+ expect(config.cloudHost[cloud.host].auth.accessToken.refreshToken).toBe("refresh-0");
191
+ }, 30_000);
192
+
193
+ test("a session with more than an hour left is used as it is", async () => {
194
+ cloud = new FakeCloud();
195
+ const home = await makeHome();
196
+ await storeSession(home, { expiresInMs: 2 * 86_400_000 });
197
+
198
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
199
+ expect(cloud.refreshes).toEqual([]);
200
+ expect(await storedToken(home)).toMatchObject({ jwt: "jwt-0", refreshToken: "refresh-0" });
201
+ }, 30_000);
202
+ });
package/cloud/cloudApi.ts CHANGED
@@ -2,6 +2,11 @@ import type { Workspace } from "../commandDecorators";
2
2
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
3
3
  import { GlobalConfig } from "./globalConfig";
4
4
 
5
+ interface HttpRequestOptions {
6
+ headers?: Record<string, string>;
7
+ signal?: AbortSignal;
8
+ }
9
+
5
10
  class HttpClient {
6
11
  readonly baseUrl: string;
7
12
  readonly headers: Record<string, string> = {};
@@ -9,15 +14,12 @@ class HttpClient {
9
14
  this.baseUrl = baseUrl;
10
15
  this.headers = headers;
11
16
  }
12
- async get<T>(url: string, { headers }: { headers?: Record<string, string> } = {}): Promise<T> {
17
+ async get<T>(url: string, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
13
18
  const response = await fetch(`${this.baseUrl}${url}`, {
14
- headers: {
15
- "Content-Type": "application/json",
16
- ...this.headers,
17
- ...headers,
18
- },
19
+ headers: { "Content-Type": "application/json", ...this.headers, ...headers },
20
+ ...(signal ? { signal } : {}),
19
21
  });
20
- return await response.json();
22
+ return await HttpClient.#body<T>(response, url);
21
23
  }
22
24
  async getFile(url: string, localPath: string, headers?: Record<string, string>): Promise<void> {
23
25
  const response = await fetch(`${this.baseUrl}${url}`, {
@@ -26,7 +28,7 @@ class HttpClient {
26
28
  if (!response.ok) throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
27
29
  await Bun.write(localPath, response);
28
30
  }
29
- async post<T>(url: string, data: unknown, { headers }: { headers?: Record<string, string> } = {}): Promise<T> {
31
+ async post<T>(url: string, data: unknown, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
30
32
  const isFormData = data instanceof FormData;
31
33
  const response = await fetch(`${this.baseUrl}${url}`, {
32
34
  method: "POST",
@@ -34,8 +36,15 @@ class HttpClient {
34
36
  headers: isFormData
35
37
  ? { ...this.headers, ...headers }
36
38
  : { "Content-Type": "application/json", ...this.headers, ...headers },
39
+ ...(signal ? { signal } : {}),
37
40
  });
38
- return await response.json();
41
+ return await HttpClient.#body<T>(response, url);
42
+ }
43
+ // A missing route answers an HTML error page, so the status is read before the body is parsed as JSON.
44
+ static async #body<T>(response: Response, url: string): Promise<T> {
45
+ const text = await response.text();
46
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText} from ${url}: ${text.slice(0, 200)}`);
47
+ return JSON.parse(text) as T;
39
48
  }
40
49
  setHeaders(headers: Record<string, string>) {
41
50
  Object.assign(this.headers, headers);
@@ -43,6 +52,27 @@ class HttpClient {
43
52
  }
44
53
  }
45
54
 
55
+ export interface TunnelGrant {
56
+ code: string;
57
+ hostname: string;
58
+ url: string;
59
+ /** Named by the control plane (e.g. `wss://tunnel.akanjs.com`); the CLI never assumes one. */
60
+ gatewayUrl: string;
61
+ /** Whoever holds it becomes the origin behind `hostname`: never written to disk or logged. */
62
+ token: string;
63
+ expiresAt: string | null;
64
+ }
65
+
66
+ export interface TunnelSummary {
67
+ code: string;
68
+ hostname: string;
69
+ url: string;
70
+ name: string | null;
71
+ createdAt: string;
72
+ expiresAt: string | null;
73
+ connected: boolean;
74
+ }
75
+
46
76
  export class CloudApi {
47
77
  readonly #api: HttpClient;
48
78
  #accessToken: AccessToken | null = null;
@@ -52,7 +82,14 @@ export class CloudApi {
52
82
 
53
83
  static async fromHost(workspace: Workspace, host?: string) {
54
84
  const hostConfig = await GlobalConfig.getHostConfig(host);
55
- return new CloudApi(workspace, hostConfig);
85
+ const accessToken = hostConfig.auth?.accessToken;
86
+ // No refresh token on a session in its last hour can be another process's refresh in flight: wait on its lock.
87
+ if (!accessToken || !GlobalConfig.needRefreshToken(accessToken)) return new CloudApi(workspace, hostConfig);
88
+ const tokenless = new CloudApi(workspace, hostConfig);
89
+ const refreshed = await GlobalConfig.refreshHostAuth(hostConfig.host, (refreshToken) =>
90
+ tokenless.refreshAuthToken(refreshToken),
91
+ );
92
+ return new CloudApi(workspace, refreshed);
56
93
  }
57
94
  constructor(workspace: Workspace, hostConfig: HostConfig) {
58
95
  this.#workspace = workspace;
@@ -61,17 +98,20 @@ export class CloudApi {
61
98
  this.url = `${this.host}/api`;
62
99
  this.#api = new HttpClient(this.url);
63
100
  if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
64
- this.#api.setHeaders({
65
- Authorization: `Bearer ${this.#accessToken.jwt}`,
66
- });
101
+ this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
102
+ }
103
+
104
+ #authorize(accessTokenDto: AccessTokenDto) {
105
+ this.#accessToken = GlobalConfig.toAccessToken(accessTokenDto);
106
+ this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
107
+ return this.#accessToken;
67
108
  }
68
109
 
69
110
  async uploadEnv(devProjectId: string, file: File): Promise<boolean> {
70
111
  const formData = new FormData();
71
112
  formData.append("devProjectId", devProjectId);
72
113
  formData.append("file", file);
73
- const data = await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
74
- return data;
114
+ return await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
75
115
  }
76
116
  async downloadEnv(devProjectId: string): Promise<unknown> {
77
117
  const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
@@ -80,38 +120,36 @@ export class CloudApi {
80
120
  }
81
121
  async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
82
122
  try {
83
- const accessToken = await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`);
84
- this.#accessToken = GlobalConfig.toAccessToken(accessToken);
85
- this.#api.setHeaders({
86
- Authorization: `Bearer ${this.#accessToken.jwt}`,
87
- });
88
- return this.#accessToken;
123
+ return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
89
124
  } catch (_) {
90
125
  return null;
91
126
  }
92
127
  }
93
- async #ensureAccessTokenLive({
94
- allowUnauthorized = false,
95
- }: {
96
- allowUnauthorized?: boolean;
97
- } = {}): Promise<AccessToken> {
98
- if (!this.#accessToken) throw new Error("No access token");
99
- const needRefresh = GlobalConfig.needRefreshToken(this.#accessToken);
100
- if (!needRefresh) return this.#accessToken;
101
- const refreshToken = this.#accessToken?.refreshToken;
102
- if (!refreshToken) throw new Error("No refresh token");
103
- return await this.refreshAuthToken(refreshToken);
104
- }
105
128
  async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
106
- const response = await this.#api.post<AccessTokenDto>(`/refreshAuthToken`, { refreshToken });
107
- this.#accessToken = GlobalConfig.toAccessToken(response);
108
- this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
109
- return this.#accessToken;
129
+ return this.#authorize(
130
+ await this.#api.post<AccessTokenDto>(
131
+ `/refreshAuthToken`,
132
+ { refreshToken },
133
+ { signal: AbortSignal.timeout(20_000) },
134
+ ),
135
+ );
136
+ }
137
+ // `/tunnel` is the model's refName, which the `_cloud` service routes above lack; dropping it answers 404.
138
+ async requestTunnel(input: { name: string; ttlMinutes?: number }): Promise<TunnelGrant> {
139
+ return await this.#api.post<TunnelGrant>(`/tunnel/requestTunnel`, input);
140
+ }
141
+ /** The generated name of the model's own `inSelf` slice: a hand-written `listTunnels` would collide with it. */
142
+ async tunnelListInSelf(): Promise<TunnelSummary[]> {
143
+ return await this.#api.get<TunnelSummary[]>(`/tunnel/tunnelListInSelf`);
144
+ }
145
+ // Bounded: callers are on the way out and an unreachable control plane would hold the event loop open; a lost
146
+ // release costs nothing, since the share expires on its own TTL.
147
+ async revokeTunnel(code: string): Promise<boolean> {
148
+ return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
110
149
  }
111
150
  async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
112
151
  try {
113
- const data = await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
114
- return data;
152
+ return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
115
153
  } catch {
116
154
  return null;
117
155
  }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
5
+ import { ConfigLock } from "./configLock";
6
+
7
+ const makeRoot = tempDirs("akan-config-lock-");
8
+ /** A pid that cannot be alive: `kill(0)` on it is ESRCH on every platform this runs on. */
9
+ const deadPid = 0x7ffffff;
10
+ const lockIn = async () => path.join(await makeRoot(), ".akan", "config.json.lock");
11
+
12
+ const withWaitTimeout = async <T>(ms: number, fn: () => Promise<T>) => {
13
+ const waitTimeoutMs = ConfigLock.waitTimeoutMs;
14
+ Object.defineProperty(ConfigLock, "waitTimeoutMs", { value: ms, configurable: true });
15
+ try {
16
+ return await fn();
17
+ } finally {
18
+ Object.defineProperty(ConfigLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
19
+ }
20
+ };
21
+
22
+ describe("ConfigLock", () => {
23
+ test("runs one caller at a time, in the same process too", async () => {
24
+ const lockPath = await lockIn();
25
+ const order: string[] = [];
26
+ const body = async (name: string) => {
27
+ order.push(`${name}:in`);
28
+ await Bun.sleep(30);
29
+ order.push(`${name}:out`);
30
+ };
31
+ await Promise.all(["a", "b", "c"].map((name) => ConfigLock.run(lockPath, () => body(name))));
32
+ for (const name of ["a", "b", "c"]) expect(order.indexOf(`${name}:out`)).toBe(order.indexOf(`${name}:in`) + 1);
33
+ });
34
+
35
+ test("releases the lock when the body throws", async () => {
36
+ const lockPath = await lockIn();
37
+ await expect(ConfigLock.run(lockPath, async () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
38
+ expect(await Bun.file(lockPath).exists()).toBe(false);
39
+ });
40
+
41
+ test("reclaims a lock whose holder died", async () => {
42
+ const lockPath = await lockIn();
43
+ await ConfigLock.run(lockPath, async () => undefined);
44
+ await writeFile(lockPath, JSON.stringify({ pid: deadPid, at: Date.now() }));
45
+ const holder = await ConfigLock.run(lockPath, async () => JSON.parse(await readFile(lockPath, "utf8")));
46
+ expect(holder.pid).toBe(process.pid);
47
+ });
48
+
49
+ test("waits out a live holder and then fails, rather than going ahead without the lock", async () => {
50
+ const lockPath = await lockIn();
51
+ await ConfigLock.run(lockPath, async () => undefined);
52
+ await writeFile(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
53
+ let ran = false;
54
+ await withWaitTimeout(150, async () => {
55
+ await expect(
56
+ ConfigLock.run(lockPath, async () => {
57
+ ran = true;
58
+ }),
59
+ ).rejects.toThrow(lockPath);
60
+ });
61
+ expect(ran).toBe(false);
62
+ expect(JSON.parse(await readFile(lockPath, "utf8")).pid).toBe(process.pid);
63
+ });
64
+ });
@@ -0,0 +1,96 @@
1
+ import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ interface LockHolder {
5
+ pid: number;
6
+ at: number;
7
+ }
8
+
9
+ // Across processes, not only calls: two `akan` commands started together share one `~/.akan/config.json`. A live
10
+ // holder is never reclaimed, so a wait that runs out fails rather than proceeding without the lock.
11
+ export class ConfigLock {
12
+ static readonly waitTimeoutMs = 30_000;
13
+ // A holder-less lock file younger than this is someone between the exclusive create and the write.
14
+ static readonly #unknownHolderStaleMs = 10_000;
15
+ static readonly #pollMs = 50;
16
+
17
+ static async run<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
18
+ await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
19
+ const deadline = Date.now() + ConfigLock.waitTimeoutMs;
20
+ while (!(await ConfigLock.#take(lockPath))) {
21
+ if (Date.now() >= deadline) {
22
+ const holder = (await ConfigLock.#holder(lockPath))?.pid ?? "unknown";
23
+ throw new Error(
24
+ `${lockPath} is held by process ${holder}; if no akan command is running, delete it and retry.`,
25
+ );
26
+ }
27
+ await ConfigLock.#reclaimIfAbandoned(lockPath);
28
+ await Bun.sleep(ConfigLock.#pollMs);
29
+ }
30
+ try {
31
+ return await fn();
32
+ } finally {
33
+ await rm(lockPath, { force: true });
34
+ }
35
+ }
36
+
37
+ static async #take(lockPath: string) {
38
+ const handle = await open(lockPath, "wx", 0o600).catch((error: NodeJS.ErrnoException) => {
39
+ if (error.code === "EEXIST") return null;
40
+ throw error;
41
+ });
42
+ if (!handle) return false;
43
+ try {
44
+ await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() } satisfies LockHolder));
45
+ } finally {
46
+ await handle.close();
47
+ }
48
+ return true;
49
+ }
50
+
51
+ // Exclusive too: a waiter acting on its own stale read could delete the lock another waiter took a moment later.
52
+ static async #reclaimIfAbandoned(lockPath: string) {
53
+ if (!(await ConfigLock.#abandoned(lockPath))) return;
54
+ const guard = `${lockPath}.reclaim`;
55
+ if (!(await ConfigLock.#take(guard))) {
56
+ if (await ConfigLock.#abandoned(guard)) await rm(guard, { force: true });
57
+ return;
58
+ }
59
+ try {
60
+ if (await ConfigLock.#abandoned(lockPath)) await rm(lockPath, { force: true });
61
+ } finally {
62
+ await rm(guard, { force: true });
63
+ }
64
+ }
65
+
66
+ static async #abandoned(lockPath: string) {
67
+ const info = await stat(lockPath).catch(() => null);
68
+ if (!info) return false;
69
+ const holder = await ConfigLock.#holder(lockPath);
70
+ if (!holder) return Date.now() - info.mtimeMs > ConfigLock.#unknownHolderStaleMs;
71
+ return !ConfigLock.#isAlive(holder.pid);
72
+ }
73
+
74
+ static async #holder(lockPath: string): Promise<LockHolder | null> {
75
+ try {
76
+ const parsed = JSON.parse(await readFile(lockPath, "utf8")) as Partial<LockHolder>;
77
+ return typeof parsed.pid === "number" && typeof parsed.at === "number"
78
+ ? { pid: parsed.pid, at: parsed.at }
79
+ : null;
80
+ } catch {
81
+ // Missing, or caught between the create and the write; either way it names no holder.
82
+ return null;
83
+ }
84
+ }
85
+
86
+ static #isAlive(pid: number) {
87
+ if (!Number.isInteger(pid) || pid <= 0) return false;
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ } catch (error) {
92
+ // EPERM is a pid that exists under another user, which still counts as holding the lock.
93
+ return (error as NodeJS.ErrnoException).code === "EPERM";
94
+ }
95
+ }
96
+ }
@@ -1,9 +1,9 @@
1
+ import path from "node:path";
1
2
  import type { Dayjs } from "dayjs";
2
- import type { SupportedLlmModel } from "../aiEditor";
3
3
  import { GlobalConfig } from "./globalConfig";
4
4
 
5
- export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
6
- export const configPath = `${basePath}/config.json`;
5
+ export const basePath = path.join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".akan");
6
+ export const configPath = path.join(basePath, "config.json");
7
7
 
8
8
  export interface HostConfig {
9
9
  host: string;
@@ -25,15 +25,36 @@ export interface RemoteEnvServerConfig {
25
25
  username?: string;
26
26
  port?: number;
27
27
  }
28
+ export type PlatformGatePolicy = "gate" | "warn";
29
+ export interface LinuxTestTargetConfig {
30
+ cpus?: number;
31
+ memory?: string;
32
+ policy?: PlatformGatePolicy;
33
+ }
34
+ export interface WindowsTestTargetConfig {
35
+ host: string;
36
+ user: string;
37
+ identityFile: string;
38
+ knownHostsFile?: string;
39
+ utmVm?: string;
40
+ workRoot?: string;
41
+ tolerateScriptFailures?: string[];
42
+ policy?: PlatformGatePolicy;
43
+ greenStreak?: number;
44
+ }
45
+ export interface TestTargetsConfig {
46
+ linux?: LinuxTestTargetConfig;
47
+ windows?: WindowsTestTargetConfig;
48
+ }
28
49
  export interface AkanGlobalConfig {
29
50
  cloudHost: { [key: string]: HostConfigDto };
30
51
  remoteEnvServers: Record<string, RemoteEnvServerConfig>;
31
- llm: { model: SupportedLlmModel; apiKey: string } | null;
52
+ testTargets: TestTargetsConfig;
32
53
  }
33
54
  export const defaultAkanGlobalConfig: AkanGlobalConfig = {
34
55
  cloudHost: {},
35
56
  remoteEnvServers: {},
36
- llm: null,
57
+ testTargets: {},
37
58
  };
38
59
 
39
60
  export interface AccessTokenDto {