@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
@@ -4,30 +4,31 @@ import { mkdtempSync, rmSync, statSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import type { CodeAgentMcpServerRef } from "akanjs/common";
7
+ import { ConfigLock } from "../../cloud/configLock";
7
8
  import { McpOAuth } from "./McpOAuth";
8
9
  import { McpSignIn } from "./McpSignIn";
9
10
  import { McpTokenStore } from "./McpTokenStore";
10
11
  import { McpToolPack } from "./McpToolPack";
11
12
 
12
- /**
13
- * One process playing the whole other side: an MCP resource server, its RFC 9728 metadata, the authorization
14
- * server named by it, dynamic client registration, and the token endpoint.
15
- *
16
- * Built as a real server rather than as stubbed fetches because what is being tested is a wire contract — the
17
- * challenge header, the well-known paths, the PKCE round trip — and a stub can only ever assert the shape this
18
- * code already believes in.
19
- */
13
+ // A real server, not stubbed fetches: the wire contract (challenge header, well-known paths, PKCE) is under test.
20
14
  class FakeProvider {
21
15
  readonly server: ReturnType<typeof Bun.serve>;
22
16
  readonly issued: string[] = [];
23
17
  readonly registrations: { redirect_uris?: string[] }[] = [];
24
- /** code -> the challenge it was issued against, which the token endpoint checks the verifier against. */
18
+ readonly sessions: string[] = [];
19
+ readonly calls: { token: string; session: string | null }[] = [];
25
20
  readonly #codes = new Map<string, { challenge: string; resource: string | null }>();
26
21
  #next = 0;
27
- /** Set to refuse S256, which a client must treat as "do not send a code" rather than downgrade. */
28
22
  methods: string[] = ["S256"];
29
23
  registerable = true;
30
24
  refreshable = true;
25
+ refreshExpiresIn: number | undefined = 3600;
26
+ refusing = false;
27
+ refreshes = 0;
28
+ refreshDelayMs = 0;
29
+ rotating: boolean = false;
30
+ reused = false;
31
+ readonly #spent = new Set<string>();
31
32
 
32
33
  constructor() {
33
34
  this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
@@ -66,7 +67,7 @@ class FakeProvider {
66
67
 
67
68
  async #mcp(request: Request) {
68
69
  const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
69
- if (!token || !this.issued.includes(token))
70
+ if (!token || !this.issued.includes(token) || this.refusing)
70
71
  return Response.json(
71
72
  { error_description: "Authentication is required to use this MCP server." },
72
73
  {
@@ -77,8 +78,14 @@ class FakeProvider {
77
78
  },
78
79
  );
79
80
  const body = (await request.json()) as { id?: number; method?: string };
80
- if (body.method === "initialize")
81
- return Response.json({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18" } });
81
+ if (body.method === "initialize") {
82
+ const session = `session-${this.sessions.length + 1}`;
83
+ this.sessions.push(session);
84
+ return Response.json(
85
+ { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18" } },
86
+ { headers: { "mcp-session-id": session } },
87
+ );
88
+ }
82
89
  if (body.method === "tools/list")
83
90
  return Response.json({
84
91
  jsonrpc: "2.0",
@@ -87,6 +94,10 @@ class FakeProvider {
87
94
  tools: [{ name: "search", description: "search it", inputSchema: { type: "object", properties: {} } }],
88
95
  },
89
96
  });
97
+ if (body.method === "tools/call") {
98
+ this.calls.push({ token, session: request.headers.get("mcp-session-id") });
99
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "found" }] } });
100
+ }
90
101
  return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
91
102
  }
92
103
 
@@ -97,7 +108,6 @@ class FakeProvider {
97
108
  return Response.json({ client_id: `client-${this.#next}`, redirect_uris: body.redirect_uris });
98
109
  }
99
110
 
100
- /** Stands in for the consent screen: it redirects straight back, which is what a browser would end up doing. */
101
111
  #authorize(url: URL) {
102
112
  const redirect = url.searchParams.get("redirect_uri") ?? "";
103
113
  const challenge = url.searchParams.get("code_challenge") ?? "";
@@ -113,10 +123,17 @@ class FakeProvider {
113
123
  async #token(request: Request) {
114
124
  const form = new URLSearchParams(await request.text());
115
125
  if (form.get("grant_type") === "refresh_token") {
116
- if (!this.refreshable) return Response.json({ error: "invalid_grant" }, { status: 400 });
126
+ this.refreshes += 1;
127
+ const presented = form.get("refresh_token") ?? "";
128
+ const reuse = this.rotating && this.#spent.has(presented);
129
+ if (this.rotating) this.#spent.add(presented);
130
+ await Bun.sleep(this.refreshDelayMs);
131
+ if (reuse) this.reused = true;
132
+ if (!this.refreshable || reuse) return Response.json({ error: "invalid_grant" }, { status: 400 });
117
133
  const access = `access-refreshed-${this.issued.length + 1}`;
118
134
  this.issued.push(access);
119
- return Response.json({ access_token: access, refresh_token: "refresh-2", expires_in: 3600 });
135
+ const refresh = this.rotating ? `refresh-for-${access}` : "refresh-2";
136
+ return Response.json({ access_token: access, refresh_token: refresh, expires_in: this.refreshExpiresIn });
120
137
  }
121
138
  const issued = this.#codes.get(form.get("code") ?? "");
122
139
  if (!issued) return Response.json({ error: "invalid_grant" }, { status: 400 });
@@ -140,16 +157,9 @@ const refOf = (patch: Partial<CodeAgentMcpServerRef> = {}): CodeAgentMcpServerRe
140
157
  ...patch,
141
158
  });
142
159
 
143
- /** A browser, reduced to what the flow needs of one: visit the url and follow where it points. */
144
160
  const browser = (url: string) => void fetch(url).catch(() => undefined);
145
161
 
146
- /**
147
- * The credential directory is moved off the real home for the whole file.
148
- *
149
- * Through `AKAN_CODE_HOME` rather than `HOME`: Bun resolves `os.homedir()` once and caches it, so `HOME`
150
- * written after anything in the import graph has read it changes nothing — and a test that quietly fell back
151
- * would write its tokens into the developer's own `~/.akan/code`.
152
- */
162
+ // AKAN_CODE_HOME, not HOME: Bun caches os.homedir(), so a late HOME write would land tokens in the real ~/.akan/code.
153
163
  beforeAll(() => {
154
164
  realHome = process.env.AKAN_CODE_HOME;
155
165
  home = mkdtempSync(path.join(tmpdir(), "akan-mcpauth-"));
@@ -206,7 +216,6 @@ describe("MCP OAuth discovery", () => {
206
216
  });
207
217
  });
208
218
 
209
- /** OAuth 2.1 drops `plain`; a downgrade here is one a network attacker would pick on the client's behalf. */
210
219
  test("a server that will not do S256 is refused rather than downgraded", async () => {
211
220
  provider.methods = ["plain"];
212
221
  await expect(McpOAuth.discover(provider.mcpUrl)).rejects.toThrow(/S256/);
@@ -228,7 +237,6 @@ describe("MCP sign-in", () => {
228
237
  expect(auth.accessToken).toBe("access-1");
229
238
  expect(auth.issuer).toBe(provider.origin);
230
239
  expect(auth.resource).toBe(provider.mcpUrl);
231
- // The client was registered on demand, against the loopback the flow actually bound.
232
240
  expect(provider.registrations).toHaveLength(1);
233
241
  expect(provider.registrations[0]?.redirect_uris?.[0]).toBe(auth.redirectUri);
234
242
  expect(McpTokenStore.read("fake")?.accessToken).toBe("access-1");
@@ -264,10 +272,18 @@ describe("MCP sign-in", () => {
264
272
  const auth = await McpSignIn.run(refOf(), { open: browser });
265
273
  McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
266
274
  expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
267
- // The rotated refresh token replaced the spent one, or the next refresh would be refused.
268
275
  expect(McpTokenStore.read("fake")?.refreshToken).toBe("refresh-2");
269
276
  });
270
277
 
278
+ test("a refreshed token the server gave no lifetime is kept until refused, not refreshed on every connect", async () => {
279
+ const auth = await McpSignIn.run(refOf(), { open: browser });
280
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
281
+ provider.refreshExpiresIn = undefined;
282
+ expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
283
+ expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
284
+ expect(McpTokenStore.read("fake")?.expiresAt).toBeUndefined();
285
+ });
286
+
271
287
  test("a refresh the server refuses reports no token rather than throwing", async () => {
272
288
  const auth = await McpSignIn.run(refOf(), { open: browser });
273
289
  McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
@@ -277,6 +293,189 @@ describe("MCP sign-in", () => {
277
293
  expect(notices.join(" ")).toContain("fake");
278
294
  });
279
295
 
296
+ const connect = async (notices: string[] = []) =>
297
+ await McpToolPack.connect({
298
+ workspaceRoot: home,
299
+ profile: { name: "t", tools: { mcp: [refOf()] } } as never,
300
+ onNotice: (message) => notices.push(message),
301
+ });
302
+
303
+ test("a token refused before its expiry is refreshed once, and the connect goes through with the new one", async () => {
304
+ const auth = await McpSignIn.run(refOf(), { open: browser });
305
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
306
+ const notices: string[] = [];
307
+ const pack = await connect(notices);
308
+ try {
309
+ expect(pack?.status[0]).toMatchObject({ auth: "authorized" });
310
+ expect(pack?.toolNames).toEqual(["mcp__fake__search"]);
311
+ expect(McpTokenStore.read("fake")).toMatchObject({
312
+ accessToken: "access-refreshed-1",
313
+ refreshToken: "refresh-2",
314
+ });
315
+ expect(notices).toEqual([]);
316
+ } finally {
317
+ pack?.close();
318
+ }
319
+ });
320
+
321
+ test("a refused token whose refresh fails is a sign-in, not a loop", async () => {
322
+ await McpSignIn.run(refOf(), { open: browser });
323
+ provider.issued.splice(0);
324
+ provider.refreshable = false;
325
+ const notices: string[] = [];
326
+ const pack = await connect(notices);
327
+ try {
328
+ expect(pack?.status[0]).toMatchObject({ auth: "required", tools: [] });
329
+ expect(notices.at(-1)).toContain("/mcp login fake");
330
+ } finally {
331
+ pack?.close();
332
+ }
333
+ });
334
+
335
+ test("a refreshed token that is refused too is not refreshed again", async () => {
336
+ await McpSignIn.run(refOf(), { open: browser });
337
+ provider.refusing = true;
338
+ const pack = await connect();
339
+ try {
340
+ expect(pack?.status[0]).toMatchObject({ auth: "required", tools: [] });
341
+ expect(provider.issued).toEqual(["access-1", "access-refreshed-2"]);
342
+ } finally {
343
+ pack?.close();
344
+ }
345
+ });
346
+
347
+ type McpExecute = (id: string, params: Record<string, unknown>) => Promise<{ content: { text: string }[] }>;
348
+ const search = async (pack: McpToolPack | undefined) => {
349
+ const tools = new Map<string, McpExecute>();
350
+ const extension = pack?.extension();
351
+ if (extension && "factory" in extension)
352
+ extension.factory({
353
+ registerTool: (tool: { name: string; execute: McpExecute }) => tools.set(tool.name, tool.execute),
354
+ } as never);
355
+ const execute = tools.get("mcp__fake__search");
356
+ if (!execute) throw new Error("the pack published no search tool");
357
+ return await execute("call", {});
358
+ };
359
+
360
+ test("a token refused mid-session is refreshed once, and the call is retried on a new session with the new token", async () => {
361
+ const auth = await McpSignIn.run(refOf(), { open: browser });
362
+ const pack = await connect();
363
+ try {
364
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
365
+ expect((await search(pack)).content[0]?.text).toBe("found");
366
+ expect(provider.refreshes).toBe(1);
367
+ expect(provider.calls).toEqual([{ token: "access-refreshed-1", session: "session-2" }]);
368
+ expect(McpTokenStore.read("fake")).toMatchObject({
369
+ accessToken: "access-refreshed-1",
370
+ refreshToken: "refresh-2",
371
+ });
372
+ } finally {
373
+ pack?.close();
374
+ }
375
+ });
376
+
377
+ test("calls refused together share one refresh and one new session", async () => {
378
+ const auth = await McpSignIn.run(refOf(), { open: browser });
379
+ const pack = await connect();
380
+ try {
381
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
382
+ const answers = await Promise.all([search(pack), search(pack), search(pack)]);
383
+ expect(answers.map((answer) => answer.content[0]?.text)).toEqual(["found", "found", "found"]);
384
+ expect(provider.refreshes).toBe(1);
385
+ expect(provider.sessions).toEqual(["session-1", "session-2"]);
386
+ } finally {
387
+ pack?.close();
388
+ }
389
+ });
390
+
391
+ test("a sub-agent's pack refused at the same moment waits on the same refresh", async () => {
392
+ const auth = await McpSignIn.run(refOf(), { open: browser });
393
+ const [parent, child] = [await connect(), await connect()];
394
+ try {
395
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
396
+ provider.refreshDelayMs = 100;
397
+ const answers = await Promise.all([search(parent), search(child)]);
398
+ expect(answers.map((answer) => answer.content[0]?.text)).toEqual(["found", "found"]);
399
+ expect(provider.refreshes).toBe(1);
400
+ } finally {
401
+ parent?.close();
402
+ child?.close();
403
+ }
404
+ });
405
+
406
+ test("a session connecting with an expired token while a call is refused shares one refresh with it", async () => {
407
+ const auth = await McpSignIn.run(refOf(), { open: browser });
408
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
409
+ provider.refreshDelayMs = 50;
410
+ const tokens = await Promise.all([McpSignIn.token(refOf()), McpSignIn.retryToken(refOf(), auth.accessToken)]);
411
+ expect(tokens).toEqual(["access-refreshed-2", "access-refreshed-2"]);
412
+ expect(provider.refreshes).toBe(1);
413
+ });
414
+
415
+ test("a mid-session refusal whose refresh fails names /mcp login, and the next call does not refresh again", async () => {
416
+ await McpSignIn.run(refOf(), { open: browser });
417
+ const notices: string[] = [];
418
+ const pack = await connect(notices);
419
+ try {
420
+ provider.issued.splice(0);
421
+ provider.refreshable = false;
422
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
423
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
424
+ expect(provider.refreshes).toBe(1);
425
+ expect(notices.join(" ")).toContain("needs signing in again");
426
+ } finally {
427
+ pack?.close();
428
+ }
429
+ });
430
+
431
+ test("a server whose token cannot be renewed mid-session reads as a failed connect does, until a sign-in and a reload", async () => {
432
+ await McpSignIn.run(refOf(), { open: browser });
433
+ const pack = await connect();
434
+ try {
435
+ expect(pack?.status[0]).toMatchObject({ auth: "authorized" });
436
+ provider.issued.splice(0);
437
+ provider.refreshable = false;
438
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
439
+ expect(pack?.status[0]).toMatchObject({ auth: "required", tools: ["mcp__fake__search"] });
440
+ } finally {
441
+ pack?.close();
442
+ }
443
+ provider.refreshable = true;
444
+ await McpSignIn.run(refOf(), { open: browser });
445
+ const reloaded = await connect();
446
+ try {
447
+ expect(reloaded?.status[0]).toMatchObject({ auth: "authorized" });
448
+ expect((await search(reloaded)).content[0]?.text).toBe("found");
449
+ } finally {
450
+ reloaded?.close();
451
+ }
452
+ });
453
+
454
+ test("a server that refuses the renewed token too reads as needing a sign-in", async () => {
455
+ await McpSignIn.run(refOf(), { open: browser });
456
+ const pack = await connect();
457
+ try {
458
+ provider.refusing = true;
459
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
460
+ expect(pack?.status[0]).toMatchObject({ auth: "required" });
461
+ } finally {
462
+ pack?.close();
463
+ }
464
+ });
465
+
466
+ test("a refreshed token the server refuses too ends the call in /mcp login after one refresh", async () => {
467
+ await McpSignIn.run(refOf(), { open: browser });
468
+ const pack = await connect();
469
+ try {
470
+ provider.refusing = true;
471
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
472
+ expect(provider.refreshes).toBe(1);
473
+ expect(provider.issued).toEqual(["access-1", "access-refreshed-2"]);
474
+ } finally {
475
+ pack?.close();
476
+ }
477
+ });
478
+
280
479
  test("a provider that registers no clients says what to declare instead", async () => {
281
480
  provider.registerable = false;
282
481
  await expect(McpSignIn.run(refOf(), { open: browser })).rejects.toThrow(/clientId/);
@@ -322,3 +521,92 @@ describe("the token file", () => {
322
521
  expect(McpTokenStore.names()).toEqual([]);
323
522
  });
324
523
  });
524
+
525
+ describe("two akan code processes", () => {
526
+ const lines = (stream: ReadableStream<Uint8Array>) => {
527
+ const reader = stream.getReader();
528
+ const decoder = new TextDecoder();
529
+ let buffer = "";
530
+ return async () => {
531
+ while (!buffer.includes("\n")) {
532
+ const chunk = await reader.read();
533
+ if (chunk.done) return buffer;
534
+ buffer += decoder.decode(chunk.value, { stream: true });
535
+ }
536
+ const [line = "", ...rest] = buffer.split("\n");
537
+ buffer = rest.join("\n");
538
+ return line;
539
+ };
540
+ };
541
+
542
+ // A separate `bun` per session, pointed at this file's AKAN_CODE_HOME so both read and write the one token file.
543
+ const session = async () => {
544
+ const script = path.join(home, `session-${crypto.randomUUID()}.ts`);
545
+ await Bun.write(
546
+ script,
547
+ `import { McpToolPack } from ${JSON.stringify(path.resolve(import.meta.dir, "McpToolPack.ts"))};
548
+ import { McpTokenStore } from ${JSON.stringify(path.resolve(import.meta.dir, "McpTokenStore.ts"))};
549
+ if (!McpTokenStore.file().startsWith(${JSON.stringify(home)})) throw new Error("refusing to touch " + McpTokenStore.file());
550
+ const pack = await McpToolPack.connect({
551
+ workspaceRoot: ${JSON.stringify(home)},
552
+ profile: { name: "t", tools: { mcp: [${JSON.stringify(refOf())}] } },
553
+ });
554
+ let execute;
555
+ const extension = pack?.extension();
556
+ if (extension && "factory" in extension)
557
+ extension.factory({ registerTool: (tool) => { if (tool.name === "mcp__fake__search") execute = tool.execute; } });
558
+ process.stdout.write("ready\\n");
559
+ for await (const line of console) if (line.trim() === "go") break;
560
+ const answer = await execute("call", {}).then((result) => result.content[0].text, (error) => "error: " + String(error));
561
+ process.stdout.write(JSON.stringify(answer) + "\\n");
562
+ pack?.close();
563
+ process.exit(0);
564
+ `,
565
+ );
566
+ const proc = Bun.spawn(["bun", script], {
567
+ env: { ...process.env, AKAN_CODE_HOME: home },
568
+ stdin: "pipe",
569
+ stdout: "pipe",
570
+ stderr: "pipe",
571
+ });
572
+ return { proc, line: lines(proc.stdout) };
573
+ };
574
+
575
+ test("a refresh waits for the lock another session holds, then takes the token that session stored", async () => {
576
+ const auth = await McpSignIn.run(refOf(), { open: browser });
577
+ const taken = Promise.withResolvers<void>();
578
+ const released = Promise.withResolvers<void>();
579
+ const elsewhere = ConfigLock.run(McpTokenStore.lockFile(), async () => {
580
+ taken.resolve();
581
+ await released.promise;
582
+ });
583
+ await taken.promise;
584
+ const retried = McpSignIn.retryToken(refOf(), auth.accessToken);
585
+ McpTokenStore.write("fake", { ...auth, accessToken: "access-elsewhere", refreshToken: "refresh-elsewhere" });
586
+ released.resolve();
587
+ await elsewhere;
588
+ expect(await retried).toBe("access-elsewhere");
589
+ expect(provider.refreshes).toBe(0);
590
+ });
591
+
592
+ test("refused at the same moment, they make one refresh between them, and both calls go through", async () => {
593
+ const auth = await McpSignIn.run(refOf(), { open: browser });
594
+ provider.rotating = true;
595
+ provider.refreshDelayMs = 300;
596
+ const sessions = [await session(), await session()];
597
+ try {
598
+ expect(await Promise.all(sessions.map(({ line }) => line()))).toEqual(["ready", "ready"]);
599
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
600
+ for (const { proc } of sessions) {
601
+ proc.stdin.write("go\n");
602
+ await proc.stdin.flush();
603
+ }
604
+ const answers = await Promise.all(sessions.map(({ line }) => line()));
605
+ expect(answers).toEqual(['"found"', '"found"']);
606
+ expect(provider.refreshes).toBe(1);
607
+ expect(provider.reused).toBe(false);
608
+ } finally {
609
+ for (const { proc } of sessions) proc.kill();
610
+ }
611
+ }, 30_000);
612
+ });
@@ -7,12 +7,7 @@ import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
7
7
 
8
8
  const roots: string[] = [];
9
9
 
10
- /**
11
- * The home half of the config, moved somewhere disposable.
12
- *
13
- * Without it every `add` in this file writes into whoever runs it — `~/.akan/code/mcp.json` is now a real
14
- * default, and a test that declares `github` would declare it for their every session.
15
- */
10
+ // Without this every add() writes into the real ~/.akan/code/mcp.json of whoever runs the suite.
16
11
  beforeAll(() => {
17
12
  const home = mkdtempSync(path.join(tmpdir(), "akan-home-"));
18
13
  roots.push(home);
@@ -61,7 +56,6 @@ describe("MCP server config", () => {
61
56
  ]);
62
57
  });
63
58
 
64
- /** Both are what a server that discovery cannot finish for is reached with, so the file has to carry them. */
65
59
  test("a static token and a hand-issued oauth client travel from the file", () => {
66
60
  const root = workspace(
67
61
  JSON.stringify({
@@ -104,7 +98,6 @@ describe("MCP server config", () => {
104
98
  expect(Object.keys(written(root).mcpServers ?? {})).toEqual(["linear", "github"]);
105
99
  });
106
100
 
107
- /** The file is shared with whatever editor wrote it, so the key it already uses is the key a write goes into. */
108
101
  test("a VS Code file keeps its own spelling through a write", () => {
109
102
  const root = workspace(JSON.stringify({ servers: { a: { command: "a" } } }));
110
103
  McpServerConfig.add(root, "b", ["b"], "workspace");
@@ -143,12 +136,6 @@ describe("MCP server config", () => {
143
136
  expect(McpServerConfig.problem(root)).toBeUndefined();
144
137
  });
145
138
 
146
- /**
147
- * The whole point of the home file: one declaration, every checkout.
148
- *
149
- * A server is an integration with an account and its token already lives in the home directory, so making
150
- * the declaration per repo means declaring and signing in again in each one.
151
- */
152
139
  test("a server declared in the home file is read from a workspace that declares nothing", () => {
153
140
  globalFile(JSON.stringify({ mcpServers: { office: { url: "https://office.example/mcp" } } }));
154
141
  const root = workspace();
@@ -189,6 +176,26 @@ describe("MCP server config", () => {
189
176
  expect(McpServerConfig.read(root)).toEqual([]);
190
177
  });
191
178
 
179
+ test("removing a name both files declare takes the workspace's, and the home file's then applies", () => {
180
+ globalFile(JSON.stringify({ mcpServers: { shared: { command: "home" } } }));
181
+ const root = workspace(JSON.stringify({ mcpServers: { shared: { command: "repo" } } }));
182
+ expect(McpServerConfig.remove(root, "shared")).toBe("workspace");
183
+ expect(McpServerConfig.declared(root)).toEqual([
184
+ { ref: { name: "shared", transport: "stdio", command: "home" }, disabled: false, scope: "global" },
185
+ ]);
186
+ });
187
+
188
+ test("a remove that names a scope touches only that file", () => {
189
+ globalFile(JSON.stringify({ mcpServers: { office: { command: "x" }, shared: { command: "home" } } }));
190
+ const root = workspace(JSON.stringify({ mcpServers: { shared: { command: "repo" } } }));
191
+ expect(McpServerConfig.remove(root, "office", "workspace")).toBe(false);
192
+ expect(McpServerConfig.remove(root, "shared", "global")).toBe("global");
193
+ expect(McpServerConfig.declared(root).map((entry) => [entry.ref.name, entry.scope])).toEqual([
194
+ ["office", "global"],
195
+ ["shared", "workspace"],
196
+ ]);
197
+ });
198
+
192
199
  test("a scope is what a caller names, never guessed from the shape", () => {
193
200
  const root = workspace();
194
201
  const scopes: McpServerScope[] = ["global", "workspace"];
@@ -1,15 +1,10 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFile, stat, utimes, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { CodegenLock } from "./codegenLock";
5
+ import { tempDirs } from "./testHelpers";
6
6
 
7
- const roots: string[] = [];
8
- const makeRoot = async () => {
9
- const root = await mkdtemp(path.join(tmpdir(), "akan-codegen-lock-"));
10
- roots.push(root);
11
- return root;
12
- };
7
+ const makeRoot = tempDirs("akan-codegen-lock-");
13
8
  const seedHolder = async (root: string, holder: unknown) => {
14
9
  const lockPath = CodegenLock.pathIn(root);
15
10
  await Bun.write(lockPath, typeof holder === "string" ? holder : JSON.stringify(holder));
@@ -18,10 +13,6 @@ const seedHolder = async (root: string, holder: unknown) => {
18
13
  /** A pid that cannot be alive: `kill(0)` on it is ESRCH on every platform this runs on. */
19
14
  const deadPid = 0x7ffffff;
20
15
 
21
- afterEach(async () => {
22
- for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
23
- });
24
-
25
16
  describe("CodegenLock", () => {
26
17
  test("serializes concurrent callers in the same process", async () => {
27
18
  const root = await makeRoot();
package/codegenLock.ts CHANGED
@@ -8,26 +8,12 @@ interface LockHolder {
8
8
  label: string;
9
9
  }
10
10
 
11
- /**
12
- * A workspace-wide mutex over the generated source files every dev server in the workspace rewrites.
13
- *
14
- * `WatchRootResolver` narrows each dev server to its own app and its own lib dependencies, but two apps
15
- * that share a lib still both watch it, so a save there reaches both builders and both regenerate the
16
- * same barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is
17
- * a rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
18
- *
19
- * A wait that expires proceeds *without* the lock rather than failing: this sits on the dev server's
20
- * hot path, and stalling the file watcher is worse than the torn read `FileSys.writeTextAtomic` already
21
- * prevents on its own.
22
- */
11
+ // Two dev servers sharing a lib both regenerate its barrels, and a mid-scan watcher reads a half-written one as a user
12
+ // edit. An expired wait proceeds unlocked: stalling the watcher is worse than the torn read `writeTextAtomic` prevents.
23
13
  export class CodegenLock {
24
14
  static readonly fileName = "codegen.lock";
25
15
  static readonly waitTimeoutMs = 10_000;
26
- /**
27
- * How long an unreadable lock file is respected. It covers the window between the exclusive create
28
- * and the holder write, where the file exists but names no pid yet — a young one is somebody else
29
- * mid-acquire, not a corpse.
30
- */
16
+ /** A holder-less lock file younger than this is someone mid-acquire (between the exclusive create and the write). */
31
17
  static readonly unknownHolderStaleMs = 60_000;
32
18
  static readonly #pollMs = 25;
33
19
  static readonly #logger = new Logger("CodegenLock");
@@ -83,11 +83,7 @@ export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPri
83
83
  export const App = createInternalArgToken<AppExecutor, "App">("App");
84
84
  export type App = AppExecutor;
85
85
 
86
- /**
87
- * One or more apps, from a variadic positional (`akan start a b`, `akan start a,b`, `akan start all`)
88
- * or a checkbox when none is named. Reach for it only where running several is meaningful — every other
89
- * command takes `App`, whose single-select is unchanged.
90
- */
86
+ /** One or more apps: `akan start a b`, `a,b` or `all`, or a checkbox when none is named. */
91
87
  export const Apps = createInternalArgToken<AppExecutor[], "Apps">("Apps");
92
88
  export type Apps = AppExecutor[];
93
89
 
@@ -0,0 +1 @@
1
+ export const camelToKebabCase = (str: string) => str.replace(/([A-Z])/g, "-$1").toLowerCase();