@wirecat/cli-template 0.14.0 → 0.16.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/.github/workflows/ci.yml +17 -0
- package/AGENTS.md +56 -2
- package/CHANGELOG.md +46 -0
- package/README.md +12 -4
- package/biome.json +2 -2
- package/contracts/mcp-tools.json +2 -2
- package/dist/bin/clidev.js +2 -2
- package/dist/bundle/{chunk-LU4A2T7Y.js → all-profiles-FS7AEOE7.js} +8 -5
- package/dist/bundle/{anthropic-ICTW7CNQ.js → anthropic-5RN7BGDI.js} +3 -3
- package/dist/bundle/{app-server-B6BCKXR7.js → app-server-WGTKZQKJ.js} +28 -29
- package/dist/bundle/{bun-sqlite-JE6FJFQT.js → bun-sqlite-WK2IGBGD.js} +2 -2
- package/dist/bundle/{chunk-AS2PJNQ2.js → chunk-2MWNE4L3.js} +1 -1
- package/dist/bundle/{chunk-2DAXUNTS.js → chunk-5HHIPDNW.js} +1 -1
- package/dist/bundle/{chunk-YOJLIVGO.js → chunk-5KAYQKBK.js} +1 -1
- package/dist/bundle/{chunk-62JRF2JA.js → chunk-6WOJIAYA.js} +1 -1
- package/dist/bundle/chunk-7EUX7AZV.js +141 -0
- package/dist/bundle/{chunk-7F5VMLVK.js → chunk-ADFSSIV4.js} +1 -1
- package/dist/bundle/{chunk-U6QD3J3F.js → chunk-ATHZEV2Z.js} +2 -2
- package/dist/bundle/{chunk-UT5TQ6MD.js → chunk-BPMKAZSS.js} +8 -8
- package/dist/bundle/{chunk-DZLHY4IA.js → chunk-C3GNOEJP.js} +1 -1
- package/dist/bundle/{chunk-PIJUGF5F.js → chunk-CTBHQRX6.js} +1 -1
- package/dist/bundle/{chunk-LFLSV353.js → chunk-DEHBTFIC.js} +1 -1
- package/dist/bundle/{chunk-NVNUUHT5.js → chunk-EA3TMI4A.js} +8 -8
- package/dist/bundle/{chunk-BEBD27QT.js → chunk-EILS4BTQ.js} +16 -18
- package/dist/bundle/{chunk-BVLPNNEL.js → chunk-ENDBOYQR.js} +14 -14
- package/dist/bundle/{chunk-6AW4DDK4.js → chunk-ETJXPB6A.js} +1 -1
- package/dist/bundle/{chunk-LATYBJ4T.js → chunk-FMO6F5Q2.js} +1 -1
- package/dist/bundle/{chunk-RJ7AS7UB.js → chunk-HTYLT6ME.js} +24 -7
- package/dist/bundle/{chunk-F34HGYWF.js → chunk-JFXWLQ2L.js} +1 -1
- package/dist/bundle/{chunk-IZKXJJ7V.js → chunk-JJZCPBH3.js} +29 -6
- package/dist/bundle/{chunk-G7W53MQP.js → chunk-LRHX62ZF.js} +2 -2
- package/dist/bundle/{chunk-SOYOOU2D.js → chunk-MBBOINK4.js} +2 -2
- package/dist/bundle/{chunk-BM5ZCDPQ.js → chunk-MEXT6XDP.js} +2 -2
- package/dist/bundle/{chunk-R2JKPHH5.js → chunk-NVPWZF67.js} +1 -1
- package/dist/bundle/{chunk-45244QQ6.js → chunk-OF56L2JU.js} +2 -2
- package/dist/bundle/{chunk-4PA6DJ7F.js → chunk-OGTRPW2K.js} +3 -3
- package/dist/bundle/{chunk-VXOR76ZL.js → chunk-ORV4EGNC.js} +1 -1
- package/dist/bundle/{chunk-TJJ2XSJP.js → chunk-QGOOPPEW.js} +2 -2
- package/dist/bundle/{chunk-NH6LZ3H3.js → chunk-RFJKNFVF.js} +3 -3
- package/dist/bundle/{chunk-R5RYPD23.js → chunk-RYOAQCVJ.js} +1 -1
- package/dist/bundle/{chunk-66VMJGV6.js → chunk-SKBRW5U3.js} +1 -1
- package/dist/bundle/{chunk-BWBZCYUN.js → chunk-TXHCL75B.js} +43 -6
- package/dist/bundle/{chunk-QXOSPYM3.js → chunk-VPCXYY46.js} +1 -1
- package/dist/bundle/{chunk-IOJ2DTCY.js → chunk-WDWP5N6M.js} +2 -0
- package/dist/bundle/{chunk-HYTHTSJP.js → chunk-WM5AMTRY.js} +4 -4
- package/dist/bundle/{chunk-ASHVHS52.js → chunk-YNROUY3R.js} +10 -10
- package/dist/bundle/{chunk-DQIBGCF3.js → chunk-Z5NL6YNU.js} +2 -2
- package/dist/bundle/{chunk-5SAF3XOL.js → chunk-ZSY4WAGI.js} +28 -21
- package/dist/bundle/clidev.js +3 -3
- package/dist/bundle/{command-contract-JMVMT5HT.js → command-contract-ZDU73EPH.js} +9 -9
- package/dist/bundle/{decode-NXB5UKMO.js → decode-QMLOGZ6R.js} +2 -2
- package/dist/bundle/{embed-IKZ24BMC.js → embed-PIR2LJR2.js} +4 -4
- package/dist/bundle/{embedder-GMQ24DOQ.js → embedder-6KBRPAEQ.js} +4 -4
- package/dist/bundle/{first-run-MDD4SIUK.js → first-run-JO3CMY3Z.js} +7 -7
- package/dist/bundle/{first-screen-376CXGXJ.js → first-screen-3T5IXTFL.js} +10 -10
- package/dist/bundle/{node-sqlite-73Z4SFOA.js → node-sqlite-LIK6OYNF.js} +2 -2
- package/dist/bundle/{oauth-EVTPAMZP.js → oauth-7FVF7HQQ.js} +4 -4
- package/dist/bundle/{open-46WZAPJ3.js → open-CRZDEATN.js} +2 -2
- package/dist/bundle/{openai-GWRDAHDU.js → openai-WAJ6WREQ.js} +11 -32
- package/dist/bundle/{program-B3CY3FP7.js → program-ZFFFPBRS.js} +2263 -300
- package/dist/bundle/{prompt-IVEYRLS5.js → prompt-QQIVHO3M.js} +3 -3
- package/dist/bundle/{recognize-WVEKHODM.js → recognize-4KLBZDEC.js} +2 -2
- package/dist/bundle/{remote-4K5VPMC7.js → remote-BABMZTHA.js} +5 -5
- package/dist/bundle/{rerank-V24UJZYJ.js → rerank-P3IQQJIL.js} +4 -4
- package/dist/bundle/{chunk-EMHCXXNJ.js → serve-25SSAKRZ.js} +6 -4
- package/dist/bundle/server-G4GCDMYI.js +52 -0
- package/dist/bundle/{chunk-PX2KP4QE.js → server-P4XUYY5A.js} +27 -7
- package/dist/bundle/{state-ZPYGECVP.js → state-QCQALQBJ.js} +3 -3
- package/dist/bundle/{store-6JQCLPQF.js → store-ZECPRGGH.js} +7 -7
- package/dist/bundle/store-maintenance-command-ECUO5G6L.js +42 -0
- package/dist/commands/comments.d.ts +3 -0
- package/dist/commands/comments.d.ts.map +1 -0
- package/dist/commands/comments.js +114 -0
- package/dist/commands/comments.js.map +1 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +96 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +83 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +58 -0
- package/dist/context.js.map +1 -0
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +26 -5
- package/dist/program.js.map +1 -1
- package/dist/sample/comments.d.ts +61 -0
- package/dist/sample/comments.d.ts.map +1 -0
- package/dist/sample/comments.js +39 -0
- package/dist/sample/comments.js.map +1 -0
- package/dist/sample/transport.d.ts +42 -0
- package/dist/sample/transport.d.ts.map +1 -0
- package/dist/sample/transport.js +152 -0
- package/dist/sample/transport.js.map +1 -0
- package/dist/token.d.ts +16 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +32 -0
- package/dist/token.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/commands.md +234 -12
- package/docs/configuration.md +1 -1
- package/docs/dev/reference-clis.md +3 -2
- package/docs/dev/test-matrix.md +36 -5
- package/docs/index.md +4 -3
- package/docs/security.md +4 -2
- package/kit/dist/new.js +6 -3
- package/lint/overrides.json +9 -1
- package/package.json +3 -4
- package/scripts/test-matrix-untested.ts +19 -0
- package/skills/clidev-cli/SKILL.md +1 -1
- package/src/commands/comments.ts +167 -0
- package/src/commands/session.ts +144 -0
- package/src/comments.test.ts +258 -0
- package/src/config.ts +5 -2
- package/src/context.ts +78 -0
- package/src/mcp.test.ts +71 -76
- package/src/program.test.ts +39 -17
- package/src/program.ts +30 -5
- package/src/sample/comments.ts +51 -0
- package/src/sample/transport.test.ts +117 -0
- package/src/sample/transport.ts +184 -0
- package/src/testing/fake-comments.ts +167 -0
- package/src/testing/harness.ts +51 -0
- package/src/token.ts +42 -0
- package/src/version.ts +1 -1
- package/dist/bundle/all-profiles-NVB2HT2S.js +0 -24
- package/dist/bundle/chunk-GUFPQZUA.js +0 -48
- package/dist/bundle/chunk-NL2DQPT7.js +0 -2
- package/dist/bundle/mcp-KHUZOJO6.js +0 -82
- package/dist/bundle/mcp-MZHWJXAE.js +0 -152
- package/dist/bundle/serve-PJWSOV7F.js +0 -19
- package/dist/bundle/server-2ZN7KZHZ.js +0 -40
- package/dist/bundle/server-5VYM7CCF.js +0 -53
- package/dist/bundle/store-maintenance-command-7XICIYHW.js +0 -42
- package/dist/commands/doctor.d.ts +0 -77
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -74
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/mcp.d.ts +0 -3
- package/dist/commands/mcp.d.ts.map +0 -1
- package/dist/commands/mcp.js +0 -97
- package/dist/commands/mcp.js.map +0 -1
- package/dist/mcp.d.ts +0 -29
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js +0 -98
- package/dist/mcp.js.map +0 -1
- package/src/commands/doctor.ts +0 -86
- package/src/commands/mcp.ts +0 -130
- package/src/mcp-http.test.ts +0 -172
- package/src/mcp.ts +0 -140
package/src/mcp.test.ts
CHANGED
|
@@ -1,88 +1,83 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { RunOptions } from "@wirecat/cli-core/cli"
|
|
4
|
+
import { mcpOverStdio } from "@wirecat/cli-core/testing"
|
|
5
|
+
import { expect, it } from "vitest"
|
|
6
|
+
import type { ServiceEnvironment } from "./context.js"
|
|
7
|
+
import { IDENTITY } from "./identity.js"
|
|
8
|
+
import { run } from "./program.js"
|
|
9
|
+
import { fakeComments } from "./testing/fake-comments.js"
|
|
10
|
+
import { runCli, signedIn } from "./testing/harness.js"
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
it("mcp serve offers search, read and write, and runs a command through the CLI", async () => {
|
|
13
|
+
const mcp = await mcpOverStdio((streams) => run(["personal", "mcp", "serve"], { tty: false, ...streams }))
|
|
14
|
+
try {
|
|
15
|
+
expect((await mcp.tools()).sort()).toEqual(["clidev_read", "clidev_tools_search", "clidev_write"])
|
|
16
|
+
const doctor = await mcp.call("clidev_read", { command: "doctor" })
|
|
17
|
+
expect(doctor.body).toMatchObject({ item: { version: IDENTITY.version } })
|
|
18
|
+
} finally {
|
|
19
|
+
expect(await mcp.close()).toBe(0)
|
|
20
|
+
}
|
|
13
21
|
})
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
run,
|
|
23
|
-
})
|
|
24
|
-
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair()
|
|
25
|
-
await server.connect(serverSide)
|
|
26
|
-
const client = new Client({ name: "test", version: "1.0.0" })
|
|
27
|
-
await client.connect(clientSide)
|
|
28
|
-
clients.push(client)
|
|
29
|
-
return client
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
33
|
-
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
34
|
-
const structured = (result: unknown) => {
|
|
35
|
-
if (!isRecord(result) || !isRecord(result.structuredContent))
|
|
36
|
-
throw new Error("the tool answered without structured content")
|
|
37
|
-
return result.structuredContent
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
it("offers search, read and write", async () => {
|
|
41
|
-
const client = await connect()
|
|
42
|
-
const { tools } = await client.listTools()
|
|
43
|
-
expect(tools.map((tool) => tool.name).sort()).toEqual(["clidev_read", "clidev_tools_search", "clidev_write"])
|
|
23
|
+
it("mcp serve refuses a --permission that names no command, and a --profiles this computer lacks", async () => {
|
|
24
|
+
const unknown = await runCli(["personal", "mcp", "serve", "--permission", "comments.nope=deny"])
|
|
25
|
+
expect(unknown.code).toBe(2)
|
|
26
|
+
expect(unknown.err).toContain("comments.nope")
|
|
27
|
+
const missing = await runCli(["mcp", "serve", "--profiles", "nope"])
|
|
28
|
+
expect(missing.code).toBe(2)
|
|
29
|
+
expect(missing.err).toContain("nope")
|
|
44
30
|
})
|
|
45
31
|
|
|
46
|
-
it("
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
expect(
|
|
50
|
-
|
|
32
|
+
it("mcp config prints an entry that serves the named profile, or every profile only when told", async () => {
|
|
33
|
+
const one = await runCli(["personal", "mcp", "config", "--permission", "comments.create=deny", "--json"])
|
|
34
|
+
const entry = one.json().item as { config: { mcpServers: Record<string, { args: string[] }> } }
|
|
35
|
+
expect(entry.config.mcpServers["clidev-personal"]?.args.slice(-5)).toEqual([
|
|
36
|
+
"personal",
|
|
37
|
+
"mcp",
|
|
38
|
+
"serve",
|
|
39
|
+
"--permission",
|
|
40
|
+
"comments.create=deny",
|
|
41
|
+
])
|
|
42
|
+
const all = await runCli(["mcp", "config", "--profiles", "all", "--json"])
|
|
43
|
+
expect(JSON.stringify(all.json())).toContain('"--profiles","all"')
|
|
51
44
|
})
|
|
52
45
|
|
|
53
|
-
it("
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
expect(
|
|
46
|
+
it("mcp revoke answers for the profile it names", async () => {
|
|
47
|
+
const revoked = await runCli(["personal", "mcp", "revoke", "--json"])
|
|
48
|
+
expect(revoked.code).toBe(0)
|
|
49
|
+
expect(revoked.json().item).toMatchObject({ profile: "personal" })
|
|
57
50
|
})
|
|
58
51
|
|
|
59
|
-
it("
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const serving = serveMcp({
|
|
64
|
-
profile: "personal",
|
|
65
|
-
commands: describeProgram(program),
|
|
66
|
-
levels: () => ({}),
|
|
67
|
-
authority: () => false,
|
|
68
|
-
run,
|
|
69
|
-
signal: stop.signal,
|
|
70
|
-
transport: serverSide,
|
|
71
|
-
})
|
|
72
|
-
const client = new Client({ name: "test", version: "1.0.0" })
|
|
73
|
-
await client.connect(clientSide)
|
|
74
|
-
expect((await client.listTools()).tools).toHaveLength(3)
|
|
75
|
-
stop.abort()
|
|
76
|
-
await expect(serving).resolves.toBeUndefined()
|
|
52
|
+
it("doctor --online answers even when npm cannot be reached", async () => {
|
|
53
|
+
const online = await runCli(["doctor", "--online", "--json"])
|
|
54
|
+
expect(online.code).toBe(0)
|
|
55
|
+
expect(online.json().item).toMatchObject({ version: IDENTITY.version, online: expect.any(Object) })
|
|
77
56
|
})
|
|
78
57
|
|
|
79
|
-
it("
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
|
|
58
|
+
it("a denied command is offered as unavailable and refused before any request (WC-PERM-23)", async () => {
|
|
59
|
+
const file = join(process.env.CLIDEV_CONFIG_DIR ?? "", "config.json")
|
|
60
|
+
mkdirSync(join(file, ".."), { recursive: true })
|
|
61
|
+
writeFileSync(file, JSON.stringify({ profiles: { personal: { permissions: { "comments.list": "deny" } } } }))
|
|
62
|
+
const service = fakeComments()
|
|
63
|
+
const options: RunOptions & ServiceEnvironment = { tty: false, keyring: signedIn(), fetch: service.fetch }
|
|
64
|
+
const mcp = await mcpOverStdio((streams) => run(["personal", "mcp", "serve"], { ...options, ...streams }))
|
|
65
|
+
try {
|
|
66
|
+
const found = await mcp.call("clidev_tools_search", { query: "comments list" })
|
|
67
|
+
expect(found.body).toMatchObject({
|
|
68
|
+
items: expect.arrayContaining([
|
|
69
|
+
expect.objectContaining({
|
|
70
|
+
command: "comments list",
|
|
71
|
+
availability: expect.objectContaining({ available: false }),
|
|
72
|
+
}),
|
|
73
|
+
]),
|
|
74
|
+
})
|
|
75
|
+
const listed = await mcp.call("clidev_read", { command: "comments list" })
|
|
76
|
+
expect(listed.isError).toBe(true)
|
|
77
|
+
expect(JSON.stringify(listed.body)).toContain("permission_error")
|
|
78
|
+
expect(service.calls).toEqual([])
|
|
79
|
+
} finally {
|
|
80
|
+
expect(await mcp.close()).toBe(0)
|
|
81
|
+
rmSync(file, { force: true })
|
|
82
|
+
}
|
|
88
83
|
})
|
package/src/program.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { join } from "node:path"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { annotate } from "@wirecat/cli-base/commands"
|
|
4
|
+
import { REMOTE_READ, run as runShared } from "@wirecat/cli-core/cli"
|
|
5
|
+
import { Command } from "commander"
|
|
6
|
+
import { expect, it } from "vitest"
|
|
6
7
|
import { IDENTITY } from "./identity.js"
|
|
7
|
-
import { run } from "./program.js"
|
|
8
|
+
import { definition, run } from "./program.js"
|
|
8
9
|
|
|
9
10
|
const ran = async (argv: string[]) => {
|
|
10
11
|
const out: string[] = []
|
|
@@ -66,20 +67,15 @@ it("doctor reports an unknown setting with the command that removes it", async (
|
|
|
66
67
|
)
|
|
67
68
|
})
|
|
68
69
|
|
|
69
|
-
it("doctor reports settings an environment variable breaks, and still answers", () => {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const spy = vi.spyOn(configuration, "resolveSettings").mockImplementation(() => {
|
|
76
|
-
throw new TypeError("boom")
|
|
70
|
+
it("doctor reports settings an environment variable breaks, and still answers", async () => {
|
|
71
|
+
const out: string[] = []
|
|
72
|
+
const code = await run(["doctor", "--json"], {
|
|
73
|
+
streams: { data: (text) => out.push(text), diagnostic: () => {} },
|
|
74
|
+
tty: false,
|
|
75
|
+
env: { ...process.env, CLIDEV_EMBEDDING_DIMS: "many" },
|
|
77
76
|
})
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
} finally {
|
|
81
|
-
spy.mockRestore()
|
|
82
|
-
}
|
|
77
|
+
expect(code).toBe(0)
|
|
78
|
+
expect(json(out.join("")).item).toMatchObject({ config: { issues: [] }, version: IDENTITY.version })
|
|
83
79
|
})
|
|
84
80
|
|
|
85
81
|
it.skipIf(process.platform === "win32")(
|
|
@@ -233,3 +229,29 @@ it("config repair --dry-run changes nothing, and config repair fixes the JSON wi
|
|
|
233
229
|
if (backup) rmSync(backup)
|
|
234
230
|
}
|
|
235
231
|
})
|
|
232
|
+
|
|
233
|
+
it("a deny in the profile stops a read command before its action runs", async () => {
|
|
234
|
+
const reached: string[] = []
|
|
235
|
+
const probe = annotate(new Command("probe"), { effects: REMOTE_READ, offline: false }).action(() => {
|
|
236
|
+
reached.push("probe")
|
|
237
|
+
})
|
|
238
|
+
const withProbe = { ...definition(), commands: () => [...definition().commands(), probe] }
|
|
239
|
+
const dir = process.env.CLIDEV_CONFIG_DIR ?? ""
|
|
240
|
+
mkdirSync(dir, { recursive: true })
|
|
241
|
+
writeFileSync(
|
|
242
|
+
join(dir, "config.json"),
|
|
243
|
+
JSON.stringify({ profiles: { personal: { permissions: { probe: "deny" } } } }),
|
|
244
|
+
)
|
|
245
|
+
const err: string[] = []
|
|
246
|
+
try {
|
|
247
|
+
const code = await runShared(["personal", "probe", "--json"], withProbe, {
|
|
248
|
+
streams: { data: () => {}, diagnostic: (text) => err.push(text) },
|
|
249
|
+
tty: false,
|
|
250
|
+
})
|
|
251
|
+
expect(code).not.toBe(0)
|
|
252
|
+
expect(err.join("\n")).toContain("permission_error")
|
|
253
|
+
expect(reached).toEqual([])
|
|
254
|
+
} finally {
|
|
255
|
+
rmSync(join(dir, "config.json"))
|
|
256
|
+
}
|
|
257
|
+
})
|
package/src/program.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
|
-
import { resolvePaths } from "@wirecat/cli-base"
|
|
2
|
+
import { type KeyringStore, resolvePaths } from "@wirecat/cli-base"
|
|
3
3
|
import {
|
|
4
|
+
appDoctorCommand,
|
|
5
|
+
appMcpCommand,
|
|
4
6
|
commandsCommand,
|
|
5
7
|
configCommand,
|
|
6
8
|
createProgram as createSharedProgram,
|
|
9
|
+
environmentOf,
|
|
7
10
|
outputFor,
|
|
8
11
|
type ProgramDefinition,
|
|
9
12
|
type ProgramOptions,
|
|
@@ -15,10 +18,12 @@ import {
|
|
|
15
18
|
} from "@wirecat/cli-core/cli"
|
|
16
19
|
import { appUpdate } from "@wirecat/cli-core/update"
|
|
17
20
|
import type { Command } from "commander"
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
21
|
+
import { commentsCommand } from "./commands/comments.js"
|
|
22
|
+
import { sessionCommand } from "./commands/session.js"
|
|
20
23
|
import { configuration } from "./config.js"
|
|
21
24
|
import { IDENTITY, PACKAGE, SKILL } from "./identity.js"
|
|
25
|
+
import { SERVICE } from "./sample/transport.js"
|
|
26
|
+
import { holdsToken } from "./token.js"
|
|
22
27
|
|
|
23
28
|
const update = appUpdate({
|
|
24
29
|
packageName: PACKAGE,
|
|
@@ -31,12 +36,32 @@ const update = appUpdate({
|
|
|
31
36
|
offVariables: [`${IDENTITY.envPrefix}_NO_UPDATE_CHECK`],
|
|
32
37
|
})
|
|
33
38
|
|
|
39
|
+
const mcpCommand = () => {
|
|
40
|
+
// cli-core asks `signedIn` with no keyring; the run's own one, a test's `memoryKeyring()`, is taken here. Each
|
|
41
|
+
// program builds its own `mcp`, so a tool call run beside it never swaps the server's keyring (WC-ARCH-22).
|
|
42
|
+
let keyring: KeyringStore | undefined
|
|
43
|
+
const mcp = appMcpCommand(IDENTITY, configuration, {
|
|
44
|
+
name: SERVICE,
|
|
45
|
+
// SAMPLE: name the kinds of text other people wrote that your answers carry (WC-MCP-11).
|
|
46
|
+
untrusted: ["comment text", "author names"],
|
|
47
|
+
permissionExample: "comments.create=deny",
|
|
48
|
+
skill: SKILL,
|
|
49
|
+
signedIn: (profile, env) => holdsToken(env, profile, keyring),
|
|
50
|
+
})
|
|
51
|
+
return mcp.hook("preAction", (_mcp, action) => {
|
|
52
|
+
keyring = environmentOf(action).keyring
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
export const definition = (): ProgramDefinition => ({
|
|
35
57
|
app: IDENTITY,
|
|
36
|
-
|
|
58
|
+
// The whole object: cli-core refuses a command by its key only when it gets the permission table too.
|
|
59
|
+
configuration,
|
|
37
60
|
commands: () => [
|
|
38
61
|
commandsCommand(IDENTITY),
|
|
39
|
-
|
|
62
|
+
sessionCommand(),
|
|
63
|
+
commentsCommand(),
|
|
64
|
+
appDoctorCommand(IDENTITY, configuration, { packageName: PACKAGE }),
|
|
40
65
|
skillCommand(IDENTITY, SKILL),
|
|
41
66
|
mcpCommand(),
|
|
42
67
|
upgradeCommand(IDENTITY, PACKAGE, (command) => ({
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as v from "valibot"
|
|
2
|
+
import type { Transport } from "./transport.js"
|
|
3
|
+
|
|
4
|
+
// SAMPLE: replace these shapes with your service's. Each wire name comes from the service's docs or a captured answer,
|
|
5
|
+
// never from memory; the strict fake in src/testing/fake-comments.ts refuses any other.
|
|
6
|
+
const wireComment = v.object({
|
|
7
|
+
id: v.string(),
|
|
8
|
+
body: v.string(),
|
|
9
|
+
authorName: v.string(),
|
|
10
|
+
createdAt: v.string(),
|
|
11
|
+
})
|
|
12
|
+
const wirePage = v.object({ comments: v.array(wireComment), hasMore: v.boolean() })
|
|
13
|
+
const wireUser = v.object({ id: v.string(), name: v.string() })
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The answer's own shape, in snake_case (WC-OUT-12). `answerEntity` publishes it, so `--json=id,text` and the MCP
|
|
17
|
+
* schema both come from here (WC-CMD-19). No wire type leaves this file (WC-ARCH-3).
|
|
18
|
+
*/
|
|
19
|
+
export const commentSchema = v.strictObject({
|
|
20
|
+
id: v.string(),
|
|
21
|
+
text: v.string(),
|
|
22
|
+
author: v.string(),
|
|
23
|
+
created_at: v.string(),
|
|
24
|
+
})
|
|
25
|
+
export type Comment = v.InferOutput<typeof commentSchema>
|
|
26
|
+
|
|
27
|
+
export const commentOf = (wire: v.InferOutput<typeof wireComment>): Comment => ({
|
|
28
|
+
id: wire.id,
|
|
29
|
+
text: wire.body,
|
|
30
|
+
author: wire.authorName,
|
|
31
|
+
created_at: wire.createdAt,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Every call the sample makes: a method, a path template and only the fields the service documents. */
|
|
35
|
+
export const commentsService = (api: Transport) => ({
|
|
36
|
+
me: () => api.send({ method: "GET", path: "me" }, wireUser),
|
|
37
|
+
list: async ({ limit, offset, author }: { limit: number; offset: number; author?: string | undefined }) => {
|
|
38
|
+
const page = await api.send({ method: "GET", path: "comments", query: { limit, offset, by: author } }, wirePage)
|
|
39
|
+
return { items: page.comments.map(commentOf), has_more: page.hasMore }
|
|
40
|
+
},
|
|
41
|
+
show: async (id: string) =>
|
|
42
|
+
commentOf(await api.send({ method: "GET", path: "comments/{id}", ids: { id } }, wireComment)),
|
|
43
|
+
create: async (text: string) =>
|
|
44
|
+
commentOf(await api.send({ method: "POST", path: "comments", body: { body: text } }, wireComment)),
|
|
45
|
+
update: async (id: string, text: string) =>
|
|
46
|
+
commentOf(
|
|
47
|
+
await api.send({ method: "PATCH", path: "comments/{id}", ids: { id }, body: { body: text } }, wireComment),
|
|
48
|
+
),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export type CommentsService = ReturnType<typeof commentsService>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Pacer } from "@wirecat/cli-core/sends"
|
|
2
|
+
import * as v from "valibot"
|
|
3
|
+
import { expect, it } from "vitest"
|
|
4
|
+
import { FAKE_TOKEN, fakeComments } from "../testing/fake-comments.js"
|
|
5
|
+
import { commentOf, commentsService } from "./comments.js"
|
|
6
|
+
import { failureOf, transport } from "./transport.js"
|
|
7
|
+
|
|
8
|
+
const anything = v.unknown()
|
|
9
|
+
const failing = (error: unknown) => async () => Promise.reject(error)
|
|
10
|
+
const timedOut = () => new DOMException("timed out", "TimeoutError")
|
|
11
|
+
|
|
12
|
+
it("maps the wire shape to the answer's own", () => {
|
|
13
|
+
expect(commentOf({ id: "c1", body: "Hi", authorName: "Test Person", createdAt: "2026-01-02T03:04:05Z" })).toEqual({
|
|
14
|
+
id: "c1",
|
|
15
|
+
text: "Hi",
|
|
16
|
+
author: "Test Person",
|
|
17
|
+
created_at: "2026-01-02T03:04:05Z",
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
[401, false, "authentication_error"],
|
|
23
|
+
[404, false, "not_found"],
|
|
24
|
+
[429, true, "rate_limited"],
|
|
25
|
+
[503, false, "provider_unavailable"],
|
|
26
|
+
[503, true, "outcome_unknown"],
|
|
27
|
+
[400, true, "validation_error"],
|
|
28
|
+
])("HTTP %i (a write: %s) is %s", (status, writes, code) => {
|
|
29
|
+
expect(failureOf(status, writes).code).toBe(code)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("a write that failed on the service is never marked safe to retry", () => {
|
|
33
|
+
expect(failureOf(500, true).details).toMatchObject({ retryable: false })
|
|
34
|
+
expect(failureOf(429, false, 2000).details).toMatchObject({ retryable: true, retry_after_ms: 2000 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("sends the token in the Authorization header only, and refuses a redirect", async () => {
|
|
38
|
+
const service = fakeComments()
|
|
39
|
+
const sent: RequestInit[] = []
|
|
40
|
+
await commentsService(
|
|
41
|
+
transport({
|
|
42
|
+
token: FAKE_TOKEN,
|
|
43
|
+
fetch: (input, init = {}) => {
|
|
44
|
+
sent.push(init)
|
|
45
|
+
return service.fetch(input, init)
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
).me()
|
|
49
|
+
expect(service.calls[0]?.url).not.toContain(FAKE_TOKEN)
|
|
50
|
+
expect(service.calls[0]?.headers.authorization).toBe(`Bearer ${FAKE_TOKEN}`)
|
|
51
|
+
expect(sent[0]?.redirect).toBe("error")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('refuses an id of "." or ".." before any request, since it would reach another endpoint', async () => {
|
|
55
|
+
const service = fakeComments()
|
|
56
|
+
const comments = commentsService(transport({ token: FAKE_TOKEN, fetch: service.fetch }))
|
|
57
|
+
await expect(comments.show("..")).rejects.toMatchObject({ code: "validation_error" })
|
|
58
|
+
expect(service.calls).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("a read that got no answer is a retryable timeout or network error; a write is outcome_unknown", async () => {
|
|
62
|
+
const send = (error: unknown, method: "GET" | "POST") =>
|
|
63
|
+
transport({ token: FAKE_TOKEN, fetch: failing(error) }).send({ method, path: "comments", body: {} }, anything)
|
|
64
|
+
await expect(send(timedOut(), "GET")).rejects.toMatchObject({ code: "timeout", details: { retryable: true } })
|
|
65
|
+
await expect(send(new TypeError("fetch failed"), "GET")).rejects.toMatchObject({ code: "network_error" })
|
|
66
|
+
await expect(send(timedOut(), "POST")).rejects.toMatchObject({
|
|
67
|
+
code: "outcome_unknown",
|
|
68
|
+
details: { retryable: false },
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("a stop during a read cancels it; during a write it stays outcome_unknown", async () => {
|
|
73
|
+
const stop = new AbortController()
|
|
74
|
+
stop.abort()
|
|
75
|
+
const send = (method: "GET" | "PATCH") =>
|
|
76
|
+
transport({ token: FAKE_TOKEN, fetch: failing(new Error("aborted")), signal: stop.signal }).send(
|
|
77
|
+
{ method, path: "comments" },
|
|
78
|
+
anything,
|
|
79
|
+
)
|
|
80
|
+
await expect(send("GET")).rejects.toMatchObject({ code: "cancelled" })
|
|
81
|
+
await expect(send("PATCH")).rejects.toMatchObject({ code: "outcome_unknown" })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("an answer that is not JSON is invalid_response", async () => {
|
|
85
|
+
const fetch = async () => new Response("<html>", { status: 200 })
|
|
86
|
+
await expect(commentsService(transport({ token: FAKE_TOKEN, fetch })).me()).rejects.toMatchObject({
|
|
87
|
+
code: "invalid_response",
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("an answer in another shape is invalid_response", async () => {
|
|
92
|
+
const fetch = async () => new Response(JSON.stringify({ unexpected: true }), { status: 200 })
|
|
93
|
+
await expect(commentsService(transport({ token: FAKE_TOKEN, fetch })).me()).rejects.toMatchObject({
|
|
94
|
+
code: "invalid_response",
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("waits its turn under the profile's pace, and ends the run instead of a long wait", async () => {
|
|
99
|
+
const service = fakeComments()
|
|
100
|
+
const waits: number[] = []
|
|
101
|
+
const paced = (at: number) =>
|
|
102
|
+
commentsService(
|
|
103
|
+
transport({
|
|
104
|
+
token: FAKE_TOKEN,
|
|
105
|
+
fetch: service.fetch,
|
|
106
|
+
now: () => 1_000,
|
|
107
|
+
pacer: { reserve: async () => at } as unknown as Pacer,
|
|
108
|
+
sleep: async (ms) => {
|
|
109
|
+
waits.push(ms)
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
).me()
|
|
113
|
+
await paced(1_500)
|
|
114
|
+
expect(waits).toEqual([500])
|
|
115
|
+
await expect(paced(1_000 + 120_000)).rejects.toMatchObject({ code: "rate_limited" })
|
|
116
|
+
expect(service.calls).toHaveLength(1)
|
|
117
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { CliError, isCliError, realSleep, type SleepLike, wallClock } from "@wirecat/cli-base"
|
|
2
|
+
import { providerWaitMs, readJsonResponse, statusToCode } from "@wirecat/cli-base/http"
|
|
3
|
+
import { type EventSink, providerRequest } from "@wirecat/cli-core/cli"
|
|
4
|
+
import type { Pacer } from "@wirecat/cli-core/sends"
|
|
5
|
+
import * as v from "valibot"
|
|
6
|
+
|
|
7
|
+
// SAMPLE: the Example Comments service does not exist; `.invalid` never resolves, so a real run reaches nothing.
|
|
8
|
+
// Replace the address, the paths and the answer shapes with your service's, each from its docs or a captured answer.
|
|
9
|
+
export const SERVICE_URL = "https://comments.example.invalid/v1/"
|
|
10
|
+
export const SERVICE = "Example Comments"
|
|
11
|
+
|
|
12
|
+
const TIMEOUT_MS = 30_000
|
|
13
|
+
const LARGEST_ANSWER = 8 * 1024 * 1024
|
|
14
|
+
/** A pace wait longer than this ends the run with `rate_limited` instead of sitting in it. */
|
|
15
|
+
const LONGEST_PACE_WAIT_MS = 60_000
|
|
16
|
+
|
|
17
|
+
export interface Request {
|
|
18
|
+
method: "GET" | "POST" | "PATCH"
|
|
19
|
+
/** The path as a template, `comments/{id}`: the run record keeps the template, never an id. */
|
|
20
|
+
path: string
|
|
21
|
+
ids?: Record<string, string>
|
|
22
|
+
query?: Record<string, string | number | undefined>
|
|
23
|
+
body?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TransportOptions {
|
|
27
|
+
token: string
|
|
28
|
+
fetch?: typeof fetch
|
|
29
|
+
signal?: AbortSignal | undefined
|
|
30
|
+
/** The run's sink from `baseContext(...).run(events => …)`, so each request lands in the run record (WC-LIFE-15). */
|
|
31
|
+
events?: EventSink
|
|
32
|
+
/** cli-core's per-profile pace, shared by every process of the profile through a file. */
|
|
33
|
+
pacer?: Pacer
|
|
34
|
+
sleep?: SleepLike
|
|
35
|
+
now?: () => number
|
|
36
|
+
timeoutMs?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `new URL` folds a `.` or `..` segment into its parent, so such an id would reach another endpoint. */
|
|
40
|
+
const segment = (name: string, value: string) => {
|
|
41
|
+
if (value === "" || value === "." || value === "..")
|
|
42
|
+
throw new CliError("validation_error", `${name} must name one item; an empty, "." or ".." id is refused`)
|
|
43
|
+
return encodeURIComponent(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const urlOf = ({ path, ids = {}, query = {} }: Request) => {
|
|
47
|
+
const url = new URL(
|
|
48
|
+
path.replace(/\{(\w+)\}/g, (_, name: string) => segment(name, ids[name] ?? "")),
|
|
49
|
+
SERVICE_URL,
|
|
50
|
+
)
|
|
51
|
+
for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value))
|
|
52
|
+
return url
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A refusal as a CliError with cli-base's code for its status. The service's own message is never repeated: it can
|
|
57
|
+
* quote what was sent (WC-SECR-6). A write that failed on the service's side may have happened, so it is
|
|
58
|
+
* `outcome_unknown` and never retryable (WC-OUT-20, WC-LIFE-9).
|
|
59
|
+
*/
|
|
60
|
+
export const failureOf = (status: number, writes: boolean, retryAfterMs?: number): CliError => {
|
|
61
|
+
if (status === 401)
|
|
62
|
+
return new CliError("authentication_error", `${SERVICE} refused the token; sign in again with session start`, {
|
|
63
|
+
status,
|
|
64
|
+
})
|
|
65
|
+
if (status === 429)
|
|
66
|
+
return new CliError("rate_limited", `${SERVICE} asked to slow down; retry the command later`, {
|
|
67
|
+
status,
|
|
68
|
+
retryable: true,
|
|
69
|
+
...(retryAfterMs === undefined ? {} : { retry_after_ms: retryAfterMs }),
|
|
70
|
+
})
|
|
71
|
+
if (status >= 500 && writes)
|
|
72
|
+
return new CliError("outcome_unknown", `${SERVICE} failed during a write; check it before repeating the write`, {
|
|
73
|
+
status,
|
|
74
|
+
retryable: false,
|
|
75
|
+
})
|
|
76
|
+
if (status === 404) return new CliError("not_found", `${SERVICE} has no such item, or this token cannot see it`)
|
|
77
|
+
return new CliError(statusToCode(status), `${SERVICE} refused the request (HTTP ${status})`, {
|
|
78
|
+
status,
|
|
79
|
+
retryable: status >= 500,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The only code that holds the token: it goes in the Authorization header, never in an address, an error or a run
|
|
85
|
+
* record (WC-SECR-7). Nothing here retries. A read the caller may run again; a write is never sent twice, since the
|
|
86
|
+
* first may have landed (WC-OUT-20).
|
|
87
|
+
*/
|
|
88
|
+
export const transport = (options: TransportOptions) => {
|
|
89
|
+
const fetchOf = options.fetch ?? fetch
|
|
90
|
+
const sleep = options.sleep ?? realSleep
|
|
91
|
+
const now = options.now ?? Date.now
|
|
92
|
+
|
|
93
|
+
const turn = async () => {
|
|
94
|
+
if (!options.pacer) return
|
|
95
|
+
const wait = (await options.pacer.reserve()) - now()
|
|
96
|
+
if (wait <= 0) return
|
|
97
|
+
if (wait > LONGEST_PACE_WAIT_MS)
|
|
98
|
+
throw new CliError("rate_limited", `this profile must wait ${Math.ceil(wait / 1000)} s before its next request`, {
|
|
99
|
+
retry_after_ms: wait,
|
|
100
|
+
})
|
|
101
|
+
await sleep(wait, options.signal, "retry")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const unanswered = (writes: boolean, error: unknown): CliError => {
|
|
105
|
+
// Before the stop check: a write stopped in flight may still have landed (WC-LIFE-9).
|
|
106
|
+
if (writes)
|
|
107
|
+
return new CliError("outcome_unknown", `${SERVICE} did not answer a write; check it before repeating the write`, {
|
|
108
|
+
retryable: false,
|
|
109
|
+
})
|
|
110
|
+
if (options.signal?.aborted) return new CliError("cancelled", `the ${SERVICE} request was stopped`)
|
|
111
|
+
const timedOut = (error as { name?: string } | undefined)?.name === "TimeoutError"
|
|
112
|
+
return timedOut
|
|
113
|
+
? new CliError("timeout", `${SERVICE} did not answer in time`, { retryable: true })
|
|
114
|
+
: new CliError("network_error", `${SERVICE} could not be reached`, { retryable: true })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const send = async <T>(request: Request, answer: v.GenericSchema<unknown, T>): Promise<T> => {
|
|
118
|
+
const writes = request.method !== "GET"
|
|
119
|
+
const url = urlOf(request)
|
|
120
|
+
await turn()
|
|
121
|
+
// Not AbortSignal.timeout: its timer keeps the run's async context alive until it fires.
|
|
122
|
+
const deadline = new AbortController()
|
|
123
|
+
const timer = setTimeout(
|
|
124
|
+
() => deadline.abort(new DOMException("timed out", "TimeoutError")),
|
|
125
|
+
options.timeoutMs ?? TIMEOUT_MS,
|
|
126
|
+
)
|
|
127
|
+
timer.unref()
|
|
128
|
+
const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal
|
|
129
|
+
const end = providerRequest(`${request.method} /${request.path}`, options.events, {
|
|
130
|
+
method: request.method,
|
|
131
|
+
transport: "http",
|
|
132
|
+
})
|
|
133
|
+
try {
|
|
134
|
+
let response: Response
|
|
135
|
+
try {
|
|
136
|
+
response = await fetchOf(url, {
|
|
137
|
+
method: request.method,
|
|
138
|
+
headers: {
|
|
139
|
+
authorization: `Bearer ${options.token}`,
|
|
140
|
+
accept: "application/json",
|
|
141
|
+
...(request.body ? { "content-type": "application/json" } : {}),
|
|
142
|
+
},
|
|
143
|
+
...(request.body ? { body: JSON.stringify(request.body) } : {}),
|
|
144
|
+
// A redirect would carry the Authorization header to an address we did not choose.
|
|
145
|
+
redirect: "error",
|
|
146
|
+
signal,
|
|
147
|
+
})
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const failure = unanswered(writes, deadline.signal.reason ?? error)
|
|
150
|
+
end({ errorCode: failure.code })
|
|
151
|
+
throw failure
|
|
152
|
+
}
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const failure = failureOf(response.status, writes, providerWaitMs(response.headers, wallClock))
|
|
155
|
+
end({ status: response.status, errorCode: failure.code })
|
|
156
|
+
throw failure
|
|
157
|
+
}
|
|
158
|
+
let body: unknown
|
|
159
|
+
try {
|
|
160
|
+
body = await readJsonResponse(response, { maxBytes: LARGEST_ANSWER, signal })
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const failure =
|
|
163
|
+
isCliError(error) && error.code !== "invalid_response"
|
|
164
|
+
? error
|
|
165
|
+
: new CliError("invalid_response", `${SERVICE} answered with something other than bounded JSON`)
|
|
166
|
+
end({ status: response.status, errorCode: failure.code })
|
|
167
|
+
throw failure
|
|
168
|
+
}
|
|
169
|
+
const parsed = v.safeParse(answer, body)
|
|
170
|
+
if (!parsed.success) {
|
|
171
|
+
end({ status: response.status, errorCode: "invalid_response" })
|
|
172
|
+
throw new CliError("invalid_response", `${SERVICE}'s answer does not have the documented shape`)
|
|
173
|
+
}
|
|
174
|
+
end({ status: response.status })
|
|
175
|
+
return parsed.output
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { send }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type Transport = ReturnType<typeof transport>
|