@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { codeAgentPresets } from "akanjs/common";
|
|
4
6
|
import { makeCliTempWorkspace, tempRoots, writeText } from "../../testHelpers";
|
|
5
7
|
import { CodeAgent, type CodeAgentOptions } from "../agent/CodeAgent";
|
|
6
8
|
import { SubagentPool } from "../agent/SubagentPool";
|
|
9
|
+
import { WorkspaceTrust } from "../agent/WorkspaceTrust";
|
|
7
10
|
import { DevLogFeedback } from "../feedback/DevLogFeedback";
|
|
8
|
-
import { PreviewView } from "../feedback/PreviewView";
|
|
9
11
|
import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
|
|
10
12
|
import { AkanCodePlugins } from "./AkanCodePlugins";
|
|
11
13
|
import { AkanEditScope } from "./AkanEditScope";
|
|
@@ -20,6 +22,26 @@ import { WebToolPack } from "./WebToolPack";
|
|
|
20
22
|
const track = tempRoots();
|
|
21
23
|
const tempRoot = async () => track(await makeCliTempWorkspace()).root;
|
|
22
24
|
|
|
25
|
+
let codeHome: string;
|
|
26
|
+
let realCodeHome: string | undefined;
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
realCodeHome = process.env.AKAN_CODE_HOME;
|
|
30
|
+
codeHome = mkdtempSync(path.join(tmpdir(), "akan-code-home-"));
|
|
31
|
+
process.env.AKAN_CODE_HOME = codeHome;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
if (realCodeHome === undefined) delete process.env.AKAN_CODE_HOME;
|
|
36
|
+
else process.env.AKAN_CODE_HOME = realCodeHome;
|
|
37
|
+
rmSync(codeHome, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const approved = (root: string) => {
|
|
41
|
+
WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), false);
|
|
42
|
+
return root;
|
|
43
|
+
};
|
|
44
|
+
|
|
23
45
|
type ToolExecute = (
|
|
24
46
|
id: string,
|
|
25
47
|
params: never,
|
|
@@ -111,38 +133,18 @@ describe("TurnFeedback", () => {
|
|
|
111
133
|
});
|
|
112
134
|
|
|
113
135
|
describe("AkanVerifier.summarize", () => {
|
|
114
|
-
|
|
136
|
+
test("passes the verify report through untouched", () => {
|
|
137
|
+
const output =
|
|
138
|
+
"verify fail · a · lint ok · typecheck 1 error · 4.1s\napps/a/x.ts:1:7 TS2322 Type 'string' is not assignable to type 'number'.";
|
|
139
|
+
expect(AkanVerifier.summarize({ command: "akan verify", ok: false, exitCode: 1, output })).toBe(output);
|
|
140
|
+
});
|
|
115
141
|
|
|
116
|
-
test("
|
|
142
|
+
test("keeps only the tail of a crash that never reached the report", () => {
|
|
117
143
|
const output = [...Array(100).keys()].map((n) => `line ${n}`).join("\n");
|
|
118
|
-
const text = AkanVerifier.summarize(
|
|
119
|
-
|
|
120
|
-
ok: false,
|
|
121
|
-
scope,
|
|
122
|
-
steps: [
|
|
123
|
-
{ command: "akan lint a", ok: true, exitCode: 0, output: "" },
|
|
124
|
-
{ command: "akan typecheck a", ok: false, exitCode: 1, output },
|
|
125
|
-
],
|
|
126
|
-
},
|
|
127
|
-
{ tailLines: 5 },
|
|
128
|
-
);
|
|
129
|
-
expect(text).toContain("Verification failed: akan typecheck a");
|
|
130
|
-
expect(text).toContain("Passed: akan lint a");
|
|
144
|
+
const text = AkanVerifier.summarize({ command: "akan verify", ok: false, exitCode: 1, output });
|
|
145
|
+
expect(text).toStartWith("akan verify exited 1 before reporting:");
|
|
131
146
|
expect(text).toContain("line 99");
|
|
132
|
-
expect(text).not.toContain("line
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("says so plainly when everything passed", () => {
|
|
136
|
-
const text = AkanVerifier.summarize({
|
|
137
|
-
ok: true,
|
|
138
|
-
scope,
|
|
139
|
-
steps: [{ command: "akan lint a", ok: true, exitCode: 0, output: "" }],
|
|
140
|
-
});
|
|
141
|
-
expect(text).toContain("Verification passed");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("an untouched workspace is not a failure", () => {
|
|
145
|
-
expect(AkanVerifier.summarize({ ok: true, scope, steps: [] })).toContain("nothing to verify");
|
|
147
|
+
expect(text).not.toContain("line 79\n");
|
|
146
148
|
});
|
|
147
149
|
});
|
|
148
150
|
|
|
@@ -153,9 +155,11 @@ describe("AkanEditScope", () => {
|
|
|
153
155
|
const baseline = await AkanEditScope.baseline(root);
|
|
154
156
|
await writeText(path.join(root, "apps", "demo", "lib", "post", "post.service.ts"), "export {};\n");
|
|
155
157
|
await writeText(path.join(root, "libs", "shared", "ui", "Card.tsx"), "export const Card = () => null;\n");
|
|
158
|
+
await writeText(path.join(root, "pkgs", "@akanjs", "cli", "index.ts"), "export {};\n");
|
|
156
159
|
const scope = await AkanEditScope.since(root, baseline);
|
|
157
160
|
expect(scope.apps).toEqual(["demo"]);
|
|
158
161
|
expect(scope.libs).toEqual(["shared"]);
|
|
162
|
+
expect(scope.pkgs).toEqual(["@akanjs/cli"]);
|
|
159
163
|
expect(scope.needsSync).toBe(true);
|
|
160
164
|
expect(scope.touchesTsx).toBe(true);
|
|
161
165
|
});
|
|
@@ -165,7 +169,7 @@ describe("AkanEditScope", () => {
|
|
|
165
169
|
await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
|
|
166
170
|
const baseline = await AkanEditScope.baseline(root);
|
|
167
171
|
const scope = await AkanEditScope.since(root, baseline);
|
|
168
|
-
expect(scope).toEqual({ paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false });
|
|
172
|
+
expect(scope).toEqual({ paths: [], apps: [], libs: [], pkgs: [], needsSync: false, touchesTsx: false });
|
|
169
173
|
});
|
|
170
174
|
});
|
|
171
175
|
|
|
@@ -243,33 +247,6 @@ describe("AkanEnvKeys", () => {
|
|
|
243
247
|
});
|
|
244
248
|
});
|
|
245
249
|
|
|
246
|
-
describe("PreviewView", () => {
|
|
247
|
-
test("reports whether this Bun build can drive a headless page at all", () => {
|
|
248
|
-
expect(typeof PreviewView.available).toBe("boolean");
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test.skipIf(!PreviewView.available)(
|
|
252
|
-
"finds an empty body and a console error on a real page",
|
|
253
|
-
async () => {
|
|
254
|
-
const server = Bun.serve({
|
|
255
|
-
port: 0,
|
|
256
|
-
fetch: () =>
|
|
257
|
-
new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
|
|
258
|
-
headers: { "content-type": "text/html" },
|
|
259
|
-
}),
|
|
260
|
-
});
|
|
261
|
-
try {
|
|
262
|
-
const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
|
|
263
|
-
expect(probe.pageErrors.join(" ")).toContain("rendered empty");
|
|
264
|
-
expect(probe.consoleErrors.join(" ")).toContain("boom");
|
|
265
|
-
} finally {
|
|
266
|
-
server.stop(true);
|
|
267
|
-
}
|
|
268
|
-
},
|
|
269
|
-
30_000,
|
|
270
|
-
);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
250
|
describe("SubagentPool", () => {
|
|
274
251
|
const workspace = { workspaceRoot: "/tmp/akan" } as never;
|
|
275
252
|
|
|
@@ -519,6 +496,54 @@ describe("SubagentPool", () => {
|
|
|
519
496
|
}
|
|
520
497
|
});
|
|
521
498
|
|
|
499
|
+
test("a child runs on the model its parent runs on when the task starts", async () => {
|
|
500
|
+
const { opened, spy } = openFakeAgents([]);
|
|
501
|
+
let current = { provider: "deepseek", id: "deepseek-v4-pro" };
|
|
502
|
+
try {
|
|
503
|
+
const root = SubagentPool.extensionFor({
|
|
504
|
+
workspace,
|
|
505
|
+
cwd: "/tmp/akan",
|
|
506
|
+
parent: budgeted(1_000),
|
|
507
|
+
model: () => current,
|
|
508
|
+
depth: 0,
|
|
509
|
+
});
|
|
510
|
+
await runTask(root, "c1");
|
|
511
|
+
current = { provider: "openai", id: "gpt-5.5" };
|
|
512
|
+
await runTask(root, "c2");
|
|
513
|
+
expect(opened.map((options) => options.model)).toEqual([
|
|
514
|
+
{ provider: "deepseek", id: "deepseek-v4-pro" },
|
|
515
|
+
{ provider: "openai", id: "gpt-5.5" },
|
|
516
|
+
]);
|
|
517
|
+
} finally {
|
|
518
|
+
spy.mockRestore();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("the plugins hand their pool the owning agent's model", async () => {
|
|
523
|
+
const { opened, spy } = openFakeAgents([]);
|
|
524
|
+
try {
|
|
525
|
+
const plugins = await AkanCodePlugins.build({
|
|
526
|
+
workspace,
|
|
527
|
+
cwd: "/tmp/akan",
|
|
528
|
+
profile: budgeted(1_000),
|
|
529
|
+
apps: [],
|
|
530
|
+
canSeeImages: false,
|
|
531
|
+
model: () => ({ provider: "deepseek", id: "deepseek-v4-pro" }),
|
|
532
|
+
depth: 0,
|
|
533
|
+
currentSessionId: () => "",
|
|
534
|
+
mailbox: () => undefined,
|
|
535
|
+
});
|
|
536
|
+
await runTask(
|
|
537
|
+
plugins.extensions.find((extension) => extension.name === "akan-subagent"),
|
|
538
|
+
"c1",
|
|
539
|
+
);
|
|
540
|
+
expect(opened[0]?.model).toEqual({ provider: "deepseek", id: "deepseek-v4-pro" });
|
|
541
|
+
plugins.dispose();
|
|
542
|
+
} finally {
|
|
543
|
+
spy.mockRestore();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
522
547
|
test("a child keeps no session and cannot ask a human", () => {
|
|
523
548
|
const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
|
|
524
549
|
expect(child.session).toEqual({ store: "memory", crossSession: false });
|
|
@@ -552,8 +577,20 @@ for await (const line of console) {
|
|
|
552
577
|
return root;
|
|
553
578
|
};
|
|
554
579
|
|
|
555
|
-
test("
|
|
580
|
+
test("a server the repo declares is not started until it is approved", async () => {
|
|
556
581
|
const root = await withServer();
|
|
582
|
+
const notices: string[] = [];
|
|
583
|
+
const pack = await McpToolPack.connect({
|
|
584
|
+
workspaceRoot: root,
|
|
585
|
+
profile: codeAgentPresets.local(root),
|
|
586
|
+
onNotice: (message) => notices.push(message),
|
|
587
|
+
});
|
|
588
|
+
expect(pack).toBeUndefined();
|
|
589
|
+
expect(notices.join(" ")).toContain("not approved: echo");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("connects a declared stdio server, lists its tools and calls one", async () => {
|
|
593
|
+
const root = approved(await withServer());
|
|
557
594
|
const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.local(root) });
|
|
558
595
|
expect(pack).toBeDefined();
|
|
559
596
|
try {
|
|
@@ -580,6 +617,7 @@ for await (const line of console) {
|
|
|
580
617
|
path.join(root, ".akan", "code", "mcp.json"),
|
|
581
618
|
JSON.stringify({ mcpServers: { broken: { command: "/nonexistent/binary" } } }),
|
|
582
619
|
);
|
|
620
|
+
approved(root);
|
|
583
621
|
const notices: string[] = [];
|
|
584
622
|
const pack = await McpToolPack.connect({
|
|
585
623
|
workspaceRoot: root,
|
|
@@ -654,6 +692,38 @@ describe("WebToolPack", () => {
|
|
|
654
692
|
}
|
|
655
693
|
});
|
|
656
694
|
|
|
695
|
+
test("follows a redirect only to a host the profile reaches without asking", async () => {
|
|
696
|
+
const server: ReturnType<typeof Bun.serve> = Bun.serve({
|
|
697
|
+
port: 0,
|
|
698
|
+
fetch: (request): Response => {
|
|
699
|
+
const { pathname } = new URL(request.url);
|
|
700
|
+
if (pathname === "/out") return Response.redirect("https://outside.example/page", 302);
|
|
701
|
+
if (pathname === "/in") return Response.redirect(`http://localhost:${server.port}/final`, 302);
|
|
702
|
+
if (pathname === "/loopback") return Response.redirect(`http://127.0.0.1:${server.port}/final`, 302);
|
|
703
|
+
return new Response("<p>final page</p>");
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
try {
|
|
707
|
+
const profile = codeAgentPresets.local(root);
|
|
708
|
+
const read = async (pack: WebToolPack, path: string) =>
|
|
709
|
+
await toolsOf(pack.extension()).get("web_fetch")?.("1", {
|
|
710
|
+
url: `http://localhost:${server.port}${path}`,
|
|
711
|
+
} as never);
|
|
712
|
+
|
|
713
|
+
const local = new WebToolPack({ profile });
|
|
714
|
+
expect((await read(local, "/in"))?.content[0]?.text).toContain("final page");
|
|
715
|
+
const out = await read(local, "/out");
|
|
716
|
+
expect(out?.isError).toBe(true);
|
|
717
|
+
expect(out?.content[0]?.text).toContain("Redirected to https://outside.example/page");
|
|
718
|
+
|
|
719
|
+
const narrowed = new WebToolPack({ profile: { ...profile, network: { allowHosts: ["localhost"] } } });
|
|
720
|
+
expect((await read(narrowed, "/in"))?.content[0]?.text).toContain("final page");
|
|
721
|
+
expect((await read(narrowed, "/loopback"))?.content[0]?.text).toContain(`Redirected to http://127.0.0.1:`);
|
|
722
|
+
} finally {
|
|
723
|
+
server.stop(true);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
657
727
|
test("reads a long page a window at a time, continuing from the snapshot it first read", async () => {
|
|
658
728
|
let hits = 0;
|
|
659
729
|
const server = Bun.serve({
|
|
@@ -239,7 +239,7 @@ describe("MCP sign-in", () => {
|
|
|
239
239
|
expect(auth.resource).toBe(provider.mcpUrl);
|
|
240
240
|
expect(provider.registrations).toHaveLength(1);
|
|
241
241
|
expect(provider.registrations[0]?.redirect_uris?.[0]).toBe(auth.redirectUri);
|
|
242
|
-
expect(McpTokenStore.read(
|
|
242
|
+
expect(McpTokenStore.read(refOf())?.accessToken).toBe("access-1");
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
test("the token is what makes the server publish its tools", async () => {
|
|
@@ -270,23 +270,23 @@ describe("MCP sign-in", () => {
|
|
|
270
270
|
|
|
271
271
|
test("an expired token is refreshed without anybody being asked", async () => {
|
|
272
272
|
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
273
|
-
McpTokenStore.write(
|
|
273
|
+
McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
|
|
274
274
|
expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
|
|
275
|
-
expect(McpTokenStore.read(
|
|
275
|
+
expect(McpTokenStore.read(refOf())?.refreshToken).toBe("refresh-2");
|
|
276
276
|
});
|
|
277
277
|
|
|
278
278
|
test("a refreshed token the server gave no lifetime is kept until refused, not refreshed on every connect", async () => {
|
|
279
279
|
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
280
|
-
McpTokenStore.write(
|
|
280
|
+
McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
|
|
281
281
|
provider.refreshExpiresIn = undefined;
|
|
282
282
|
expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
|
|
283
283
|
expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
|
|
284
|
-
expect(McpTokenStore.read(
|
|
284
|
+
expect(McpTokenStore.read(refOf())?.expiresAt).toBeUndefined();
|
|
285
285
|
});
|
|
286
286
|
|
|
287
287
|
test("a refresh the server refuses reports no token rather than throwing", async () => {
|
|
288
288
|
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
289
|
-
McpTokenStore.write(
|
|
289
|
+
McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
|
|
290
290
|
provider.refreshable = false;
|
|
291
291
|
const notices: string[] = [];
|
|
292
292
|
expect(await McpSignIn.token(refOf(), (message) => notices.push(message))).toBeUndefined();
|
|
@@ -308,7 +308,7 @@ describe("MCP sign-in", () => {
|
|
|
308
308
|
try {
|
|
309
309
|
expect(pack?.status[0]).toMatchObject({ auth: "authorized" });
|
|
310
310
|
expect(pack?.toolNames).toEqual(["mcp__fake__search"]);
|
|
311
|
-
expect(McpTokenStore.read(
|
|
311
|
+
expect(McpTokenStore.read(refOf())).toMatchObject({
|
|
312
312
|
accessToken: "access-refreshed-1",
|
|
313
313
|
refreshToken: "refresh-2",
|
|
314
314
|
});
|
|
@@ -365,7 +365,7 @@ describe("MCP sign-in", () => {
|
|
|
365
365
|
expect((await search(pack)).content[0]?.text).toBe("found");
|
|
366
366
|
expect(provider.refreshes).toBe(1);
|
|
367
367
|
expect(provider.calls).toEqual([{ token: "access-refreshed-1", session: "session-2" }]);
|
|
368
|
-
expect(McpTokenStore.read(
|
|
368
|
+
expect(McpTokenStore.read(refOf())).toMatchObject({
|
|
369
369
|
accessToken: "access-refreshed-1",
|
|
370
370
|
refreshToken: "refresh-2",
|
|
371
371
|
});
|
|
@@ -405,7 +405,7 @@ describe("MCP sign-in", () => {
|
|
|
405
405
|
|
|
406
406
|
test("a session connecting with an expired token while a call is refused shares one refresh with it", async () => {
|
|
407
407
|
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
408
|
-
McpTokenStore.write(
|
|
408
|
+
McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
|
|
409
409
|
provider.refreshDelayMs = 50;
|
|
410
410
|
const tokens = await Promise.all([McpSignIn.token(refOf()), McpSignIn.retryToken(refOf(), auth.accessToken)]);
|
|
411
411
|
expect(tokens).toEqual(["access-refreshed-2", "access-refreshed-2"]);
|
|
@@ -495,9 +495,23 @@ describe("MCP sign-in", () => {
|
|
|
495
495
|
|
|
496
496
|
test("signing out forgets the token and leaves nothing behind", async () => {
|
|
497
497
|
await McpSignIn.run(refOf(), { open: browser });
|
|
498
|
-
expect(McpTokenStore.clear(
|
|
499
|
-
expect(McpTokenStore.read(
|
|
500
|
-
expect(McpTokenStore.clear(
|
|
498
|
+
expect(McpTokenStore.clear(refOf())).toBe(true);
|
|
499
|
+
expect(McpTokenStore.read(refOf())).toBeUndefined();
|
|
500
|
+
expect(McpTokenStore.clear(refOf())).toBe(false);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("signing out of a renamed server forgets the token its origin still hands it", async () => {
|
|
504
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
505
|
+
expect(McpTokenStore.read(refOf({ name: "renamed" }))?.accessToken).toBe("access-1");
|
|
506
|
+
expect(McpTokenStore.clear(refOf({ name: "renamed" }))).toBe(true);
|
|
507
|
+
expect(McpTokenStore.read(refOf({ name: "renamed" }))).toBeUndefined();
|
|
508
|
+
expect(McpTokenStore.names()).toEqual([]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("signing out by the name alone forgets a token whose server is no longer declared", async () => {
|
|
512
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
513
|
+
expect(McpTokenStore.clear({ name: "fake" })).toBe(true);
|
|
514
|
+
expect(McpTokenStore.read(refOf())).toBeUndefined();
|
|
501
515
|
});
|
|
502
516
|
});
|
|
503
517
|
|
|
@@ -514,10 +528,32 @@ describe("the token file", () => {
|
|
|
514
528
|
expect(statSync(McpTokenStore.file()).mode & 0o777).toBe(0o600);
|
|
515
529
|
});
|
|
516
530
|
|
|
531
|
+
test("a token is found by the server's origin, so the same name at another url gets none", async () => {
|
|
532
|
+
await McpSignIn.run(refOf(), { open: browser });
|
|
533
|
+
expect(await McpSignIn.token(refOf({ url: "http://127.0.0.1:9/mcp" }))).toBeUndefined();
|
|
534
|
+
expect(await McpSignIn.token(refOf({ name: "renamed" }))).toBe("access-1");
|
|
535
|
+
expect(McpTokenStore.names()).toEqual(["fake"]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("a file an older build keyed by name keeps its tokens at the origin they were minted for", async () => {
|
|
539
|
+
const auth = await McpSignIn.run(refOf(), { open: browser });
|
|
540
|
+
await Bun.write(
|
|
541
|
+
McpTokenStore.file(),
|
|
542
|
+
JSON.stringify({ servers: { fake: auth, broken: { ...auth, resource: "" } } }),
|
|
543
|
+
);
|
|
544
|
+
expect(McpTokenStore.read(refOf())?.accessToken).toBe("access-1");
|
|
545
|
+
expect(McpTokenStore.read(refOf({ url: "https://elsewhere.example/mcp" }))).toBeUndefined();
|
|
546
|
+
expect(McpTokenStore.names()).toEqual(["fake"]);
|
|
547
|
+
McpTokenStore.write(refOf(), { ...auth, accessToken: "access-rewritten" });
|
|
548
|
+
const saved = (await Bun.file(McpTokenStore.file()).json()) as { servers?: unknown; tokens: object };
|
|
549
|
+
expect(saved.servers).toBeUndefined();
|
|
550
|
+
expect(Object.keys(saved.tokens)).toEqual([`${provider.origin} ${provider.origin}`]);
|
|
551
|
+
});
|
|
552
|
+
|
|
517
553
|
test("a corrupt file costs the sign-ins and not the session", async () => {
|
|
518
554
|
await McpSignIn.run(refOf(), { open: browser });
|
|
519
555
|
await Bun.write(McpTokenStore.file(), "{ not json");
|
|
520
|
-
expect(McpTokenStore.read(
|
|
556
|
+
expect(McpTokenStore.read(refOf())).toBeUndefined();
|
|
521
557
|
expect(McpTokenStore.names()).toEqual([]);
|
|
522
558
|
});
|
|
523
559
|
});
|
|
@@ -582,7 +618,7 @@ process.exit(0);
|
|
|
582
618
|
});
|
|
583
619
|
await taken.promise;
|
|
584
620
|
const retried = McpSignIn.retryToken(refOf(), auth.accessToken);
|
|
585
|
-
McpTokenStore.write(
|
|
621
|
+
McpTokenStore.write(refOf(), { ...auth, accessToken: "access-elsewhere", refreshToken: "refresh-elsewhere" });
|
|
586
622
|
released.resolve();
|
|
587
623
|
await elsewhere;
|
|
588
624
|
expect(await retried).toBe("access-elsewhere");
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
truncateSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
3
13
|
import { tmpdir } from "node:os";
|
|
4
14
|
import path from "node:path";
|
|
5
15
|
import { akanCodePaths } from "../agent/akanCodePaths";
|
|
16
|
+
import { WorkspaceTrust } from "../agent/WorkspaceTrust";
|
|
6
17
|
import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
|
|
7
18
|
|
|
8
19
|
const roots: string[] = [];
|
|
@@ -210,3 +221,121 @@ describe("MCP server config", () => {
|
|
|
210
221
|
expect(McpServerConfig.targetOf({ name: "a", transport: "http", url: "https://x" })).toBe("https://x");
|
|
211
222
|
});
|
|
212
223
|
});
|
|
224
|
+
|
|
225
|
+
describe("workspace trust", () => {
|
|
226
|
+
const extension = (root: string, name: string, body: string) => {
|
|
227
|
+
const file = path.join(akanCodePaths.extensionsDir(root), name);
|
|
228
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
229
|
+
writeFileSync(file, body);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
afterEach(() => {
|
|
233
|
+
rmSync(akanCodePaths.trustFile(), { force: true });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a repo with no servers or extensions of its own needs no approval", () => {
|
|
237
|
+
const root = workspace();
|
|
238
|
+
McpServerConfig.add(root, "home", ["https://home.example/mcp"], "global");
|
|
239
|
+
expect(WorkspaceTrust.pending(root)).toEqual([]);
|
|
240
|
+
expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("the repo's servers and extensions wait for approval, and a remembered one stays approved", () => {
|
|
244
|
+
const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", args: ["x.js"] } } }));
|
|
245
|
+
extension(root, "hello.ts", "export default () => {};");
|
|
246
|
+
expect(WorkspaceTrust.pending(root).map((item) => item.kind)).toEqual(["mcp", "extensions"]);
|
|
247
|
+
expect(WorkspaceTrust.pending(root)[0]?.lines).toEqual(["tool: node x.js"]);
|
|
248
|
+
WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
|
|
249
|
+
expect(WorkspaceTrust.pending(root)).toEqual([]);
|
|
250
|
+
expect(Object.keys(JSON.parse(readFileSync(akanCodePaths.trustFile(), "utf8")).workspaces)).toHaveLength(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test.skipIf(process.platform === "win32")("the trust file is readable only by its owner", () => {
|
|
254
|
+
const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
|
|
255
|
+
WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
|
|
256
|
+
expect(statSync(akanCodePaths.trustFile()).mode & 0o777).toBe(0o600);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("a change to what was approved is asked about again", () => {
|
|
260
|
+
const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", args: ["x.js"] } } }));
|
|
261
|
+
extension(root, "hello.ts", "export default () => {};");
|
|
262
|
+
WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
|
|
263
|
+
writeFileSync(
|
|
264
|
+
McpServerConfig.file(root),
|
|
265
|
+
JSON.stringify({ mcpServers: { tool: { command: "node", args: ["y.js"] } } }),
|
|
266
|
+
);
|
|
267
|
+
extension(root, "nested/more.ts", "export default () => {};");
|
|
268
|
+
expect(WorkspaceTrust.pending(root).map((item) => item.kind)).toEqual(["mcp", "extensions"]);
|
|
269
|
+
expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("an approval that is not remembered lasts only for this process", () => {
|
|
273
|
+
const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
|
|
274
|
+
WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), false);
|
|
275
|
+
expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
|
|
276
|
+
expect(existsSync(akanCodePaths.trustFile())).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("an edit made through akan code keeps the approval the content had", () => {
|
|
280
|
+
const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
|
|
281
|
+
WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
|
|
282
|
+
WorkspaceTrust.carry(root, "mcp", () =>
|
|
283
|
+
McpServerConfig.add(root, "more", ["https://more.example/mcp"], "workspace"),
|
|
284
|
+
);
|
|
285
|
+
expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
|
|
286
|
+
const untrusted = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
|
|
287
|
+
WorkspaceTrust.carry(untrusted, "mcp", () => McpServerConfig.add(untrusted, "more", ["x"], "workspace"));
|
|
288
|
+
expect(WorkspaceTrust.isTrusted(untrusted, "mcp")).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a link that stays in the extensions folder is followed and checked", () => {
|
|
292
|
+
const root = workspace();
|
|
293
|
+
extension(root, "real/index.ts", "export default () => {};");
|
|
294
|
+
symlinkSync(
|
|
295
|
+
path.join(akanCodePaths.extensionsDir(root), "real"),
|
|
296
|
+
path.join(akanCodePaths.extensionsDir(root), "alias"),
|
|
297
|
+
);
|
|
298
|
+
const [item] = WorkspaceTrust.pending(root);
|
|
299
|
+
expect(item?.lines).toEqual(["alias/index.ts"]);
|
|
300
|
+
expect(item?.hash).not.toBe("");
|
|
301
|
+
WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
|
|
302
|
+
extension(root, "real/index.ts", "export default () => 1;");
|
|
303
|
+
expect(WorkspaceTrust.isTrusted(root, "extensions")).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("a link out of the extensions folder is not followed, and an approval of that folder is never remembered", () => {
|
|
307
|
+
const root = workspace();
|
|
308
|
+
const outside = workspace();
|
|
309
|
+
writeFileSync(path.join(outside, "secret-notes.ts"), "export default () => {};");
|
|
310
|
+
extension(root, "hello.ts", "export default () => {};");
|
|
311
|
+
symlinkSync(outside, path.join(akanCodePaths.extensionsDir(root), "linked"));
|
|
312
|
+
const [item] = WorkspaceTrust.pending(root);
|
|
313
|
+
expect(item?.hash).toBe("");
|
|
314
|
+
expect(item?.lines[0]).toContain("linked links out of the folder");
|
|
315
|
+
expect(item?.lines.join("\n")).not.toContain("secret-notes");
|
|
316
|
+
WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
|
|
317
|
+
expect(existsSync(akanCodePaths.trustFile())).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("an extensions folder past the file or byte cap is not checked, and its approval is never remembered", () => {
|
|
321
|
+
const many = workspace();
|
|
322
|
+
const modules = path.join(akanCodePaths.extensionsDir(many), "node_modules", "dep");
|
|
323
|
+
mkdirSync(modules, { recursive: true });
|
|
324
|
+
for (let index = 0; index <= 10_000; index++) writeFileSync(path.join(modules, `f${index}.js`), "");
|
|
325
|
+
const large = workspace();
|
|
326
|
+
extension(large, "big.js", "");
|
|
327
|
+
truncateSync(path.join(akanCodePaths.extensionsDir(large), "big.js"), 64 * 1024 * 1024 + 1);
|
|
328
|
+
for (const root of [many, large]) {
|
|
329
|
+
const [item] = WorkspaceTrust.pending(root);
|
|
330
|
+
expect(item?.hash).toBe("");
|
|
331
|
+
expect(item?.lines[0]).toContain("too many to check");
|
|
332
|
+
WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
|
|
333
|
+
}
|
|
334
|
+
expect(existsSync(akanCodePaths.trustFile())).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("a disabled server needs no approval", () => {
|
|
338
|
+
const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", disabled: true } } }));
|
|
339
|
+
expect(WorkspaceTrust.pending(root)).toEqual([]);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -436,7 +436,10 @@ export const runCommands = async (...commands: CommandCls[]) => {
|
|
|
436
436
|
reportFailure = async (failure) =>
|
|
437
437
|
await onFailure?.(failure, { usage: false, given: givenOptionsOf(allArgMetas, opt) });
|
|
438
438
|
const commandArgs = [] as unknown[];
|
|
439
|
-
if (targetMeta.targetOption.runsOnWorkspaceRoot)
|
|
439
|
+
if (targetMeta.targetOption.runsOnWorkspaceRoot) {
|
|
440
|
+
await assertCurrentDirectoryIsWorkspaceRoot();
|
|
441
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(process.cwd());
|
|
442
|
+
}
|
|
440
443
|
const workspace = WorkspaceExecutor.fromRoot();
|
|
441
444
|
const commandContext: CommandContext = { values: {} };
|
|
442
445
|
for (const argMeta of allArgMetas) {
|
|
@@ -23,14 +23,24 @@ describe("WorkspaceVersionCheck", () => {
|
|
|
23
23
|
expect(WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("sync", "demo"), await makeWorkspace({}))).toBeNull();
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("names both versions and the matching CLI with the same arguments", async () => {
|
|
26
|
+
test("names both versions and the matching CLI with the same arguments, quoted for the shell it runs in", async () => {
|
|
27
27
|
const root = await makeWorkspace({ akanjs: "3.1.0-rc.6" });
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
29
|
+
const warningOn = (os: NodeJS.Platform) => {
|
|
30
|
+
Object.defineProperty(process, "platform", { value: os });
|
|
31
|
+
return WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo", "--title", "it's"), root);
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const posix = warningOn("linux");
|
|
35
|
+
expect(posix).toContain("@akanjs/cli 3.1.0, but this workspace has akanjs 3.1.0-rc.6");
|
|
36
|
+
expect(posix).toContain(`Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title 'it'\\''s'`);
|
|
37
|
+
expect(posix).toEndWith("together: akan update");
|
|
38
|
+
expect(warningOn("win32")).toContain(
|
|
39
|
+
`Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title "it's"`,
|
|
40
|
+
);
|
|
41
|
+
} finally {
|
|
42
|
+
if (platform) Object.defineProperty(process, "platform", platform);
|
|
43
|
+
}
|
|
34
44
|
});
|
|
35
45
|
|
|
36
46
|
test("offers akan update with the CLI's prerelease tag only when the CLI is the newer one", async () => {
|
|
@@ -45,6 +45,9 @@ export class WorkspaceVersionCheck {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
static #quote(arg: string) {
|
|
48
|
-
|
|
48
|
+
if (/^[\w@%+=:,./-]+$/.test(arg)) return arg;
|
|
49
|
+
//? cmd and PowerShell take neither single quotes nor '\'' as POSIX shells do.
|
|
50
|
+
if (process.platform === "win32") return `"${arg.replaceAll('"', '\\"')}"`;
|
|
51
|
+
return `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
49
52
|
}
|
|
50
53
|
}
|