@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.
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +1 -4
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +64 -250
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +30 -39
- package/akanConfig/akanConfig.ts +40 -136
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.ts +25 -59
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +3 -19
- package/artifact/implicitRootLayout.ts +34 -91
- package/artifact/routeSeedIndex.ts +11 -44
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +4 -14
- package/capacitorApp.ts +86 -235
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +33 -57
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +3 -2
- package/cloud/globalConfig.ts +64 -31
- package/codeAgent/agent/AkanCodeServices.ts +15 -57
- package/codeAgent/agent/AkanContextFiles.ts +5 -28
- package/codeAgent/agent/CodeAgent.ts +33 -108
- package/codeAgent/agent/CodeAgentAsks.ts +7 -30
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
- package/codeAgent/agent/CodeAgentGate.ts +8 -34
- package/codeAgent/agent/CodeAgentProxy.ts +4 -13
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
- package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
- package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
- package/codeAgent/agent/CodeAgentUi.ts +2 -7
- package/codeAgent/agent/CodeMailbox.ts +7 -34
- package/codeAgent/agent/CodeSessionFork.ts +4 -19
- package/codeAgent/agent/CodeSessionIndex.ts +5 -31
- package/codeAgent/agent/SubagentPool.ts +48 -58
- package/codeAgent/agent/akanCodeModel.ts +5 -39
- package/codeAgent/agent/akanCodePaths.ts +4 -24
- package/codeAgent/agent/akanContextFiles.test.ts +0 -2
- package/codeAgent/agent/akanSystemPrompt.ts +1 -9
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +5 -28
- package/codeAgent/agent/codeMailbox.test.ts +0 -4
- package/codeAgent/agent/codeSessionFork.test.ts +0 -2
- package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
- package/codeAgent/agent/consoleToStderr.ts +2 -12
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +3 -18
- package/codeAgent/feedback/PreviewChrome.ts +5 -29
- package/codeAgent/feedback/PreviewFeedback.ts +2 -9
- package/codeAgent/feedback/PreviewView.ts +3 -15
- package/codeAgent/feedback/TurnFeedback.ts +2 -17
- package/codeAgent/feedback/VerifyFeedback.ts +1 -7
- package/codeAgent/feedback/previewChrome.test.ts +1 -1
- package/codeAgent/index.ts +2 -10
- package/codeAgent/skills/akan-code/SKILL.md +12 -4
- package/codeAgent/tools/AkanCli.ts +4 -16
- package/codeAgent/tools/AkanCodePlugins.ts +21 -43
- package/codeAgent/tools/AkanEditScope.ts +2 -12
- package/codeAgent/tools/AkanEnvKeys.ts +3 -11
- package/codeAgent/tools/AkanToolPack.ts +6 -28
- package/codeAgent/tools/AkanVerifier.ts +1 -8
- package/codeAgent/tools/AskToolPack.ts +3 -15
- package/codeAgent/tools/MailToolPack.ts +0 -9
- package/codeAgent/tools/McpClient.ts +3 -24
- package/codeAgent/tools/McpOAuth.ts +6 -41
- package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
- package/codeAgent/tools/McpServerConfig.ts +7 -26
- package/codeAgent/tools/McpSignIn.ts +46 -49
- package/codeAgent/tools/McpTokenStore.ts +6 -17
- package/codeAgent/tools/McpToolPack.ts +107 -35
- package/codeAgent/tools/SessionToolPack.ts +1 -11
- package/codeAgent/tools/WebToolPack.ts +1 -7
- package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
- package/codeAgent/tools/askToolPack.test.ts +0 -3
- package/codeAgent/tools/mcpOAuth.test.ts +315 -27
- package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +44 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +1 -9
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +66 -144
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +27 -78
- package/devkitUtils.test.ts +47 -19
- package/executors.test.ts +114 -60
- package/executors.ts +165 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +1 -1
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +14 -38
- package/frontendBuild/autoImportSync.ts +15 -67
- package/frontendBuild/buildRouteClient.test.ts +10 -36
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +5 -18
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +5 -13
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +27 -52
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +7 -31
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +7 -47
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +14 -18
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
- package/index.ts +3 -15
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +9 -29
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/gritRules.test.ts +6 -23
- package/linter.test.ts +1 -2
- package/linter.ts +22 -115
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +2 -2
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +3 -14
- package/platformTest/IdleWatchedProcess.test.ts +5 -13
- package/platformTest/IdleWatchedProcess.ts +1 -5
- package/platformTest/LinuxDockerTarget.ts +20 -35
- package/platformTest/PlatformTestRun.test.ts +5 -9
- package/platformTest/PlatformTestTarget.ts +24 -2
- package/platformTest/TestSnapshot.test.ts +6 -14
- package/platformTest/TestSnapshot.ts +1 -5
- package/platformTest/WindowsSshTarget.ts +6 -22
- package/prompter.ts +3 -6
- package/qualityScanner.test.ts +6 -24
- package/qualityScanner.ts +196 -284
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.ts +7 -30
- package/scanInfo.ts +82 -214
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/subspace.test.ts +6 -25
- package/subspace.ts +31 -109
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +49 -34
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +85 -146
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/source.ts +33 -56
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"];
|
package/codegenLock.test.ts
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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();
|