@acid-cat/cli 0.4.0 → 0.6.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/README.md +1 -0
- package/dist/catalog.d.ts +5 -0
- package/dist/catalog.js +23 -4
- package/dist/conform/check.d.ts +6 -1
- package/dist/conform/check.js +13 -2
- package/dist/conform/cli.js +16 -6
- package/dist/conform/http.d.ts +0 -1
- package/dist/conform/http.js +17 -9
- package/dist/conform/mcp.js +18 -11
- package/dist/conform/properties.d.ts +2 -1
- package/dist/conform/properties.js +19 -9
- package/dist/conform/serve.js +45 -8
- package/dist/conform/webmcp.d.ts +7 -1
- package/dist/conform/webmcp.js +48 -18
- package/dist/json/package.json.js +1 -1
- package/dist/json.d.ts +22 -0
- package/dist/json.js +21 -0
- package/dist/main.js +8 -4
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -54,5 +54,6 @@ The command line is an [Effect](https://effect.website) program that uses `effec
|
|
|
54
54
|
| `src/conform/` | The conformance checks, and `serve`, which serves web handlers on a local port |
|
|
55
55
|
| `conformance/` | The conformance fixture, in Pkl and in JSON |
|
|
56
56
|
| `test/` | Tests. Run them with `moon run cli:test` |
|
|
57
|
+
| `test/fixtures/` | The catalog of the tests: a bakery, in canonical form |
|
|
57
58
|
|
|
58
59
|
The canonical writer comes from [`@acid-cat/catalog`](../catalog/typescript).
|
package/dist/catalog.d.ts
CHANGED
|
@@ -7,5 +7,10 @@ export interface Report {
|
|
|
7
7
|
readonly errors: readonly string[];
|
|
8
8
|
readonly warnings: readonly string[];
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
|
|
12
|
+
* does that.
|
|
13
|
+
*/
|
|
14
|
+
export declare const isCatalog: (value: unknown) => value is Catalog;
|
|
10
15
|
/** Check a catalog. When `text` is given, the file must also be in canonical form. */
|
|
11
16
|
export declare function check(document: unknown, text?: string): Report;
|
package/dist/catalog.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import catalog_schema_default from "./json/catalog.schema.json.js";
|
|
2
|
+
import { isObject, isRecordOf, isStringArray } from "./json.js";
|
|
2
3
|
import { canonicalText } from "@acid-cat/catalog";
|
|
3
4
|
import { REFERENCE, bundle, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
|
|
4
5
|
import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
|
|
@@ -34,13 +35,24 @@ const KNOWN_MEMBERS = {
|
|
|
34
35
|
/** The catalog schema (schemas/catalog.schema.json), imported as an Effect schema. */
|
|
35
36
|
const decodeCatalog = (() => {
|
|
36
37
|
const { $defs, $schema: _schema, $id: _id, ...root } = catalog_schema_default;
|
|
38
|
+
const definitions = $defs;
|
|
37
39
|
const schema = SchemaRepresentation.fromJsonSchemaDocument({
|
|
38
40
|
dialect: "draft-2020-12",
|
|
39
41
|
schema: root,
|
|
40
|
-
definitions
|
|
42
|
+
definitions
|
|
41
43
|
}, { patterns: "apply" });
|
|
42
|
-
return Schema.decodeUnknownResult(schema);
|
|
44
|
+
return Schema.decodeUnknownResult(Schema.toType(schema));
|
|
43
45
|
})();
|
|
46
|
+
/** True if `key` is absent from the object, or if `isMember` is true for its value. */
|
|
47
|
+
const optional = (object, key, isMember) => !(key in object) || isMember(object[key]);
|
|
48
|
+
const isString = (value) => typeof value === "string";
|
|
49
|
+
const isTarget = (value) => isObject(value) && isStringArray(value.bindings);
|
|
50
|
+
const isCapability = (value) => isObject(value) && typeof value.description === "string" && optional(value, "audience", isStringArray) && optional(value, "input", isObject) && optional(value, "output", isObject) && optional(value, "state", isObject) && optional(value, "failures", (failures) => isRecordOf(failures, isObject)) && optional(value, "readOnly", (readOnly) => typeof readOnly === "boolean");
|
|
51
|
+
/**
|
|
52
|
+
* True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
|
|
53
|
+
* does that.
|
|
54
|
+
*/
|
|
55
|
+
const isCatalog = (value) => isObject(value) && typeof value.acidcat === "string" && optional(value, "title", isString) && optional(value, "description", isString) && isRecordOf(value.targets, isTarget) && optional(value, "$defs", (types) => isRecordOf(types, isObject)) && isRecordOf(value.capabilities, isCapability);
|
|
44
56
|
const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
|
|
45
57
|
/** Check a catalog. When `text` is given, the file must also be in canonical form. */
|
|
46
58
|
function check(document, text) {
|
|
@@ -49,6 +61,10 @@ function check(document, text) {
|
|
|
49
61
|
errors: formatIssues(decoded.failure.issue).issues.map((issue) => `${(issue.path ?? []).map((key) => `[${JSON.stringify(key)}]`).join("") || "the catalog"}: ${issue.message}`),
|
|
50
62
|
warnings: []
|
|
51
63
|
};
|
|
64
|
+
if (!isCatalog(document)) return {
|
|
65
|
+
errors: ["the catalog: the members do not have the types of a catalog"],
|
|
66
|
+
warnings: []
|
|
67
|
+
};
|
|
52
68
|
const catalog = document;
|
|
53
69
|
const errors = ruleErrors(catalog);
|
|
54
70
|
const warnings = [...unknownMembers(catalog), ...errors.length === 0 ? schemaWarnings(catalog) : []];
|
|
@@ -103,9 +119,12 @@ function referenceErrors(schema, types) {
|
|
|
103
119
|
if ("$defs" in node) errors.push("has its own $defs; put shared types in the catalog's $defs");
|
|
104
120
|
if (node.$ref === void 0) return;
|
|
105
121
|
const name = typeof node.$ref === "string" ? REFERENCE.exec(node.$ref)?.[1] : void 0;
|
|
106
|
-
if (name === void 0 || !(name in types))
|
|
122
|
+
if (name === void 0 || !(name in types)) {
|
|
123
|
+
const reference = typeof node.$ref === "string" ? node.$ref : JSON.stringify(node.$ref);
|
|
124
|
+
errors.push(`references ${reference}, which is not a type`);
|
|
125
|
+
}
|
|
107
126
|
});
|
|
108
127
|
return errors;
|
|
109
128
|
}
|
|
110
129
|
//#endregion
|
|
111
|
-
export { bundle, check, viewOf$1 as viewOf };
|
|
130
|
+
export { bundle, check, isCatalog, viewOf$1 as viewOf };
|
package/dist/conform/check.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CatalogDocument } from "@acid-cat/spec-ts";
|
|
1
|
+
import type { CatalogDocument, Outcome } from "@acid-cat/spec-ts";
|
|
2
2
|
export interface Check {
|
|
3
3
|
readonly binding: string;
|
|
4
4
|
readonly name: string;
|
|
@@ -9,6 +9,11 @@ export interface Check {
|
|
|
9
9
|
}
|
|
10
10
|
/** The conformance fixture: the catalog that each implementation serves. */
|
|
11
11
|
export declare const fixture: CatalogDocument;
|
|
12
|
+
/**
|
|
13
|
+
* True if the value is an outcome (spec/invocation.md): `{ ok: true, value }`, `{ ok: false, error }`, or a call
|
|
14
|
+
* failure `{ kind, message }` with no `ok`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const isOutcome: (value: unknown) => value is Outcome<unknown, unknown>;
|
|
12
17
|
/** The ids of the capabilities in the view of a target of the fixture. */
|
|
13
18
|
export declare const idsOf: (target: string) => string[];
|
|
14
19
|
/** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
|
package/dist/conform/check.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { isObject } from "../json.js";
|
|
1
2
|
import fixture_acidcat_default from "../json/fixture.acidcat.json.js";
|
|
2
|
-
import { viewOf } from "@acid-cat/spec-ts";
|
|
3
|
+
import { CALL_FAILURE_KINDS, viewOf } from "@acid-cat/spec-ts";
|
|
3
4
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
4
5
|
//#region src/conform/check.ts
|
|
5
6
|
/** The result of one conformance check, and helpers to write checks. */
|
|
6
7
|
/** The conformance fixture: the catalog that each implementation serves. */
|
|
7
8
|
const fixture = fixture_acidcat_default;
|
|
9
|
+
/**
|
|
10
|
+
* True if the value is an outcome (spec/invocation.md): `{ ok: true, value }`, `{ ok: false, error }`, or a call
|
|
11
|
+
* failure `{ kind, message }` with no `ok`.
|
|
12
|
+
*/
|
|
13
|
+
const isOutcome = (value) => {
|
|
14
|
+
if (!isObject(value)) return false;
|
|
15
|
+
if (value.ok === true) return "value" in value;
|
|
16
|
+
if (value.ok === false) return "error" in value;
|
|
17
|
+
return !("ok" in value) && CALL_FAILURE_KINDS.some((kind) => kind === value.kind) && typeof value.message === "string";
|
|
18
|
+
};
|
|
8
19
|
/** The ids of the capabilities in the view of a target of the fixture. */
|
|
9
20
|
const idsOf = (target) => Object.keys(viewOf(fixture, target).capabilities).sort();
|
|
10
21
|
/** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
|
|
@@ -88,4 +99,4 @@ const sorted = (values) => [...values].sort();
|
|
|
88
99
|
/** An origin that no deployment allows. */
|
|
89
100
|
const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
|
|
90
101
|
//#endregion
|
|
91
|
-
export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, expect, expectEqual, fixture, idsOf, sorted, until };
|
|
102
|
+
export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, expect, expectEqual, fixture, idsOf, isOutcome, sorted, until };
|
package/dist/conform/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isArrayOf, isObject, memberOf, parseJson } from "../json.js";
|
|
1
2
|
import { Checks, expect, expectEqual, idsOf, sorted, until } from "./check.js";
|
|
2
3
|
import { spawn } from "node:child_process";
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
@@ -19,13 +20,22 @@ const USAGES = {
|
|
|
19
20
|
"fixture.switch.set": "fixture switch set --on",
|
|
20
21
|
"fixture.toggle": "fixture toggle"
|
|
21
22
|
};
|
|
23
|
+
const isEntry = (value) => isObject(value) && typeof value.id === "string";
|
|
24
|
+
/** The list that the `list` command writes to standard output. The check fails when it is not a list of entries. */
|
|
25
|
+
function entriesOf(stdout) {
|
|
26
|
+
const list = parseJson(stdout);
|
|
27
|
+
if (!isArrayOf(list, isEntry)) throw new Error(`list must give a list of objects with an id, got ${JSON.stringify(list)}`);
|
|
28
|
+
return list;
|
|
29
|
+
}
|
|
22
30
|
/**
|
|
23
31
|
* Start the command line. When the signal aborts, the process is killed, and its run rejects. The rejection is observed
|
|
24
32
|
* here too, so that a check that stops waiting first, for example at the deadline, records a failure and does not end
|
|
25
33
|
* the process with an unhandled rejection.
|
|
26
34
|
*/
|
|
27
35
|
function start(command, args, signal) {
|
|
28
|
-
const
|
|
36
|
+
const [program, ...programArgs] = command;
|
|
37
|
+
if (program === void 0) throw new Error("the command that starts the command line is empty");
|
|
38
|
+
const child = spawn(program, [...programArgs, ...args], {
|
|
29
39
|
stdio: [
|
|
30
40
|
"ignore",
|
|
31
41
|
"pipe",
|
|
@@ -59,12 +69,12 @@ async function checkCli(command, options) {
|
|
|
59
69
|
await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async (signal) => {
|
|
60
70
|
const result = await run(signal, "fixture", "echo", "--text", "hello");
|
|
61
71
|
expectEqual(result.code, 0, "exit code");
|
|
62
|
-
expectEqual(
|
|
72
|
+
expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
|
|
63
73
|
});
|
|
64
74
|
await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async (signal) => {
|
|
65
75
|
const result = await run(signal, "fixture", "refuse");
|
|
66
76
|
expectEqual(result.code, 1, "exit code");
|
|
67
|
-
expectEqual(
|
|
77
|
+
expectEqual(memberOf(parseJson(result.stdout), "_tag"), "Refused", "_tag");
|
|
68
78
|
});
|
|
69
79
|
await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async (signal) => {
|
|
70
80
|
const result = await run(signal, "fixture", "crash");
|
|
@@ -141,14 +151,14 @@ async function checkCli(command, options) {
|
|
|
141
151
|
await checks.run("call gives the value of a capability", `${RULE}#commands`, async (signal) => {
|
|
142
152
|
const result = await run(signal, "call", "fixture.echo", "{\"text\":\"hello\"}");
|
|
143
153
|
expectEqual(result.code, 0, "exit code");
|
|
144
|
-
expectEqual(
|
|
154
|
+
expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
|
|
145
155
|
});
|
|
146
156
|
await checks.run("list gives the view", `${RULE}#commands`, async (signal) => {
|
|
147
157
|
const result = await run(signal, "list");
|
|
148
|
-
expectEqual(sorted(
|
|
158
|
+
expectEqual(sorted(entriesOf(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
|
|
149
159
|
});
|
|
150
160
|
await checks.run("list gives the usage of each capability, with its flags", `${RULE}#commands`, async (signal) => {
|
|
151
|
-
const entries =
|
|
161
|
+
const entries = entriesOf((await run(signal, "list")).stdout);
|
|
152
162
|
for (const { id, usage } of entries) {
|
|
153
163
|
expect(typeof usage === "string", `${id} must have a usage`);
|
|
154
164
|
const [program, ...rest] = usage.split(" ");
|
package/dist/conform/http.d.ts
CHANGED
package/dist/conform/http.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { isArrayOf, isObject, memberOf, readJson } from "../json.js";
|
|
1
2
|
import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
|
|
2
3
|
//#region src/conform/http.ts
|
|
3
4
|
/** Conformance checks for the HTTP binding (spec/bindings/http.md). */
|
|
4
5
|
const RULE = "spec/bindings/http.md";
|
|
6
|
+
const isEntry = (value) => isObject(value) && typeof value.id === "string";
|
|
7
|
+
/** The list that `GET /capabilities` gives. The check fails when it is not a list of entries. */
|
|
8
|
+
async function entriesOf(response) {
|
|
9
|
+
const list = await readJson(response);
|
|
10
|
+
if (!isArrayOf(list, isEntry)) throw new Error(`GET /capabilities must give a list of objects with an id, got ${JSON.stringify(list)}`);
|
|
11
|
+
return list;
|
|
12
|
+
}
|
|
5
13
|
async function checkHttp(base, options) {
|
|
6
14
|
const checks = new Checks("http", options.deadline);
|
|
7
15
|
const post = (signal, id, body, headers = {}) => fetch(`${base}/capabilities/${id}`, {
|
|
@@ -10,28 +18,28 @@ async function checkHttp(base, options) {
|
|
|
10
18
|
"Content-Type": "application/json",
|
|
11
19
|
...headers
|
|
12
20
|
},
|
|
13
|
-
body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([body]) : JSON.stringify(body),
|
|
21
|
+
body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([new Uint8Array(body)]) : JSON.stringify(body),
|
|
14
22
|
signal
|
|
15
23
|
});
|
|
16
|
-
const
|
|
24
|
+
const kindOf = async (response) => memberOf(await readJson(response), "kind");
|
|
17
25
|
await checks.run("GET /capabilities lists the view with availability", `${RULE}#requests`, async (signal) => {
|
|
18
|
-
const list = await (await fetch(`${base}/capabilities`, { signal }))
|
|
26
|
+
const list = await entriesOf(await fetch(`${base}/capabilities`, { signal }));
|
|
19
27
|
expectEqual(sorted(list.map((entry) => entry.id)), idsOf("api"), "ids");
|
|
20
28
|
expect(list.every((entry) => typeof entry.available === "boolean"), "each entry must have available");
|
|
21
29
|
});
|
|
22
30
|
await checks.run("a value has the status 200", `${RULE}#requests`, async (signal) => {
|
|
23
31
|
const response = await post(signal, "fixture.echo", { text: "hello" });
|
|
24
32
|
expectEqual(response.status, 200, "status");
|
|
25
|
-
expectEqual(await
|
|
33
|
+
expectEqual(await readJson(response), {
|
|
26
34
|
ok: true,
|
|
27
35
|
value: { text: "hello" }
|
|
28
36
|
}, "body");
|
|
29
37
|
});
|
|
30
38
|
await checks.run("a failure has the status 200 and ok: false", `${RULE}#requests`, async (signal) => {
|
|
31
39
|
const response = await post(signal, "fixture.refuse", {});
|
|
32
|
-
const body = await
|
|
40
|
+
const body = await readJson(response);
|
|
33
41
|
expectEqual(response.status, 200, "status");
|
|
34
|
-
expect(body
|
|
42
|
+
expect(memberOf(body, "ok") === false && memberOf(memberOf(body, "error"), "_tag") === "Refused", `got ${JSON.stringify(body)}`);
|
|
35
43
|
});
|
|
36
44
|
for (const [name, id, input, status, kind] of [
|
|
37
45
|
[
|
|
@@ -58,16 +66,16 @@ async function checkHttp(base, options) {
|
|
|
58
66
|
]) await checks.run(name, `${RULE}#requests`, async (signal) => {
|
|
59
67
|
const response = await post(signal, id, input);
|
|
60
68
|
expectEqual(response.status, status, "status");
|
|
61
|
-
expectEqual(
|
|
69
|
+
expectEqual(await kindOf(response), kind, "kind");
|
|
62
70
|
});
|
|
63
71
|
await checks.run("an unavailable capability has the status 503", `${RULE}#requests`, async (signal) => {
|
|
64
72
|
await post(signal, "fixture.switch.set", { on: false });
|
|
65
73
|
try {
|
|
66
|
-
const toggle = (await (await fetch(`${base}/capabilities`, { signal }))
|
|
74
|
+
const toggle = (await entriesOf(await fetch(`${base}/capabilities`, { signal }))).find((entry) => entry.id === "fixture.toggle");
|
|
67
75
|
expect(toggle?.available === false && typeof toggle.reason === "string", "fixture.toggle must be unavailable, with a reason");
|
|
68
76
|
const response = await post(signal, "fixture.toggle", {});
|
|
69
77
|
expectEqual(response.status, 503, "status");
|
|
70
|
-
expectEqual(
|
|
78
|
+
expectEqual(await kindOf(response), "unavailable", "kind");
|
|
71
79
|
} finally {
|
|
72
80
|
await post(AbortSignal.timeout(options.deadline), "fixture.switch.set", { on: true });
|
|
73
81
|
}
|
package/dist/conform/mcp.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isObject, memberOf, parseJson, readJson } from "../json.js";
|
|
1
2
|
import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, fixture, idsOf, sorted } from "./check.js";
|
|
2
3
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
3
4
|
//#region src/conform/mcp.ts
|
|
@@ -15,7 +16,11 @@ async function mcpClient(url, signal) {
|
|
|
15
16
|
await client.connect(new StreamableHTTPClientTransport(new URL(url)), signal ? { signal } : void 0);
|
|
16
17
|
return client;
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
+
/** The JSON value in the first text content of a tool result, or `null` when there is no text content. */
|
|
20
|
+
const textOf = (result) => {
|
|
21
|
+
const first = result.content[0];
|
|
22
|
+
return parseJson(first?.type === "text" ? first.text : "null");
|
|
23
|
+
};
|
|
19
24
|
async function checkMcp(url, options) {
|
|
20
25
|
const checks = new Checks("mcp", options.deadline);
|
|
21
26
|
let client;
|
|
@@ -41,9 +46,9 @@ async function checkMcp(url, options) {
|
|
|
41
46
|
const refuse = tools.find((tool) => tool.name === "fixture.refuse");
|
|
42
47
|
expect(echo?.annotations?.readOnlyHint === true, "fixture.echo must have readOnlyHint: true");
|
|
43
48
|
expect(refuse?.annotations?.readOnlyHint !== true, "fixture.refuse must not have readOnlyHint: true");
|
|
44
|
-
expect(
|
|
49
|
+
expect(echo?.outputSchema?.type === "object", "fixture.echo must have an object outputSchema");
|
|
45
50
|
expect(refuse?.outputSchema === void 0, "fixture.refuse has no object output, so it must have no outputSchema");
|
|
46
|
-
expect(
|
|
51
|
+
expect(echo?.inputSchema.type === "object", "an inputSchema must be an object");
|
|
47
52
|
});
|
|
48
53
|
await checks.run("a value is a tool result with structured content", `${RULE}#tools`, async (signal) => {
|
|
49
54
|
const result = await call(signal, "fixture.echo", { text: "hello" });
|
|
@@ -55,21 +60,23 @@ async function checkMcp(url, options) {
|
|
|
55
60
|
const result = await call(signal, "fixture.refuse", {});
|
|
56
61
|
expect(result.isError === true, "a failure must be an error result");
|
|
57
62
|
const body = textOf(result);
|
|
58
|
-
expect(body
|
|
63
|
+
expect(memberOf(body, "ok") === false && memberOf(memberOf(body, "error"), "_tag") === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
|
|
59
64
|
});
|
|
60
65
|
await checks.run("a defect is an internal call failure with no internal details", `${RULE}#tools`, async (signal) => {
|
|
61
66
|
const result = await call(signal, "fixture.crash", {});
|
|
62
67
|
const body = textOf(result);
|
|
68
|
+
expect(isObject(body), `expected an object, got ${JSON.stringify(body)}`);
|
|
63
69
|
expect(result.isError === true && body.kind === "internal" && !("ok" in body), `expected { kind: "internal" }, got ${JSON.stringify(body)}`);
|
|
64
|
-
|
|
70
|
+
const message = typeof body.message === "string" ? body.message : "";
|
|
71
|
+
expect(!/\n\s+at /.test(message), "the message must not contain a stack trace");
|
|
65
72
|
});
|
|
66
73
|
await checks.run("an input that does not agree with its schema is invalid-input", `${RULE}#tools`, async (signal) => {
|
|
67
74
|
const result = await call(signal, "fixture.echo", { text: 1 });
|
|
68
|
-
expectEqual(textOf(result)
|
|
75
|
+
expectEqual(memberOf(textOf(result), "kind"), "invalid-input", "kind");
|
|
69
76
|
});
|
|
70
77
|
await checks.run("a capability outside the view is an unknown tool", `${RULE}#tools`, async (signal) => {
|
|
71
78
|
const error = await call(signal, "fixture.hidden", {}).then(() => void 0, (caught) => caught);
|
|
72
|
-
expect(error
|
|
79
|
+
expect(memberOf(error, "code") === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
|
|
73
80
|
});
|
|
74
81
|
await checks.run("an unavailable capability is not listed", `${RULE}#tools`, async (signal) => {
|
|
75
82
|
await call(signal, "fixture.switch.set", { on: false });
|
|
@@ -81,19 +88,19 @@ async function checkMcp(url, options) {
|
|
|
81
88
|
}
|
|
82
89
|
});
|
|
83
90
|
await mcp.close();
|
|
84
|
-
const post = (signal,
|
|
91
|
+
const post = (signal, { headers = {}, body }) => fetch(url, {
|
|
85
92
|
method: "POST",
|
|
86
|
-
|
|
93
|
+
body,
|
|
87
94
|
headers: {
|
|
88
95
|
"Content-Type": "application/json",
|
|
89
|
-
...
|
|
96
|
+
...headers
|
|
90
97
|
},
|
|
91
98
|
signal
|
|
92
99
|
});
|
|
93
100
|
const rejection = async (response, status, code) => {
|
|
94
101
|
expectEqual(response.status, status, "status");
|
|
95
102
|
expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
|
|
96
|
-
if (code !== void 0) expectEqual((await response
|
|
103
|
+
if (code !== void 0) expectEqual(memberOf(memberOf(await readJson(response), "error"), "code"), code, "JSON-RPC error code");
|
|
97
104
|
};
|
|
98
105
|
await checks.run("a method other than POST is rejected with 405", `${RULE}#server`, async (signal) => {
|
|
99
106
|
const response = await fetch(url, { signal });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Outcome } from "@acid-cat/spec-ts";
|
|
2
|
+
import type { JsonObject } from "../json.js";
|
|
2
3
|
import { Checks } from "./check.js";
|
|
3
4
|
/** A call through one binding. When the signal aborts, the client stops the call and rejects with the reason of the signal. */
|
|
4
|
-
export type Invoke = (id: string, input:
|
|
5
|
+
export type Invoke = (id: string, input: JsonObject, signal?: AbortSignal) => Promise<Outcome<unknown, unknown>>;
|
|
5
6
|
export declare function httpInvoke(base: string): Invoke;
|
|
6
7
|
export declare function mcpInvoke(url: string, signal: AbortSignal): Promise<{
|
|
7
8
|
readonly invoke: Invoke;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isStringArray, memberOf, parseJson, readJson } from "../json.js";
|
|
2
|
+
import { Checks, expect, isOutcome, until } from "./check.js";
|
|
2
3
|
import { mcpClient } from "./mcp.js";
|
|
3
4
|
import { SdkError } from "@modelcontextprotocol/client";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -11,12 +12,15 @@ import fc from "fast-check";
|
|
|
11
12
|
const RULE = "spec/invocation.md#cancellation-and-concurrency";
|
|
12
13
|
function httpInvoke(base) {
|
|
13
14
|
return async (id, input, signal) => {
|
|
14
|
-
|
|
15
|
+
const response = await fetch(`${base}/capabilities/${id}`, {
|
|
15
16
|
method: "POST",
|
|
16
17
|
headers: { "Content-Type": "application/json" },
|
|
17
18
|
body: JSON.stringify(input),
|
|
18
19
|
...signal ? { signal } : {}
|
|
19
|
-
})
|
|
20
|
+
});
|
|
21
|
+
const outcome = await readJson(response);
|
|
22
|
+
if (!isOutcome(outcome)) throw new Error(`POST /capabilities/${id} gave ${JSON.stringify(outcome)}, which is not an outcome`);
|
|
23
|
+
return outcome;
|
|
20
24
|
};
|
|
21
25
|
}
|
|
22
26
|
async function mcpInvoke(url, signal) {
|
|
@@ -28,15 +32,17 @@ async function mcpInvoke(url, signal) {
|
|
|
28
32
|
name: id,
|
|
29
33
|
arguments: input
|
|
30
34
|
}, signal ? { signal } : void 0);
|
|
31
|
-
const
|
|
35
|
+
const first = result.content[0];
|
|
36
|
+
const body = parseJson(first?.type === "text" ? first.text : "null");
|
|
32
37
|
if (result.isError !== true) return {
|
|
33
38
|
ok: true,
|
|
34
39
|
value: result.structuredContent ?? body
|
|
35
40
|
};
|
|
41
|
+
if (!isOutcome(body)) throw new Error(`the error result of ${id} gave ${JSON.stringify(body)}, which is not an outcome`);
|
|
36
42
|
return body;
|
|
37
43
|
} catch (error) {
|
|
38
44
|
if (signal?.aborted && error instanceof SdkError && error.message === String(signal.reason)) throw signal.reason;
|
|
39
|
-
if (error
|
|
45
|
+
if (memberOf(error, "code") === -32602) return {
|
|
40
46
|
kind: "unknown-capability",
|
|
41
47
|
message: String(error)
|
|
42
48
|
};
|
|
@@ -54,7 +60,7 @@ const operation = fc.oneof(fc.string({ maxLength: 40 }).map((text) => ({
|
|
|
54
60
|
function agrees(operation, outcome) {
|
|
55
61
|
switch (operation.kind) {
|
|
56
62
|
case "echo": return "ok" in outcome && outcome.ok && JSON.stringify(outcome.value) === JSON.stringify({ text: operation.text });
|
|
57
|
-
case "refuse": return "ok" in outcome && !outcome.ok && outcome.error
|
|
63
|
+
case "refuse": return "ok" in outcome && !outcome.ok && memberOf(outcome.error, "_tag") === "Refused";
|
|
58
64
|
case "crash": return "kind" in outcome && outcome.kind === "internal";
|
|
59
65
|
case "invalid": return "kind" in outcome && outcome.kind === "invalid-input";
|
|
60
66
|
}
|
|
@@ -78,7 +84,9 @@ const isCancellation = (settled, reason) => "outcome" in settled ? "kind" in set
|
|
|
78
84
|
async function heldKeys(invoke, signal) {
|
|
79
85
|
const outcome = await invoke("fixture.held", {}, signal);
|
|
80
86
|
expect("ok" in outcome && outcome.ok, `fixture.held gave ${JSON.stringify(outcome)}`);
|
|
81
|
-
|
|
87
|
+
const keys = memberOf(outcome.value, "keys");
|
|
88
|
+
expect(isStringArray(keys), `fixture.held gave ${JSON.stringify(outcome)}, with no list of keys`);
|
|
89
|
+
return keys;
|
|
82
90
|
}
|
|
83
91
|
/**
|
|
84
92
|
* Start one call to fixture.hold for each flag, and wait until each call holds. Then cancel each call whose flag is
|
|
@@ -138,8 +146,10 @@ async function checkProperties(binding, invoke, deadline) {
|
|
|
138
146
|
maxLength: 12
|
|
139
147
|
}), async (operations) => {
|
|
140
148
|
fc.pre(!signal.aborted);
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
(await Promise.all(operations.map(async (op) => ({
|
|
150
|
+
op,
|
|
151
|
+
outcome: await callFor(invoke, op, signal)
|
|
152
|
+
})))).forEach(({ op, outcome }) => expect(agrees(op, outcome), `${op.kind} gave ${JSON.stringify(outcome)}`));
|
|
143
153
|
}), {
|
|
144
154
|
numRuns: 25,
|
|
145
155
|
includeErrorInReport: true
|
package/dist/conform/serve.js
CHANGED
|
@@ -23,7 +23,8 @@ function serve(routes) {
|
|
|
23
23
|
async function answer(incoming, outgoing, prefixes, routes) {
|
|
24
24
|
const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "127.0.0.1"}`);
|
|
25
25
|
const prefix = prefixes.find((candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`));
|
|
26
|
-
|
|
26
|
+
const handler = prefix === void 0 ? void 0 : routes[prefix];
|
|
27
|
+
if (handler === void 0) {
|
|
27
28
|
outgoing.writeHead(404).end();
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
@@ -35,17 +36,19 @@ async function answer(incoming, outgoing, prefixes, routes) {
|
|
|
35
36
|
for (const [name, value] of Object.entries(incoming.headers)) if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
|
|
36
37
|
else if (value !== void 0) headers.set(name, value);
|
|
37
38
|
const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
|
|
38
|
-
const
|
|
39
|
+
const init = {
|
|
39
40
|
method: incoming.method ?? "GET",
|
|
40
41
|
headers,
|
|
41
|
-
signal: abort.signal
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
signal: abort.signal
|
|
43
|
+
};
|
|
44
|
+
const withBody = () => ({
|
|
45
|
+
...init,
|
|
46
|
+
body: bodyOf(incoming),
|
|
47
|
+
duplex: "half"
|
|
46
48
|
});
|
|
49
|
+
const request = new Request(url, hasBody ? withBody() : init);
|
|
47
50
|
try {
|
|
48
|
-
const response = await
|
|
51
|
+
const response = await handler(request);
|
|
49
52
|
outgoing.writeHead(response.status, Object.fromEntries(response.headers));
|
|
50
53
|
if (response.body === null) {
|
|
51
54
|
outgoing.end();
|
|
@@ -58,5 +61,39 @@ async function answer(incoming, outgoing, prefixes, routes) {
|
|
|
58
61
|
outgoing.end();
|
|
59
62
|
}
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The body of an incoming request, as a web stream. The stream reads the request only when the handler reads the body,
|
|
66
|
+
* and the request pauses after each chunk until the handler reads again. A request that the handler does not read stays
|
|
67
|
+
* unread, so that Node.js discards it after the response. When the handler cancels the stream, the request discards the
|
|
68
|
+
* rest of its body.
|
|
69
|
+
*/
|
|
70
|
+
function bodyOf(incoming) {
|
|
71
|
+
let open = true;
|
|
72
|
+
let reading = false;
|
|
73
|
+
const close = (end) => {
|
|
74
|
+
if (open) end();
|
|
75
|
+
open = false;
|
|
76
|
+
};
|
|
77
|
+
return new ReadableStream({
|
|
78
|
+
pull(controller) {
|
|
79
|
+
if (!reading) {
|
|
80
|
+
reading = true;
|
|
81
|
+
incoming.on("data", (chunk) => {
|
|
82
|
+
if (!open) return;
|
|
83
|
+
if (!(chunk instanceof Uint8Array)) return close(() => controller.error(/* @__PURE__ */ new TypeError("a chunk of the request body is not bytes")));
|
|
84
|
+
controller.enqueue(chunk);
|
|
85
|
+
incoming.pause();
|
|
86
|
+
});
|
|
87
|
+
incoming.on("end", () => close(() => controller.close()));
|
|
88
|
+
incoming.on("error", (error) => close(() => controller.error(error)));
|
|
89
|
+
}
|
|
90
|
+
incoming.resume();
|
|
91
|
+
},
|
|
92
|
+
cancel() {
|
|
93
|
+
open = false;
|
|
94
|
+
incoming.resume();
|
|
95
|
+
}
|
|
96
|
+
}, { highWaterMark: 0 });
|
|
97
|
+
}
|
|
61
98
|
//#endregion
|
|
62
99
|
export { serve };
|
package/dist/conform/webmcp.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface RecordedTool {
|
|
|
5
5
|
readonly description: string;
|
|
6
6
|
readonly inputSchema: unknown;
|
|
7
7
|
}
|
|
8
|
+
/** A tool, as a page gives it to `registerTool`. */
|
|
9
|
+
export interface HostTool extends RecordedTool {
|
|
10
|
+
execute(input: unknown, options: {
|
|
11
|
+
readonly signal: AbortSignal;
|
|
12
|
+
}): Promise<unknown>;
|
|
13
|
+
}
|
|
8
14
|
/** What a call to a tool gives: the value that `execute` resolves with, or the error that it rejects with. */
|
|
9
15
|
export type ToolCall = {
|
|
10
16
|
readonly resolved: unknown;
|
|
@@ -20,7 +26,7 @@ export type ToolCall = {
|
|
|
20
26
|
/** The host that the checks give to the page. */
|
|
21
27
|
export interface TestHost {
|
|
22
28
|
readonly modelContext: {
|
|
23
|
-
registerTool(tool:
|
|
29
|
+
registerTool(tool: HostTool, options: {
|
|
24
30
|
readonly signal: AbortSignal;
|
|
25
31
|
}): void;
|
|
26
32
|
};
|
package/dist/conform/webmcp.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isArrayOf, isObject, isStringArray, memberOf, parseJson } from "../json.js";
|
|
1
2
|
import { Checks, abortable, expect, expectEqual, fixture, idsOf, sorted, until } from "./check.js";
|
|
2
3
|
import { REFERENCE, visitObjects } from "@acid-cat/spec-ts";
|
|
3
4
|
//#region src/conform/webmcp.ts
|
|
@@ -20,10 +21,9 @@ function createTestHost() {
|
|
|
20
21
|
const calls = /* @__PURE__ */ new Map();
|
|
21
22
|
return {
|
|
22
23
|
modelContext: { registerTool(tool, options) {
|
|
23
|
-
|
|
24
|
-
registered.set(recorded.name, recorded);
|
|
24
|
+
registered.set(tool.name, tool);
|
|
25
25
|
options.signal.addEventListener("abort", () => {
|
|
26
|
-
if (registered.get(
|
|
26
|
+
if (registered.get(tool.name) === tool) registered.delete(tool.name);
|
|
27
27
|
});
|
|
28
28
|
} },
|
|
29
29
|
tools: () => [...registered.values()].map((tool) => ({
|
|
@@ -39,7 +39,10 @@ function createTestHost() {
|
|
|
39
39
|
try {
|
|
40
40
|
return { resolved: await tool.execute(input, { signal: controller.signal }) };
|
|
41
41
|
} catch (error) {
|
|
42
|
-
const
|
|
42
|
+
const hasMembers = typeof error === "object" && error !== null;
|
|
43
|
+
const errorName = hasMembers && "name" in error ? error.name : void 0;
|
|
44
|
+
const kind = hasMembers && "kind" in error ? error.kind : void 0;
|
|
45
|
+
const message = hasMembers && "message" in error ? error.message : void 0;
|
|
43
46
|
return { rejected: {
|
|
44
47
|
name: String(errorName),
|
|
45
48
|
kind,
|
|
@@ -54,6 +57,30 @@ function createTestHost() {
|
|
|
54
57
|
}
|
|
55
58
|
};
|
|
56
59
|
}
|
|
60
|
+
/** The tools that a host gives, after they pass through JSON. The check fails when the value is not a list of tools. */
|
|
61
|
+
function toolsOf(value) {
|
|
62
|
+
const isTool = (tool) => isObject(tool) && typeof tool.name === "string" && typeof tool.description === "string";
|
|
63
|
+
if (!isArrayOf(value, isTool)) throw new Error(`the host must give a list of tools, got ${JSON.stringify(value)}`);
|
|
64
|
+
return value.map((tool) => ({
|
|
65
|
+
name: tool.name,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
inputSchema: tool.inputSchema
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/** A tool call that a host gives, after it passes through JSON. The check fails when the value is not a tool call. */
|
|
71
|
+
function toolCallOf(value) {
|
|
72
|
+
if (isObject(value)) {
|
|
73
|
+
if (value.missing === true) return { missing: true };
|
|
74
|
+
if ("resolved" in value) return { resolved: value.resolved };
|
|
75
|
+
const rejected = value.rejected;
|
|
76
|
+
if (isObject(rejected) && typeof rejected.name === "string" && typeof rejected.message === "string") return { rejected: {
|
|
77
|
+
name: rejected.name,
|
|
78
|
+
kind: rejected.kind,
|
|
79
|
+
message: rejected.message
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`the host must give a tool call, got ${JSON.stringify(value)}`);
|
|
83
|
+
}
|
|
57
84
|
let lastCall = 0;
|
|
58
85
|
function driverOf(host) {
|
|
59
86
|
return {
|
|
@@ -76,10 +103,10 @@ function driverOf(host) {
|
|
|
76
103
|
async function processDriver(install) {
|
|
77
104
|
const host = createTestHost();
|
|
78
105
|
const remove = await install(host.modelContext);
|
|
79
|
-
const copy = (value) =>
|
|
106
|
+
const copy = (value) => parseJson(JSON.stringify(value));
|
|
80
107
|
return driverOf({
|
|
81
|
-
tools: async () => copy(host.tools()),
|
|
82
|
-
call: async (name, input, id) => copy(await host.call(name, input, id)),
|
|
108
|
+
tools: async () => toolsOf(copy(host.tools())),
|
|
109
|
+
call: async (name, input, id) => toolCallOf(copy(await host.call(name, input, id))),
|
|
83
110
|
cancel: async (id) => host.cancel(id),
|
|
84
111
|
close: async () => {
|
|
85
112
|
if (typeof remove === "function") await remove();
|
|
@@ -105,23 +132,22 @@ async function browserDriver(url) {
|
|
|
105
132
|
}
|
|
106
133
|
`);
|
|
107
134
|
await page.goto(url);
|
|
135
|
+
const host = (method, ...args) => page.evaluate(`window.__acidCatConform.${method}(${args.map((arg) => JSON.stringify(arg) ?? "undefined").join(", ")})`);
|
|
108
136
|
return driverOf({
|
|
109
137
|
tools: async () => {
|
|
110
138
|
if (errors.length > 0) throw new Error(`The page has errors: ${errors.join("; ")}`);
|
|
111
|
-
return
|
|
139
|
+
return toolsOf(await host("tools"));
|
|
140
|
+
},
|
|
141
|
+
call: async (name, input, id) => toolCallOf(await host("call", name, input, id)),
|
|
142
|
+
cancel: async (id) => {
|
|
143
|
+
await host("cancel", id);
|
|
112
144
|
},
|
|
113
|
-
call: (name, input, id) => page.evaluate(([name, input, id]) => window.__acidCatConform.call(name, input, id), [
|
|
114
|
-
name,
|
|
115
|
-
input,
|
|
116
|
-
id
|
|
117
|
-
]),
|
|
118
|
-
cancel: (id) => page.evaluate((id) => window.__acidCatConform.cancel(id), id),
|
|
119
145
|
close: () => browser.close()
|
|
120
146
|
});
|
|
121
147
|
}
|
|
122
148
|
/** True if each `$ref` of a schema refers to a type in its own `$defs` (spec/catalog.md#bundling). */
|
|
123
149
|
function isBundled(schema) {
|
|
124
|
-
const types = schema.$defs
|
|
150
|
+
const types = isObject(schema.$defs) ? schema.$defs : {};
|
|
125
151
|
let bundled = true;
|
|
126
152
|
visitObjects(schema, (node) => {
|
|
127
153
|
if (typeof node.$ref !== "string") return;
|
|
@@ -135,7 +161,9 @@ const names = async (driver, signal) => sorted((await driver.tools(signal)).map(
|
|
|
135
161
|
async function heldKeys(driver, signal) {
|
|
136
162
|
const call = await driver.call("fixture.held", {}, { signal });
|
|
137
163
|
expect("resolved" in call, `fixture.held gave ${JSON.stringify(call)}`);
|
|
138
|
-
|
|
164
|
+
const keys = memberOf(memberOf(call.resolved, "value"), "keys");
|
|
165
|
+
expect(isStringArray(keys), `fixture.held gave ${JSON.stringify(call)}, with no list of keys`);
|
|
166
|
+
return keys;
|
|
139
167
|
}
|
|
140
168
|
async function checkWebMcp(driver, options) {
|
|
141
169
|
const checks = new Checks("webmcp", options.deadline);
|
|
@@ -148,10 +176,12 @@ async function checkWebMcp(driver, options) {
|
|
|
148
176
|
for (const tool of await driver.tools(signal)) {
|
|
149
177
|
expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
|
|
150
178
|
const schema = tool.inputSchema;
|
|
179
|
+
expect(isObject(schema), `the input schema of ${tool.name} must be an object`);
|
|
151
180
|
const input = view[tool.name]?.input ?? {};
|
|
181
|
+
const required = (object) => isStringArray(object.required) ? object.required : [];
|
|
152
182
|
expectEqual(schema.type, "object", `type of the input schema of ${tool.name}`);
|
|
153
183
|
expectEqual(sorted(Object.keys(schema.properties ?? {})), sorted(Object.keys(input.properties ?? {})), `properties of ${tool.name}`);
|
|
154
|
-
expectEqual(sorted(schema
|
|
184
|
+
expectEqual(sorted(required(schema)), sorted(required(input)), `required properties of ${tool.name}`);
|
|
155
185
|
expect(isBundled(schema), `the input schema of ${tool.name} must be bundled`);
|
|
156
186
|
}
|
|
157
187
|
});
|
|
@@ -164,7 +194,7 @@ async function checkWebMcp(driver, options) {
|
|
|
164
194
|
await checks.run("a failure resolves as a result", `${RULE}#tools`, async (signal) => {
|
|
165
195
|
const call = await driver.call("fixture.refuse", {}, { signal });
|
|
166
196
|
expect("resolved" in call, `the call must resolve, got ${JSON.stringify(call)}`);
|
|
167
|
-
expectEqual(call.resolved
|
|
197
|
+
expectEqual(memberOf(memberOf(call.resolved, "error"), "_tag"), "Refused", "_tag");
|
|
168
198
|
});
|
|
169
199
|
for (const [name, capability, input, kind, errorName] of [[
|
|
170
200
|
"a defect rejects with the kind internal",
|
package/dist/json.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks of JSON values at run time. A value from a file, a process or a network is not trusted. These checks give it a
|
|
3
|
+
* type only after they examine it.
|
|
4
|
+
*/
|
|
5
|
+
import type { JsonObject } from "@acid-cat/spec-ts";
|
|
6
|
+
export type { JsonObject };
|
|
7
|
+
/** True if the value is a JSON object: not `null`, and not an array. */
|
|
8
|
+
export declare const isObject: (value: unknown) => value is JsonObject;
|
|
9
|
+
/** True if the value is an array of strings. */
|
|
10
|
+
export declare const isStringArray: (value: unknown) => value is readonly string[];
|
|
11
|
+
/** True if the value is a JSON object, and `isMember` is true for each of its member values. */
|
|
12
|
+
export declare const isRecordOf: <T>(value: unknown, isMember: (member: unknown) => member is T) => value is {
|
|
13
|
+
readonly [key: string]: T;
|
|
14
|
+
};
|
|
15
|
+
/** The member `key` of a value, or `undefined` when the value is not an object. */
|
|
16
|
+
export declare const memberOf: (value: unknown, key: string) => unknown;
|
|
17
|
+
/** Parse JSON text. The value is not trusted, so its type is `unknown`. */
|
|
18
|
+
export declare function parseJson(text: string): unknown;
|
|
19
|
+
/** Read the body of a response as JSON. The value is not trusted, so its type is `unknown`. */
|
|
20
|
+
export declare function readJson(response: Response): Promise<unknown>;
|
|
21
|
+
/** True if the value is an array, and `isItem` is true for each of its items. */
|
|
22
|
+
export declare const isArrayOf: <T>(value: unknown, isItem: (item: unknown) => item is T) => value is readonly T[];
|
package/dist/json.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/json.ts
|
|
2
|
+
/** True if the value is a JSON object: not `null`, and not an array. */
|
|
3
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
/** True if the value is an array of strings. */
|
|
5
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
6
|
+
/** True if the value is a JSON object, and `isMember` is true for each of its member values. */
|
|
7
|
+
const isRecordOf = (value, isMember) => isObject(value) && Object.values(value).every(isMember);
|
|
8
|
+
/** The member `key` of a value, or `undefined` when the value is not an object. */
|
|
9
|
+
const memberOf = (value, key) => isObject(value) ? value[key] : void 0;
|
|
10
|
+
/** Parse JSON text. The value is not trusted, so its type is `unknown`. */
|
|
11
|
+
function parseJson(text) {
|
|
12
|
+
return JSON.parse(text);
|
|
13
|
+
}
|
|
14
|
+
/** Read the body of a response as JSON. The value is not trusted, so its type is `unknown`. */
|
|
15
|
+
async function readJson(response) {
|
|
16
|
+
return await response.json();
|
|
17
|
+
}
|
|
18
|
+
/** True if the value is an array, and `isItem` is true for each of its items. */
|
|
19
|
+
const isArrayOf = (value, isItem) => Array.isArray(value) && value.every(isItem);
|
|
20
|
+
//#endregion
|
|
21
|
+
export { isArrayOf, isObject, isRecordOf, isStringArray, memberOf, parseJson, readJson };
|
package/dist/main.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { parseJson } from "./json.js";
|
|
3
|
+
import { check, isCatalog, viewOf } from "./catalog.js";
|
|
3
4
|
import { DEADLINE } from "./conform/check.js";
|
|
4
5
|
import { conform } from "./conform/index.js";
|
|
5
6
|
import { version } from "./json/package.json.js";
|
|
@@ -17,7 +18,7 @@ const readCatalog = Effect.fn("readCatalog")(function* (file) {
|
|
|
17
18
|
const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
|
|
18
19
|
return {
|
|
19
20
|
text,
|
|
20
|
-
document:
|
|
21
|
+
document: parseJson(text)
|
|
21
22
|
};
|
|
22
23
|
});
|
|
23
24
|
const fail = Effect.sync(() => {
|
|
@@ -55,8 +56,11 @@ const viewCommand = Command.make("view", {
|
|
|
55
56
|
file: Argument.String("catalog").pipe(Argument.withDescription("A catalog file")),
|
|
56
57
|
target: Argument.String("target").pipe(Argument.withDescription("The name of a target in the catalog"))
|
|
57
58
|
}, Effect.fn(function* ({ file, target }) {
|
|
58
|
-
const { document } = yield* readCatalog(file);
|
|
59
|
-
|
|
59
|
+
const { document: catalog } = yield* readCatalog(file);
|
|
60
|
+
if (!isCatalog(catalog)) {
|
|
61
|
+
yield* Console.error(`${file}: error: the file is not a catalog; run \`acid-cat check ${file}\``);
|
|
62
|
+
return yield* fail;
|
|
63
|
+
}
|
|
60
64
|
if (!(target in catalog.targets)) {
|
|
61
65
|
yield* Console.error(`${file}: error: there is no target ${target}`);
|
|
62
66
|
return yield* fail;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The AcidCat command line: check, format and view catalogs, and test implementations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acidcat",
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@acid-cat/catalog": "0.
|
|
41
|
-
"@acid-cat/spec-ts": "0.
|
|
42
|
-
"@effect/platform-node": "4.0.
|
|
43
|
-
"@effect/platform-node-shared": "4.0.
|
|
40
|
+
"@acid-cat/catalog": "0.6.0",
|
|
41
|
+
"@acid-cat/spec-ts": "0.6.0",
|
|
42
|
+
"@effect/platform-node": "4.0.6",
|
|
43
|
+
"@effect/platform-node-shared": "4.0.6",
|
|
44
44
|
"@modelcontextprotocol/client": "2.3.1",
|
|
45
|
-
"effect": "4.0.
|
|
45
|
+
"effect": "4.0.6",
|
|
46
46
|
"fast-check": "4.10.2"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|