@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19
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.ts +5 -4
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +33 -6
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +28 -2
- package/capacitorApp.ts +25 -4
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +38 -12
- package/codeAgent/agent/CodeAgent.ts +94 -30
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +19 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +324 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +18 -46
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/index.ts +0 -1
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/executor.ts +52 -0
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { CodeAgentMcpServerRef } from "akanjs/common";
|
|
7
|
+
import { McpOAuth } from "./McpOAuth";
|
|
8
|
+
import { McpSignIn } from "./McpSignIn";
|
|
9
|
+
import { McpTokenStore } from "./McpTokenStore";
|
|
10
|
+
import { McpToolPack } from "./McpToolPack";
|
|
11
|
+
|
|
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
|
+
*/
|
|
20
|
+
class FakeProvider {
|
|
21
|
+
readonly server: ReturnType<typeof Bun.serve>;
|
|
22
|
+
readonly issued: string[] = [];
|
|
23
|
+
readonly registrations: { redirect_uris?: string[] }[] = [];
|
|
24
|
+
/** code -> the challenge it was issued against, which the token endpoint checks the verifier against. */
|
|
25
|
+
readonly #codes = new Map<string, { challenge: string; resource: string | null }>();
|
|
26
|
+
#next = 0;
|
|
27
|
+
/** Set to refuse S256, which a client must treat as "do not send a code" rather than downgrade. */
|
|
28
|
+
methods: string[] = ["S256"];
|
|
29
|
+
registerable = true;
|
|
30
|
+
refreshable = true;
|
|
31
|
+
|
|
32
|
+
constructor() {
|
|
33
|
+
this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get origin() {
|
|
37
|
+
return `http://127.0.0.1:${this.server.port}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get mcpUrl() {
|
|
41
|
+
return `${this.origin}/mcp`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
close() {
|
|
45
|
+
this.server.stop(true);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async #route(request: Request): Promise<Response> {
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
if (url.pathname === "/mcp") return await this.#mcp(request);
|
|
51
|
+
if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
|
|
52
|
+
return Response.json({ resource: this.mcpUrl, authorization_servers: [this.origin], scopes_supported: ["read"] });
|
|
53
|
+
if (url.pathname === "/.well-known/oauth-authorization-server")
|
|
54
|
+
return Response.json({
|
|
55
|
+
issuer: this.origin,
|
|
56
|
+
authorization_endpoint: `${this.origin}/authorize`,
|
|
57
|
+
token_endpoint: `${this.origin}/token`,
|
|
58
|
+
...(this.registerable ? { registration_endpoint: `${this.origin}/register` } : {}),
|
|
59
|
+
code_challenge_methods_supported: this.methods,
|
|
60
|
+
});
|
|
61
|
+
if (url.pathname === "/register") return await this.#register(request);
|
|
62
|
+
if (url.pathname === "/authorize") return this.#authorize(url);
|
|
63
|
+
if (url.pathname === "/token") return await this.#token(request);
|
|
64
|
+
return new Response("not found", { status: 404 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async #mcp(request: Request) {
|
|
68
|
+
const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
|
|
69
|
+
if (!token || !this.issued.includes(token))
|
|
70
|
+
return Response.json(
|
|
71
|
+
{ error_description: "Authentication is required to use this MCP server." },
|
|
72
|
+
{
|
|
73
|
+
status: 401,
|
|
74
|
+
headers: {
|
|
75
|
+
"WWW-Authenticate": `Bearer resource_metadata="${this.origin}/.well-known/oauth-protected-resource/mcp"`,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
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" } });
|
|
82
|
+
if (body.method === "tools/list")
|
|
83
|
+
return Response.json({
|
|
84
|
+
jsonrpc: "2.0",
|
|
85
|
+
id: body.id,
|
|
86
|
+
result: {
|
|
87
|
+
tools: [{ name: "search", description: "search it", inputSchema: { type: "object", properties: {} } }],
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #register(request: Request) {
|
|
94
|
+
const body = (await request.json()) as { redirect_uris?: string[] };
|
|
95
|
+
this.registrations.push(body);
|
|
96
|
+
this.#next += 1;
|
|
97
|
+
return Response.json({ client_id: `client-${this.#next}`, redirect_uris: body.redirect_uris });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stands in for the consent screen: it redirects straight back, which is what a browser would end up doing. */
|
|
101
|
+
#authorize(url: URL) {
|
|
102
|
+
const redirect = url.searchParams.get("redirect_uri") ?? "";
|
|
103
|
+
const challenge = url.searchParams.get("code_challenge") ?? "";
|
|
104
|
+
const code = `code-${this.#codes.size + 1}`;
|
|
105
|
+
this.#codes.set(code, { challenge, resource: url.searchParams.get("resource") });
|
|
106
|
+
const back = new URL(redirect);
|
|
107
|
+
back.searchParams.set("code", code);
|
|
108
|
+
back.searchParams.set("state", url.searchParams.get("state") ?? "");
|
|
109
|
+
back.searchParams.set("iss", this.origin);
|
|
110
|
+
return Response.redirect(back.href, 302);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async #token(request: Request) {
|
|
114
|
+
const form = new URLSearchParams(await request.text());
|
|
115
|
+
if (form.get("grant_type") === "refresh_token") {
|
|
116
|
+
if (!this.refreshable) return Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
117
|
+
const access = `access-refreshed-${this.issued.length + 1}`;
|
|
118
|
+
this.issued.push(access);
|
|
119
|
+
return Response.json({ access_token: access, refresh_token: "refresh-2", expires_in: 3600 });
|
|
120
|
+
}
|
|
121
|
+
const issued = this.#codes.get(form.get("code") ?? "");
|
|
122
|
+
if (!issued) return Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
123
|
+
const verifier = form.get("code_verifier") ?? "";
|
|
124
|
+
if (createHash("sha256").update(verifier).digest("base64url") !== issued.challenge)
|
|
125
|
+
return Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
126
|
+
const access = `access-${this.issued.length + 1}`;
|
|
127
|
+
this.issued.push(access);
|
|
128
|
+
return Response.json({ access_token: access, refresh_token: "refresh-1", expires_in: 3600, scope: "read" });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let provider: FakeProvider;
|
|
133
|
+
let home: string;
|
|
134
|
+
let realHome: string | undefined;
|
|
135
|
+
|
|
136
|
+
const refOf = (patch: Partial<CodeAgentMcpServerRef> = {}): CodeAgentMcpServerRef => ({
|
|
137
|
+
name: "fake",
|
|
138
|
+
transport: "http",
|
|
139
|
+
url: provider.mcpUrl,
|
|
140
|
+
...patch,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** A browser, reduced to what the flow needs of one: visit the url and follow where it points. */
|
|
144
|
+
const browser = (url: string) => void fetch(url).catch(() => undefined);
|
|
145
|
+
|
|
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
|
+
*/
|
|
153
|
+
beforeAll(() => {
|
|
154
|
+
realHome = process.env.AKAN_CODE_HOME;
|
|
155
|
+
home = mkdtempSync(path.join(tmpdir(), "akan-mcpauth-"));
|
|
156
|
+
process.env.AKAN_CODE_HOME = home;
|
|
157
|
+
expect(McpTokenStore.file().startsWith(home)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterAll(() => {
|
|
161
|
+
if (realHome === undefined) delete process.env.AKAN_CODE_HOME;
|
|
162
|
+
else process.env.AKAN_CODE_HOME = realHome;
|
|
163
|
+
rmSync(home, { recursive: true, force: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
provider = new FakeProvider();
|
|
168
|
+
rmSync(McpTokenStore.file(), { force: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
provider.close();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("MCP OAuth discovery", () => {
|
|
176
|
+
test("the challenge names the metadata, and it is preferred over any url we could derive", () => {
|
|
177
|
+
expect(
|
|
178
|
+
McpOAuth.resourceMetadataUrls("https://x.dev/mcp", 'Bearer resource_metadata="https://x.dev/custom"'),
|
|
179
|
+
).toEqual(["https://x.dev/custom"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("with no challenge, both spellings of the well-known url are tried", () => {
|
|
183
|
+
expect(McpOAuth.resourceMetadataUrls("https://x.dev/mcp")).toEqual([
|
|
184
|
+
"https://x.dev/.well-known/oauth-protected-resource/mcp",
|
|
185
|
+
"https://x.dev/.well-known/oauth-protected-resource",
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("the issuer's path is inserted after the suffix, as RFC 8414 requires", () => {
|
|
190
|
+
expect(McpOAuth.serverMetadataUrls("https://x.dev/tenant/a")).toEqual([
|
|
191
|
+
"https://x.dev/.well-known/oauth-authorization-server/tenant/a",
|
|
192
|
+
"https://x.dev/tenant/a/.well-known/openid-configuration",
|
|
193
|
+
"https://x.dev/.well-known/oauth-authorization-server",
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("everything the flow needs is found from the server url alone", async () => {
|
|
198
|
+
const server = await McpOAuth.discover(provider.mcpUrl, await McpSignIn.challenge(refOf()));
|
|
199
|
+
expect(server).toMatchObject({
|
|
200
|
+
issuer: provider.origin,
|
|
201
|
+
authorizationEndpoint: `${provider.origin}/authorize`,
|
|
202
|
+
tokenEndpoint: `${provider.origin}/token`,
|
|
203
|
+
registrationEndpoint: `${provider.origin}/register`,
|
|
204
|
+
resource: provider.mcpUrl,
|
|
205
|
+
resourceScopes: ["read"],
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** OAuth 2.1 drops `plain`; a downgrade here is one a network attacker would pick on the client's behalf. */
|
|
210
|
+
test("a server that will not do S256 is refused rather than downgraded", async () => {
|
|
211
|
+
provider.methods = ["plain"];
|
|
212
|
+
await expect(McpOAuth.discover(provider.mcpUrl)).rejects.toThrow(/S256/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a server that wants no credential answers no challenge", async () => {
|
|
216
|
+
const open = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => Response.json({ jsonrpc: "2.0", id: 0 }) });
|
|
217
|
+
try {
|
|
218
|
+
expect(await McpSignIn.challenge(refOf({ url: `http://127.0.0.1:${open.port}/mcp` }))).toBeNull();
|
|
219
|
+
} finally {
|
|
220
|
+
open.stop(true);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("MCP sign-in", () => {
|
|
226
|
+
test("a browser round trip ends with a token this session can connect with", async () => {
|
|
227
|
+
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
228
|
+
expect(auth.accessToken).toBe("access-1");
|
|
229
|
+
expect(auth.issuer).toBe(provider.origin);
|
|
230
|
+
expect(auth.resource).toBe(provider.mcpUrl);
|
|
231
|
+
// The client was registered on demand, against the loopback the flow actually bound.
|
|
232
|
+
expect(provider.registrations).toHaveLength(1);
|
|
233
|
+
expect(provider.registrations[0]?.redirect_uris?.[0]).toBe(auth.redirectUri);
|
|
234
|
+
expect(McpTokenStore.read("fake")?.accessToken).toBe("access-1");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("the token is what makes the server publish its tools", async () => {
|
|
238
|
+
const before = await McpToolPack.connect({
|
|
239
|
+
workspaceRoot: home,
|
|
240
|
+
profile: { name: "t", tools: { mcp: [refOf()] } } as never,
|
|
241
|
+
});
|
|
242
|
+
expect(before?.status[0]).toMatchObject({ auth: "required", tools: [] });
|
|
243
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
244
|
+
const after = await McpToolPack.connect({
|
|
245
|
+
workspaceRoot: home,
|
|
246
|
+
profile: { name: "t", tools: { mcp: [refOf()] } } as never,
|
|
247
|
+
});
|
|
248
|
+
try {
|
|
249
|
+
expect(after?.status[0]).toMatchObject({ auth: "authorized" });
|
|
250
|
+
expect(after?.toolNames).toEqual(["mcp__fake__search"]);
|
|
251
|
+
} finally {
|
|
252
|
+
after?.close();
|
|
253
|
+
before?.close();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a second sign-in reuses the client it already registered", async () => {
|
|
258
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
259
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
260
|
+
expect(provider.registrations).toHaveLength(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("an expired token is refreshed without anybody being asked", async () => {
|
|
264
|
+
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
265
|
+
McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
|
|
266
|
+
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
|
+
expect(McpTokenStore.read("fake")?.refreshToken).toBe("refresh-2");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a refresh the server refuses reports no token rather than throwing", async () => {
|
|
272
|
+
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
273
|
+
McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
|
|
274
|
+
provider.refreshable = false;
|
|
275
|
+
const notices: string[] = [];
|
|
276
|
+
expect(await McpSignIn.token(refOf(), (message) => notices.push(message))).toBeUndefined();
|
|
277
|
+
expect(notices.join(" ")).toContain("fake");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a provider that registers no clients says what to declare instead", async () => {
|
|
281
|
+
provider.registerable = false;
|
|
282
|
+
await expect(McpSignIn.run(refOf(), { open: browser })).rejects.toThrow(/clientId/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a declared client id is used instead of registering one", async () => {
|
|
286
|
+
const auth = await McpSignIn.run(refOf({ oauth: { clientId: "mine" } }), { open: browser });
|
|
287
|
+
expect(auth.clientId).toBe("mine");
|
|
288
|
+
expect(provider.registrations).toHaveLength(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a stdio server is told its credential goes in env", async () => {
|
|
292
|
+
await expect(McpSignIn.run({ name: "s", transport: "stdio", command: "x" }, { open: browser })).rejects.toThrow(
|
|
293
|
+
/env/,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("signing out forgets the token and leaves nothing behind", async () => {
|
|
298
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
299
|
+
expect(McpTokenStore.clear("fake")).toBe(true);
|
|
300
|
+
expect(McpTokenStore.read("fake")).toBeUndefined();
|
|
301
|
+
expect(McpTokenStore.clear("fake")).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("the token file", () => {
|
|
306
|
+
test("it lives outside the workspace", async () => {
|
|
307
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
308
|
+
const file = McpTokenStore.file();
|
|
309
|
+
expect(file.startsWith(home)).toBe(true);
|
|
310
|
+
expect(await Bun.file(file).exists()).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test.skipIf(process.platform === "win32")("it is readable only by its owner", async () => {
|
|
314
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
315
|
+
expect(statSync(McpTokenStore.file()).mode & 0o777).toBe(0o600);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a corrupt file costs the sign-ins and not the session", async () => {
|
|
319
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
320
|
+
await Bun.write(McpTokenStore.file(), "{ not json");
|
|
321
|
+
expect(McpTokenStore.read("fake")).toBeUndefined();
|
|
322
|
+
expect(McpTokenStore.names()).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { akanCodePaths } from "../agent/akanCodePaths";
|
|
6
|
+
import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
|
|
6
7
|
|
|
7
8
|
const roots: string[] = [];
|
|
8
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
|
+
*/
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
const home = mkdtempSync(path.join(tmpdir(), "akan-home-"));
|
|
18
|
+
roots.push(home);
|
|
19
|
+
process.env.AKAN_CODE_HOME = home;
|
|
20
|
+
expect(akanCodePaths.globalMcpFile().startsWith(home)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const globalFile = (file: string) => {
|
|
24
|
+
const target = akanCodePaths.globalMcpFile();
|
|
25
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
26
|
+
writeFileSync(target, file);
|
|
27
|
+
};
|
|
28
|
+
|
|
9
29
|
const workspace = (file?: string) => {
|
|
10
30
|
const root = mkdtempSync(path.join(tmpdir(), "akan-mcp-"));
|
|
11
31
|
roots.push(root);
|
|
@@ -20,7 +40,9 @@ const written = (root: string) =>
|
|
|
20
40
|
JSON.parse(readFileSync(McpServerConfig.file(root), "utf8")) as Record<string, object>;
|
|
21
41
|
|
|
22
42
|
afterEach(() => {
|
|
23
|
-
|
|
43
|
+
rmSync(akanCodePaths.globalMcpFile(), { force: true });
|
|
44
|
+
for (const root of roots.slice(1)) rmSync(root, { recursive: true, force: true });
|
|
45
|
+
roots.splice(1);
|
|
24
46
|
});
|
|
25
47
|
|
|
26
48
|
describe("MCP server config", () => {
|
|
@@ -39,22 +61,41 @@ describe("MCP server config", () => {
|
|
|
39
61
|
]);
|
|
40
62
|
});
|
|
41
63
|
|
|
64
|
+
/** Both are what a server that discovery cannot finish for is reached with, so the file has to carry them. */
|
|
65
|
+
test("a static token and a hand-issued oauth client travel from the file", () => {
|
|
66
|
+
const root = workspace(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
mcpServers: {
|
|
69
|
+
sentry: {
|
|
70
|
+
url: "https://mcp.sentry.dev/mcp",
|
|
71
|
+
headers: { "x-api-key": "from-the-file" },
|
|
72
|
+
oauth: { clientId: "abc", scope: "project:read" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(McpServerConfig.read(root)[0]).toMatchObject({
|
|
78
|
+
headers: { "x-api-key": "from-the-file" },
|
|
79
|
+
oauth: { clientId: "abc", scope: "project:read" },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
42
83
|
test("a disabled server is declared but never connected", () => {
|
|
43
84
|
const root = workspace(JSON.stringify({ mcpServers: { off: { command: "x", disabled: true } } }));
|
|
44
85
|
expect(McpServerConfig.read(root)).toEqual([]);
|
|
45
86
|
expect(McpServerConfig.declared(root)).toEqual([
|
|
46
|
-
{ ref: { name: "off", transport: "stdio", command: "x" }, disabled: true },
|
|
87
|
+
{ ref: { name: "off", transport: "stdio", command: "x" }, disabled: true, scope: "workspace" },
|
|
47
88
|
]);
|
|
48
89
|
});
|
|
49
90
|
|
|
50
91
|
test("a url is an http server and anything else is a command with its argv", () => {
|
|
51
92
|
const root = workspace();
|
|
52
|
-
expect(McpServerConfig.add(root, "linear", ["https://mcp.linear.app/sse"])).toEqual({
|
|
93
|
+
expect(McpServerConfig.add(root, "linear", ["https://mcp.linear.app/sse"], "workspace")).toEqual({
|
|
53
94
|
name: "linear",
|
|
54
95
|
transport: "http",
|
|
55
96
|
url: "https://mcp.linear.app/sse",
|
|
56
97
|
});
|
|
57
|
-
expect(McpServerConfig.add(root, "github", ["npx", "-y", "server-github"])).toEqual({
|
|
98
|
+
expect(McpServerConfig.add(root, "github", ["npx", "-y", "server-github"], "workspace")).toEqual({
|
|
58
99
|
name: "github",
|
|
59
100
|
transport: "stdio",
|
|
60
101
|
command: "npx",
|
|
@@ -66,7 +107,7 @@ describe("MCP server config", () => {
|
|
|
66
107
|
/** The file is shared with whatever editor wrote it, so the key it already uses is the key a write goes into. */
|
|
67
108
|
test("a VS Code file keeps its own spelling through a write", () => {
|
|
68
109
|
const root = workspace(JSON.stringify({ servers: { a: { command: "a" } } }));
|
|
69
|
-
McpServerConfig.add(root, "b", ["b"]);
|
|
110
|
+
McpServerConfig.add(root, "b", ["b"], "workspace");
|
|
70
111
|
const file = written(root);
|
|
71
112
|
expect(Object.keys(file)).toEqual(["servers"]);
|
|
72
113
|
expect(Object.keys(file.servers ?? {})).toEqual(["a", "b"]);
|
|
@@ -74,20 +115,20 @@ describe("MCP server config", () => {
|
|
|
74
115
|
|
|
75
116
|
test("adding the same name twice replaces the entry rather than doubling it", () => {
|
|
76
117
|
const root = workspace();
|
|
77
|
-
McpServerConfig.add(root, "a", ["old"]);
|
|
78
|
-
McpServerConfig.add(root, "a", ["new", "--flag"]);
|
|
118
|
+
McpServerConfig.add(root, "a", ["old"], "workspace");
|
|
119
|
+
McpServerConfig.add(root, "a", ["new", "--flag"], "workspace");
|
|
79
120
|
expect(McpServerConfig.read(root)).toEqual([{ name: "a", transport: "stdio", command: "new", args: ["--flag"] }]);
|
|
80
121
|
});
|
|
81
122
|
|
|
82
123
|
test("removing says whether it removed anything", () => {
|
|
83
124
|
const root = workspace(JSON.stringify({ mcpServers: { a: { command: "a" } } }));
|
|
84
125
|
expect(McpServerConfig.remove(root, "nope")).toBe(false);
|
|
85
|
-
expect(McpServerConfig.remove(root, "a")).toBe(
|
|
126
|
+
expect(McpServerConfig.remove(root, "a")).toBe("workspace");
|
|
86
127
|
expect(McpServerConfig.read(root)).toEqual([]);
|
|
87
128
|
});
|
|
88
129
|
|
|
89
130
|
test("a server with nothing to reach it by is refused", () => {
|
|
90
|
-
expect(() => McpServerConfig.add(workspace(), "a", [])).toThrow();
|
|
131
|
+
expect(() => McpServerConfig.add(workspace(), "a", [], "workspace")).toThrow();
|
|
91
132
|
});
|
|
92
133
|
|
|
93
134
|
test("a malformed file costs its servers and is named rather than thrown", () => {
|
|
@@ -102,6 +143,59 @@ describe("MCP server config", () => {
|
|
|
102
143
|
expect(McpServerConfig.problem(root)).toBeUndefined();
|
|
103
144
|
});
|
|
104
145
|
|
|
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
|
+
test("a server declared in the home file is read from a workspace that declares nothing", () => {
|
|
153
|
+
globalFile(JSON.stringify({ mcpServers: { office: { url: "https://office.example/mcp" } } }));
|
|
154
|
+
const root = workspace();
|
|
155
|
+
expect(McpServerConfig.read(root)).toEqual([
|
|
156
|
+
{ name: "office", transport: "http", url: "https://office.example/mcp" },
|
|
157
|
+
]);
|
|
158
|
+
expect(McpServerConfig.declared(root)[0]?.scope).toBe("global");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("both files are merged, and the workspace wins the name they share", () => {
|
|
162
|
+
globalFile(JSON.stringify({ mcpServers: { office: { url: "https://home/mcp" }, shared: { command: "home" } } }));
|
|
163
|
+
const root = workspace(JSON.stringify({ mcpServers: { shared: { command: "repo" } } }));
|
|
164
|
+
expect(McpServerConfig.declared(root).map((entry) => [entry.ref.name, entry.scope])).toEqual([
|
|
165
|
+
["office", "global"],
|
|
166
|
+
["shared", "workspace"],
|
|
167
|
+
]);
|
|
168
|
+
expect(McpServerConfig.read(root).find((ref) => ref.name === "shared")?.command).toBe("repo");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a write goes to the scope it was asked for, and leaves the other file alone", () => {
|
|
172
|
+
const root = workspace();
|
|
173
|
+
McpServerConfig.add(root, "office", ["https://office.example/mcp"]);
|
|
174
|
+
expect(McpServerConfig.declared(root)).toEqual([
|
|
175
|
+
{
|
|
176
|
+
ref: { name: "office", transport: "http", url: "https://office.example/mcp" },
|
|
177
|
+
disabled: false,
|
|
178
|
+
scope: "global",
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
expect(existsSync(McpServerConfig.file(root, "workspace"))).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("removing finds the file the server is actually in", () => {
|
|
185
|
+
globalFile(JSON.stringify({ mcpServers: { office: { command: "x" } } }));
|
|
186
|
+
const root = workspace(JSON.stringify({ mcpServers: { repobot: { command: "y" } } }));
|
|
187
|
+
expect(McpServerConfig.remove(root, "office")).toBe("global");
|
|
188
|
+
expect(McpServerConfig.remove(root, "repobot")).toBe("workspace");
|
|
189
|
+
expect(McpServerConfig.read(root)).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a scope is what a caller names, never guessed from the shape", () => {
|
|
193
|
+
const root = workspace();
|
|
194
|
+
const scopes: McpServerScope[] = ["global", "workspace"];
|
|
195
|
+
for (const scope of scopes) McpServerConfig.add(root, `in-${scope}`, ["x"], scope);
|
|
196
|
+
expect(McpServerConfig.declared(root).map((entry) => entry.scope)).toEqual(["global", "workspace"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
105
199
|
test("a server reads back as the one line it is reached at", () => {
|
|
106
200
|
expect(McpServerConfig.targetOf({ name: "a", transport: "stdio", command: "npx", args: ["-y", "x"] })).toBe(
|
|
107
201
|
"npx -y x",
|
|
@@ -71,6 +71,8 @@ const handleOption = (programCommand: Command, argMeta: ArgMeta) => {
|
|
|
71
71
|
`-${flag}, --${kebabName}${type === "boolean" ? " [boolean]" : ` <${kebabName}>`}`,
|
|
72
72
|
`${desc}${ask ? ` (${ask})` : ""}${example ? ` (example: ${example})` : ""}${choices ? ` (choices: ${choices.map((choice) => choice.name).join(", ")})` : ""}`,
|
|
73
73
|
);
|
|
74
|
+
if (type === "boolean" && argMeta.argsOption.default === true)
|
|
75
|
+
programCommand.option(`--no-${kebabName}`, `turn off --${kebabName}`);
|
|
74
76
|
return programCommand;
|
|
75
77
|
};
|
|
76
78
|
const handleArgument = (programCommand: Command, argMeta: ArgMeta) => {
|
|
@@ -125,7 +125,7 @@ describe("command helper metadata", () => {
|
|
|
125
125
|
|
|
126
126
|
const commandHelp = stripAnsi(formatCommandHelp(HelpCommand, "buildApp"));
|
|
127
127
|
expect(commandHelp).toContain("akan build-app [app]");
|
|
128
|
-
expect(commandHelp).toContain("--write");
|
|
128
|
+
expect(commandHelp).toContain("--write, --no-write");
|
|
129
129
|
expect(commandHelp).toContain("[default: true]");
|
|
130
130
|
expect(commandHelp).toContain("Fast, Full");
|
|
131
131
|
});
|
|
@@ -217,7 +217,8 @@ export const formatCommandHelp = (command: CommandCls, key: string) => {
|
|
|
217
217
|
const opt = arg.argsOption;
|
|
218
218
|
const flag = opt.flag ? `-${opt.flag}, ` : "";
|
|
219
219
|
const kebabName = camelToKebabCase(arg.name);
|
|
220
|
-
const
|
|
220
|
+
const negation = opt.type === "boolean" && opt.default === true ? `, --no-${kebabName}` : "";
|
|
221
|
+
const optName = `${flag}--${kebabName}${negation}`;
|
|
221
222
|
const optDesc = opt.desc ?? "";
|
|
222
223
|
const defaultVal = opt.default !== undefined ? chalk.gray(` [default: ${String(opt.default)}]`) : "";
|
|
223
224
|
const choices = opt.enum
|
package/dependencyScanner.ts
CHANGED
|
@@ -10,8 +10,10 @@ const testFileRegex = /\.(?:test|spec)\.[cm]?[tj]sx?$/;
|
|
|
10
10
|
// `__fixtures__` holds deliberately invalid sample sources for the lint-rule suites; tsconfig `exclude` and
|
|
11
11
|
// biome's `!` override already skip them, and their imports are violations rather than real dependencies.
|
|
12
12
|
const skipDirs = ["node_modules", "dist", "build", ".git", ".next", "public", "ios", "android", "__fixtures__"];
|
|
13
|
-
const isSkippedPath = (filePath: string) =>
|
|
14
|
-
|
|
13
|
+
const isSkippedPath = (filePath: string) => {
|
|
14
|
+
const posixPath = filePath.split(path.sep).join("/");
|
|
15
|
+
return skipDirs.some((dir) => posixPath.includes(`/${dir}/`) || posixPath.startsWith(`${dir}/`));
|
|
16
|
+
};
|
|
15
17
|
const builtinModuleSet = new Set([...builtinModules, ...builtinModules.map((mod) => `node:${mod}`)]);
|
|
16
18
|
const stripShebang = (source: string) => source.replace(/^#!.*(?:\r?\n|$)/, "");
|
|
17
19
|
|
package/devkitUtils.test.ts
CHANGED
|
@@ -40,7 +40,7 @@ describe("resolveSignalTestPreloadPath", () => {
|
|
|
40
40
|
await write(path.join(root, "node_modules/akanjs/test/signalTest.preload.ts"), "export {};\n");
|
|
41
41
|
|
|
42
42
|
await expect(resolveSignalTestPreloadPath({ cwdPath: libDir })).resolves.toContain(
|
|
43
|
-
"node_modules/akanjs/test/signalTest.preload.ts",
|
|
43
|
+
path.join("node_modules/akanjs/test/signalTest.preload.ts"),
|
|
44
44
|
);
|
|
45
45
|
});
|
|
46
46
|
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
//? The Windows VM runs as an administrator, so a refused link only ever happens here — injected, on every OS.
|
|
7
|
+
const realSymlink = fsPromises.symlink;
|
|
8
|
+
let refuseLinks = false;
|
|
9
|
+
mock.module("node:fs/promises", () => ({
|
|
10
|
+
...fsPromises,
|
|
11
|
+
symlink: async (...args: Parameters<typeof fsPromises.symlink>) => {
|
|
12
|
+
if (refuseLinks)
|
|
13
|
+
throw Object.assign(new Error(`EPERM: operation not permitted, symlink '${String(args[0])}'`), {
|
|
14
|
+
code: "EPERM",
|
|
15
|
+
});
|
|
16
|
+
return await realSymlink(...args);
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
const { AppExecutor, WorkspaceExecutor } = await import("./executors");
|
|
20
|
+
|
|
21
|
+
const { lstat, mkdir, mkdtemp, readFile, rm, writeFile } = fsPromises;
|
|
22
|
+
const originalEnv = { ...process.env };
|
|
23
|
+
const originalPlatform = process.platform;
|
|
24
|
+
const tempRoots: string[] = [];
|
|
25
|
+
const PAGE_SOURCE = "export default function Page() {\n return null;\n}\n";
|
|
26
|
+
|
|
27
|
+
const setPlatform = (platform: NodeJS.Platform) =>
|
|
28
|
+
Object.defineProperty(process, "platform", { value: platform, configurable: true, writable: true });
|
|
29
|
+
|
|
30
|
+
// `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
|
|
31
|
+
const makeApp = async (appName: string) => {
|
|
32
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-link-fallback-"));
|
|
33
|
+
tempRoots.push(root);
|
|
34
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
35
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
36
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
37
|
+
process.env.PORT_OFFSET = "0";
|
|
38
|
+
await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" }));
|
|
39
|
+
await mkdir(path.join(root, "libs/shared/public"), { recursive: true });
|
|
40
|
+
await writeFile(path.join(root, "libs/shared/public/logo.png"), "logo");
|
|
41
|
+
await mkdir(path.join(root, "libs/shared/page/about"), { recursive: true });
|
|
42
|
+
await writeFile(path.join(root, "libs/shared/page/about/_index.tsx"), PAGE_SOURCE);
|
|
43
|
+
await mkdir(path.join(root, "apps", appName, "page"), { recursive: true });
|
|
44
|
+
await writeFile(path.join(root, "apps", appName, "akan.config.ts"), "export default { syncPageLibs: true };\n");
|
|
45
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
46
|
+
return { root, app: AppExecutor.from(workspace, appName), appRoot: path.join(root, "apps", appName) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
process.env = { ...originalEnv };
|
|
51
|
+
refuseLinks = true;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
refuseLinks = false;
|
|
56
|
+
setPlatform(originalPlatform);
|
|
57
|
+
process.env = { ...originalEnv };
|
|
58
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("lib links the OS refuses to create", () => {
|
|
62
|
+
test("Windows gets a copy of the lib assets, and a resync replaces it without touching the lib", async () => {
|
|
63
|
+
setPlatform("win32");
|
|
64
|
+
const { root, app, appRoot } = await makeApp("fallback-assets");
|
|
65
|
+
|
|
66
|
+
await app.syncAssets(["shared"]);
|
|
67
|
+
const copied = path.join(appRoot, "public/libs/shared");
|
|
68
|
+
expect((await lstat(copied)).isSymbolicLink()).toBe(false);
|
|
69
|
+
expect(await readFile(path.join(copied, "logo.png"), "utf8")).toBe("logo");
|
|
70
|
+
|
|
71
|
+
await writeFile(path.join(root, "libs/shared/public/logo.png"), "logo-v2");
|
|
72
|
+
await app.syncAssets(["shared"]);
|
|
73
|
+
expect(await readFile(path.join(copied, "logo.png"), "utf8")).toBe("logo-v2");
|
|
74
|
+
|
|
75
|
+
await app.syncAssets([]);
|
|
76
|
+
expect(await lstat(path.join(appRoot, "public/libs")).catch(() => null)).toBeNull();
|
|
77
|
+
expect(await readFile(path.join(root, "libs/shared/public/logo.png"), "utf8")).toBe("logo-v2");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("Windows gets a copy of the lib pages that still routes, and a resync picks up a new lib route", async () => {
|
|
81
|
+
setPlatform("win32");
|
|
82
|
+
const { root, app, appRoot } = await makeApp("fallback-pages");
|
|
83
|
+
|
|
84
|
+
expect(await app.syncPages(["shared"])).toBe(true);
|
|
85
|
+
expect((await lstat(path.join(appRoot, "page/(libs)/(shared)"))).isSymbolicLink()).toBe(false);
|
|
86
|
+
expect(await app.getPageKeys({ refresh: true })).toEqual(["./(libs)/(shared)/about/_index.tsx"]);
|
|
87
|
+
|
|
88
|
+
await mkdir(path.join(root, "libs/shared/page/terms"), { recursive: true });
|
|
89
|
+
await writeFile(path.join(root, "libs/shared/page/terms/_index.tsx"), PAGE_SOURCE);
|
|
90
|
+
expect(await app.syncPages(["shared"])).toBe(true);
|
|
91
|
+
expect(await app.getPageKeys({ refresh: true })).toEqual([
|
|
92
|
+
"./(libs)/(shared)/about/_index.tsx",
|
|
93
|
+
"./(libs)/(shared)/terms/_index.tsx",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("elsewhere the refusal surfaces rather than being papered over with a copy", async () => {
|
|
98
|
+
setPlatform(originalPlatform === "win32" ? "linux" : originalPlatform);
|
|
99
|
+
const { app } = await makeApp("fallback-posix");
|
|
100
|
+
|
|
101
|
+
await expect(app.syncAssets(["shared"])).rejects.toThrow("EPERM");
|
|
102
|
+
});
|
|
103
|
+
});
|