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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
@@ -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
@@ -16,11 +16,7 @@ class HttpClient {
16
16
  }
17
17
  async get<T>(url: string, { headers, signal }: HttpRequestOptions = {}): Promise<T> {
18
18
  const response = await fetch(`${this.baseUrl}${url}`, {
19
- headers: {
20
- "Content-Type": "application/json",
21
- ...this.headers,
22
- ...headers,
23
- },
19
+ headers: { "Content-Type": "application/json", ...this.headers, ...headers },
24
20
  ...(signal ? { signal } : {}),
25
21
  });
26
22
  return await HttpClient.#body<T>(response, url);
@@ -44,10 +40,7 @@ class HttpClient {
44
40
  });
45
41
  return await HttpClient.#body<T>(response, url);
46
42
  }
47
- /**
48
- * A route that does not exist answers the error *page*, not JSON, so parsing first reports a syntax error at
49
- * `<` and buries the status that says what is actually wrong. Read the status before the body.
50
- */
43
+ // A missing route answers an HTML error page, so the status is read before the body is parsed as JSON.
51
44
  static async #body<T>(response: Response, url: string): Promise<T> {
52
45
  const text = await response.text();
53
46
  if (!response.ok) throw new Error(`${response.status} ${response.statusText} from ${url}: ${text.slice(0, 200)}`);
@@ -59,16 +52,13 @@ class HttpClient {
59
52
  }
60
53
  }
61
54
 
62
- /**
63
- * A share the control plane just issued. `token` is the connector credential — whoever holds it can become the
64
- * origin behind `hostname`, so it is never written to disk or logged, only handed to the agent in memory.
65
- */
66
55
  export interface TunnelGrant {
67
56
  code: string;
68
57
  hostname: string;
69
58
  url: string;
70
- /** The gateway to dial, as `wss://tunnel.akanjs.com`. The control plane names it; the CLI never assumes one. */
59
+ /** Named by the control plane (e.g. `wss://tunnel.akanjs.com`); the CLI never assumes one. */
71
60
  gatewayUrl: string;
61
+ /** Whoever holds it becomes the origin behind `hostname`: never written to disk or logged. */
72
62
  token: string;
73
63
  expiresAt: string | null;
74
64
  }
@@ -92,7 +82,14 @@ export class CloudApi {
92
82
 
93
83
  static async fromHost(workspace: Workspace, host?: string) {
94
84
  const hostConfig = await GlobalConfig.getHostConfig(host);
95
- 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);
96
93
  }
97
94
  constructor(workspace: Workspace, hostConfig: HostConfig) {
98
95
  this.#workspace = workspace;
@@ -101,17 +98,20 @@ export class CloudApi {
101
98
  this.url = `${this.host}/api`;
102
99
  this.#api = new HttpClient(this.url);
103
100
  if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
104
- this.#api.setHeaders({
105
- Authorization: `Bearer ${this.#accessToken.jwt}`,
106
- });
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;
107
108
  }
108
109
 
109
110
  async uploadEnv(devProjectId: string, file: File): Promise<boolean> {
110
111
  const formData = new FormData();
111
112
  formData.append("devProjectId", devProjectId);
112
113
  formData.append("file", file);
113
- const data = await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
114
- return data;
114
+ return await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
115
115
  }
116
116
  async downloadEnv(devProjectId: string): Promise<unknown> {
117
117
  const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
@@ -120,39 +120,21 @@ export class CloudApi {
120
120
  }
121
121
  async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
122
122
  try {
123
- const accessToken = await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`);
124
- this.#accessToken = GlobalConfig.toAccessToken(accessToken);
125
- this.#api.setHeaders({
126
- Authorization: `Bearer ${this.#accessToken.jwt}`,
127
- });
128
- return this.#accessToken;
123
+ return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
129
124
  } catch (_) {
130
125
  return null;
131
126
  }
132
127
  }
133
- async #ensureAccessTokenLive({
134
- allowUnauthorized = false,
135
- }: {
136
- allowUnauthorized?: boolean;
137
- } = {}): Promise<AccessToken> {
138
- if (!this.#accessToken) throw new Error("No access token");
139
- const needRefresh = GlobalConfig.needRefreshToken(this.#accessToken);
140
- if (!needRefresh) return this.#accessToken;
141
- const refreshToken = this.#accessToken?.refreshToken;
142
- if (!refreshToken) throw new Error("No refresh token");
143
- return await this.refreshAuthToken(refreshToken);
144
- }
145
128
  async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
146
- const response = await this.#api.post<AccessTokenDto>(`/refreshAuthToken`, { refreshToken });
147
- this.#accessToken = GlobalConfig.toAccessToken(response);
148
- this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
149
- return this.#accessToken;
150
- }
151
- /**
152
- * The `/tunnel` segment is the model's refName, which every database module's endpoints carry and the service
153
- * modules above — `_cloud`, which owns `uploadEnv` and `getRemoteSelf` — do not. Dropping it to match those
154
- * neighbours answers 404, and the control plane returns an HTML error page that fails as a JSON parse error.
155
- */
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.
156
138
  async requestTunnel(input: { name: string; ttlMinutes?: number }): Promise<TunnelGrant> {
157
139
  return await this.#api.post<TunnelGrant>(`/tunnel/requestTunnel`, input);
158
140
  }
@@ -160,20 +142,14 @@ export class CloudApi {
160
142
  async tunnelListInSelf(): Promise<TunnelSummary[]> {
161
143
  return await this.#api.get<TunnelSummary[]>(`/tunnel/tunnelListInSelf`);
162
144
  }
163
- /**
164
- * Hands the grant back. The CLI calls this "stop", which is what the operator is doing, not what it does.
165
- *
166
- * Bounded because every caller is on the way out: an unreachable control plane is exactly the state a session
167
- * ends in, and an unbounded `fetch` there holds the event loop open long after the work is done. Losing the
168
- * release costs nothing — the share expires on its own TTL.
169
- */
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.
170
147
  async revokeTunnel(code: string): Promise<boolean> {
171
148
  return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
172
149
  }
173
150
  async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
174
151
  try {
175
- const data = await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
176
- return data;
152
+ return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
177
153
  } catch {
178
154
  return null;
179
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,8 +1,9 @@
1
+ import path from "node:path";
1
2
  import type { Dayjs } from "dayjs";
2
3
  import { GlobalConfig } from "./globalConfig";
3
4
 
4
- export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
5
- 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");
6
7
 
7
8
  export interface HostConfig {
8
9
  host: string;
@@ -1,6 +1,7 @@
1
- import { chmod, mkdir } from "node:fs/promises";
1
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
2
2
  import dayjs from "dayjs";
3
3
  import { FileSys } from "../fileSys";
4
+ import { ConfigLock } from "./configLock";
4
5
  import {
5
6
  type AccessToken,
6
7
  type AccessTokenDto,
@@ -31,58 +32,90 @@ export class GlobalConfig {
31
32
  testTargets: akanConfig.testTargets ?? defaultAkanGlobalConfig.testTargets,
32
33
  };
33
34
  }
34
- /**
35
- * This file holds the cloud jwt and a refresh token that does not expire, so it is
36
- * written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
37
- * and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
38
- * existing world-readable file is tightened by the next write rather than left as it was found.
39
- */
35
+ // Holds the cloud jwt and refresh token, so owner-only 0600: written to a 0600 sibling and renamed over the file,
36
+ // which also replaces a world-readable one and never shows another CLI process a half-written file.
40
37
  static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
41
38
  await mkdir(basePath, { recursive: true, mode: 0o700 });
42
- await Bun.write(configPath, JSON.stringify(akanConfig, null, 2));
43
- await chmod(configPath, 0o600);
39
+ const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
40
+ try {
41
+ await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
42
+ await rename(temp, configPath);
43
+ } catch (error) {
44
+ await rm(temp, { force: true });
45
+ throw error;
46
+ }
47
+ }
48
+ static readonly #lockPath = `${configPath}.lock`;
49
+ // Every write re-reads under the lock: one built on an earlier read could restore a rotated-away refresh token.
50
+ static async #update(mutate: (akanConfig: AkanGlobalConfig) => AkanGlobalConfig) {
51
+ await ConfigLock.run(GlobalConfig.#lockPath, async () => {
52
+ await GlobalConfig.#setAkanGlobalConfig(mutate(await GlobalConfig.#getAkanGlobalConfig()));
53
+ });
44
54
  }
45
55
  static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
46
56
  const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
47
57
  return GlobalConfig.toHostConfig(akanConfig.cloudHost[host] ?? getDefaultHostConfig(host));
48
58
  }
49
59
  static async setHostConfig(config: HostConfig = getDefaultHostConfig()) {
50
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
51
- akanConfig.cloudHost[config.host] = GlobalConfig.toHostConfigDto(config);
52
- await GlobalConfig.#setAkanGlobalConfig(akanConfig);
60
+ await GlobalConfig.#update((akanConfig) => GlobalConfig.#withHost(akanConfig, config));
61
+ }
62
+ static #withHost(akanConfig: AkanGlobalConfig, config: HostConfig): AkanGlobalConfig {
63
+ return {
64
+ ...akanConfig,
65
+ cloudHost: { ...akanConfig.cloudHost, [config.host]: GlobalConfig.toHostConfigDto(config) },
66
+ };
67
+ }
68
+ // The cloud rotates the refresh token and revokes every session of the account when one is presented twice, so it
69
+ // leaves the disk before it is sent: a refresh killed in flight leaves nothing to replay, only a sign-in to redo.
70
+ static async refreshHostAuth(host: string, refresh: (refreshToken: string) => Promise<AccessToken>) {
71
+ try {
72
+ return await ConfigLock.run(GlobalConfig.#lockPath, async () => {
73
+ const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
74
+ const current = GlobalConfig.toHostConfig(akanConfig.cloudHost[host] ?? getDefaultHostConfig(host));
75
+ const accessToken = current.auth?.accessToken;
76
+ const refreshToken = accessToken?.refreshToken;
77
+ if (!accessToken || !refreshToken || !GlobalConfig.needRefreshToken(accessToken)) return current;
78
+ const claimed: HostConfig = {
79
+ ...current,
80
+ auth: { ...current.auth, accessToken: { ...accessToken, refreshToken: null } },
81
+ };
82
+ await GlobalConfig.#setAkanGlobalConfig(GlobalConfig.#withHost(akanConfig, claimed));
83
+ const next = await refresh(refreshToken).catch(() => null);
84
+ if (!next?.jwt) return claimed;
85
+ const refreshed: HostConfig = { ...current, auth: { ...current.auth, accessToken: next } };
86
+ await GlobalConfig.#setAkanGlobalConfig(
87
+ GlobalConfig.#withHost(await GlobalConfig.#getAkanGlobalConfig(), refreshed),
88
+ );
89
+ return refreshed;
90
+ });
91
+ } catch {
92
+ // No lock, or a write that failed: what is stored is used as it is, and nothing is refreshed outside the lock.
93
+ return await GlobalConfig.getHostConfig(host);
94
+ }
53
95
  }
54
96
  static async getRemoteEnvServers(): Promise<AkanGlobalConfig["remoteEnvServers"]> {
55
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
56
- return akanConfig.remoteEnvServers;
97
+ return (await GlobalConfig.#getAkanGlobalConfig()).remoteEnvServers;
57
98
  }
58
99
  static async setRemoteEnvServer(name: string, config: RemoteEnvServerConfig) {
59
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
60
- await GlobalConfig.#setAkanGlobalConfig({
100
+ await GlobalConfig.#update((akanConfig) => ({
61
101
  ...akanConfig,
62
- remoteEnvServers: {
63
- ...akanConfig.remoteEnvServers,
64
- [name]: config,
65
- },
66
- });
102
+ remoteEnvServers: { ...akanConfig.remoteEnvServers, [name]: config },
103
+ }));
67
104
  }
68
105
  static async removeRemoteEnvServer(name: string) {
69
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
70
- const { [name]: _, ...remoteEnvServers } = akanConfig.remoteEnvServers;
71
- await GlobalConfig.#setAkanGlobalConfig({
72
- ...akanConfig,
73
- remoteEnvServers,
106
+ await GlobalConfig.#update((akanConfig) => {
107
+ const { [name]: _, ...remoteEnvServers } = akanConfig.remoteEnvServers;
108
+ return { ...akanConfig, remoteEnvServers };
74
109
  });
75
110
  }
76
111
  static async getTestTargets(): Promise<TestTargetsConfig> {
77
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
78
- return akanConfig.testTargets;
112
+ return (await GlobalConfig.#getAkanGlobalConfig()).testTargets;
79
113
  }
80
114
  static async setTestTargets(testTargets: TestTargetsConfig) {
81
- const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
82
- await GlobalConfig.#setAkanGlobalConfig({
115
+ await GlobalConfig.#update((akanConfig) => ({
83
116
  ...akanConfig,
84
117
  testTargets: { ...akanConfig.testTargets, ...testTargets },
85
- });
118
+ }));
86
119
  }
87
120
  static needRefreshToken(accessToken: AccessToken): boolean {
88
121
  return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));