diff --git a/.changeset/exact-text-io.md b/.changeset/exact-text-io.md new file mode 100644 index 0000000..cff342f --- /dev/null +++ b/.changeset/exact-text-io.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Send and read exact text. `--text-file =` puts a file's exact UTF-8 content, or stdin with `-`, in one field of a `call` or `api` input, such as `changes.0.content`; `--raw ` prints one text field of the result without JSON quotes or an added newline. Conflicting, unsafe or ambiguous fields, and a `--raw` field the declared output schema rules out, are refused before anything is sent; when a request succeeded but its field is not text, the error says so and keeps the full result. `fulldev api` errors keep the server's details, such as `status` and a conflict's `data`. Add Fulldev Meet as the app `meet`. diff --git a/README.md b/README.md index 81d1d9b..7c6c2e0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Use Fulldev apps from a terminal or an AI agent. The CLI discovers the full HTTP | `pages` | Reports and plans as web pages with [Fulldev Pages](https://pages.full.dev), for administrators only | | `contacts` | [Daily synchronization of your Google contacts across multiple accounts with Import and Export](https://contacts.full.dev) | | `send` | [Customer message scenarios and deliveries](https://send.full.dev) | +| `meet` | Your meetings, transcripts and screenshots for follow-up with [Fulldev Meet](https://meet.full.dev) | | `work` | Tasks, projects and goals in your organization | ## Install @@ -89,6 +90,30 @@ On the server the organization is the tool's `organization` argument, which ever `call` prints the tool's result as JSON on stdout. When the tool fails, it prints the error on stderr and exits with 1. +## Exact text + +For Markdown and other text, `--text-file =` puts a file's exact UTF-8 content in one field of the input, and `--raw ` prints one text field of the result as it is, without JSON quotes or an added newline. Both work with `call` and `api`. Nothing is trimmed, and line endings stay as they are. + +```sh +fulldev cms call commit_files '{"branchId":"","expectedRevision":"","changes":[{"path":"src/content/home.md"}]}' \ + --text-file changes.0.content=home.md +fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' --raw content > home.md +fulldev pages call export_page_markdown '{"pageId":""}' --raw markdown > page.md +``` + +To change a task's notes only when nobody else did, read them first and send back the `notesVersion` from that same result as `expectedNotesVersion`. This short token also works for large notes; without a guard, the update works as before: + +```sh +fulldev work call get_task '{"taskId":""}' +fulldev work call update_task '{"taskId":"","expectedNotesVersion":""}' --text-file notes=notes.md +``` + +- A field is a dotted path, such as `content.blocks.0.markdown`. Objects on the way are made; arrays must be in the JSON, with the item. +- The JSON leaves that field out, and each field is named once. `-` reads stdin, for one input only. +- Names such as `__proto__` and `constructor`, an existing field, a missing array item, and text that is not UTF-8 are usage errors (exit 64), before anything is sent. +- When the tool's output schema or the API's response schema shows the field can never be text, `--raw` refuses before sending (exit 64). It does not work with `--all-orgs`. +- When the request succeeded but its field is missing or not text, `--raw` prints nothing on stdout and exits with 1. The error has `"succeeded": true` and the full `result`, such as a new page's id: use it, and do not send the request again. + `fulldev cms form wait` keeps calling `cms_wait_for_form` until the person sends the form, with a progress line on stderr each round, then prints the result. After `--timeout` minutes (default 30) it stops, also in the middle of a request, prints the form id, and exits with 2, so you can run it again. ## Output and exit codes @@ -152,4 +177,4 @@ fulldev api contacts.accounts.list '{}' --org my-company fulldev contacts api accounts.list --file input.json --org my-company ``` -Use an operation id from `fulldev api `. Input is a JSON object, a file with `--file`, or stdin with `-`. Reads send query parameters; changes send JSON. Existing OAuth credentials and organization grants cover both interfaces. The CLI refuses redirects and paths outside the selected app before sending credentials. Servers without OpenAPI continue to support the existing MCP commands. +Use an operation id from `fulldev api `. Input is a JSON object, a file with `--file`, or stdin with `-`, with `--text-file` and `--raw` as for `call`. Reads send query parameters; changes send JSON. An error keeps the server's details, such as `status` and a conflict's `data`. Existing OAuth credentials and organization grants cover both interfaces. The CLI refuses redirects and paths outside the selected app before sending credentials. Servers without OpenAPI continue to support the existing MCP commands. diff --git a/dist/cli.mjs b/dist/cli.mjs index b053f5a..6df203a 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -4,7 +4,7 @@ import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node: import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { Client, OAuthError, OAuthErrorCode, ProtocolError, SdkError, StreamableHTTPClientTransport, UnauthorizedError, auth, checkResourceAllowed, discoverAuthorizationServerMetadata, discoverOAuthServerInfo, refreshAuthorization, resourceUrlFromServerUrl } from "@modelcontextprotocol/client"; -import { text } from "node:stream/consumers"; +import { buffer } from "node:stream/consumers"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; @@ -313,9 +313,178 @@ var CredentialStore = class { //#region package.json var version = "0.6.1"; //#endregion +//#region src/fields.ts +/** Names that would reach an object's prototype instead of its own data. */ +const unsafe = /* @__PURE__ */ new Set([ + "__proto__", + "prototype", + "constructor" +]); +const index = /^(?:0|[1-9]\d*)$/; +const fieldName = (field) => field.join("."); +function parseField(text, flag) { + const field = text.split("."); + if (field.some((segment) => segment === "")) throw new UsageError(`${flag} needs a field such as content or changes.0.content, not "${text}".`); + const bad = field.find((segment) => unsafe.has(segment)); + if (bad) throw new UsageError(`${flag} cannot use the field name ${bad}.`); + return field; +} +const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +/** +* Sets `text` at `field` of the tool input. Objects on the way are created +* when missing; arrays must already be there. A field the input already has +* is a conflict: the person names each field once. +*/ +function setText(input, field, text) { + const name = fieldName(field); + const fail = (reason) => { + throw new UsageError(`Cannot set --text-file ${name}: ${reason}`); + }; + let container = input; + for (const [position, segment] of field.entries()) { + const last = position === field.length - 1; + const at = fieldName(field.slice(0, position + 1)); + if (Array.isArray(container)) { + if (last) fail(`${at} is an array element; set a field inside it.`); + if (!index.test(segment) || Number(segment) >= container.length) fail(`${fieldName(field.slice(0, position))} has no item ${segment}.`); + container = container[Number(segment)]; + continue; + } + if (!isRecord(container)) return fail(`${fieldName(field.slice(0, position))} is not an object.`); + if (last) { + if (Object.hasOwn(container, segment)) fail(`the input already has ${at}. Leave it out of the JSON.`); + container[segment] = text; + return; + } + if (!Object.hasOwn(container, segment)) { + if (index.test(field[position + 1])) fail(`${at} is missing; give the array in the JSON input.`); + container[segment] = {}; + } + container = container[segment]; + } +} +/** +* --raw: the text at `field` of a successful request's result. When it is +* not text, the error says the request succeeded and keeps its result, such +* as a new page's id, so nobody sends a change twice. +*/ +function rawText(result, field, readOnly) { + let current = result; + for (const segment of field) { + current = Array.isArray(current) && index.test(segment) ? current[Number(segment)] : isRecord(current) && Object.hasOwn(current, segment) ? current[segment] : void 0; + if (current === void 0) break; + } + if (typeof current === "string") return current; + const kind = current === void 0 ? "missing" : current === null ? "null" : Array.isArray(current) ? "an array" : typeof current === "object" ? "an object" : `a ${typeof current}`; + const name = fieldName(field); + throw new CliError("RAW_NOT_TEXT", `The request succeeded${readOnly ? "" : " and any change it made is kept"}, but --raw ${name} needs text, and the result's ${name} is ${kind}. The full result is under "result"${readOnly ? "." : "; do not send the request again."}`, exitCodes.error, { + succeeded: true, + result + }); +} +const article = (type) => type === "null" ? "null" : /^[aeiou]/.test(type) ? `an ${type}` : `a ${type}`; +/** A local reference such as #/$defs/page or #/components/schemas/Page. */ +function resolve(root, ref) { + if (ref !== "#" && !ref.startsWith("#/")) return void 0; + let node = root; + for (const part of ref === "#" ? [] : ref.slice(2).split("/")) { + const key = part.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!isRecord(node) && !Array.isArray(node) || !Object.hasOwn(node, key)) return void 0; + node = node[key]; + } + return node; +} +/** +* Why a declared output schema rules out text at `field`, or undefined when +* it may be text. It trusts only what the schema proves: types, properties +* closed with additionalProperties false, array items, local $refs, and +* unions whose every branch rules it out. An open object, a pattern or an +* unknown keyword may hold text. +*/ +function ruledOutText(schema, field, root = schema) { + let budget = 200; + const visit = (node, position) => { + if (--budget < 0 || !isRecord(node)) return void 0; + if (typeof node.$ref === "string") return visit(resolve(root, node.$ref), position); + if (Array.isArray(node.allOf)) { + const ruled = node.allOf.map((branch) => visit(branch, position)).find((reason) => reason !== void 0); + if (ruled) return ruled; + } + for (const branches of [node.anyOf, node.oneOf]) { + if (!Array.isArray(branches) || branches.length === 0) continue; + const reasons = branches.map((branch) => visit(branch, position)); + if (reasons.every((reason) => reason !== void 0)) return reasons[0]; + } + const types = typeof node.type === "string" ? [node.type] : Array.isArray(node.type) ? node.type.filter((type) => typeof type === "string") : void 0; + const at = fieldName(field.slice(0, position)) || "the result"; + const is = () => `${at} is ${types.map(article).join(" or ")}`; + const segment = field[position]; + if (segment === void 0) return types && !types.includes("string") ? is() : void 0; + const reasons = []; + if (!types || types.includes("object")) { + const properties = isRecord(node.properties) ? node.properties : {}; + const composed = [ + "allOf", + "anyOf", + "oneOf", + "patternProperties" + ].some((key) => node[key] !== void 0); + reasons.push(Object.hasOwn(properties, segment) ? visit(properties[segment], position + 1) : composed ? void 0 : node.additionalProperties === false ? `${at} has no ${segment}` : visit(node.additionalProperties, position + 1)); + } + if (index.test(segment) && (!types || types.includes("array"))) { + const item = Number(segment); + const prefix = Array.isArray(node.prefixItems) ? node.prefixItems : []; + reasons.push(item < prefix.length ? visit(prefix[item], position + 1) : node.items === false ? `${at} has no item ${segment}` : visit(node.items, position + 1)); + } + if (reasons.length === 0) return `${is()}, so it has no ${segment}`; + return reasons.every((reason) => reason !== void 0) ? reasons[0] : void 0; + }; + return visit(schema, 0); +} +//#endregion //#region src/input.ts -/** Reads a tool's arguments: a JSON object from the argument, a file or stdin. */ -async function readToolArguments({ json, file, readStdin = () => text(process.stdin) }) { +/** Strict UTF-8 that keeps a byte order mark, so the text stays exactly as written. */ +const exact = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true +}); +/** Parses the --text-file flags; each field once, and stdin for one input only. */ +function parseTextFiles(specs, json) { + const textFiles = specs.map((spec) => { + const split = spec.indexOf("="); + if (split <= 0 || split === spec.length - 1) throw new UsageError(`--text-file needs =, such as content=page.md, not "${spec}".`); + return { + field: parseField(spec.slice(0, split), "--text-file"), + path: spec.slice(split + 1) + }; + }); + for (const [position, { field }] of textFiles.entries()) for (const other of textFiles.slice(position + 1)) { + const [short, long] = field.length <= other.field.length ? [field, other.field] : [other.field, field]; + if (short.every((segment, at) => segment === long[at])) throw new UsageError(short.length === long.length ? `--text-file names ${fieldName(short)} twice.` : `--text-file sets ${fieldName(short)} and ${fieldName(long)}, which is inside it.`); + } + if (textFiles.filter(({ path }) => path === "-").length + (json === "-" ? 1 : 0) > 1) throw new UsageError("Only one input can come from stdin: the JSON with - or one --text-file."); + return textFiles; +} +/** A text file's exact content; no trimming and no newline changes. */ +async function readText(path, readStdin) { + let bytes; + if (path === "-") bytes = await readStdin(); + else try { + bytes = await readFile(path); + } catch (error) { + throw new UsageError(`Cannot read ${path}: ${error.code ?? error.message}`); + } + try { + return exact.decode(bytes); + } catch { + throw new UsageError(`${path === "-" ? "stdin" : path} is not valid UTF-8 text.`); + } +} +/** +* Reads a tool's arguments: a JSON object from the argument, a file or stdin, +* with the exact text of each --text-file in its field. +*/ +async function readToolArguments({ json, file, textFiles = [], readStdin = () => buffer(process.stdin) }) { if (json !== void 0 && file !== void 0) throw new UsageError("Give the JSON as an argument or with --file, not both."); let source = "{}"; let from = "the argument"; @@ -328,7 +497,7 @@ async function readToolArguments({ json, file, readStdin = () => text(process.st } } else if (json === "-") { from = "stdin"; - source = await readStdin(); + source = new TextDecoder().decode(await readStdin()); } else if (json !== void 0) source = json; if (!source.trim()) source = "{}"; let value; @@ -338,7 +507,9 @@ async function readToolArguments({ json, file, readStdin = () => text(process.st throw new UsageError(`The tool input from ${from} is not valid JSON: ${error.message}`); } if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`); - return value; + const input = value; + for (const { field, path } of textFiles) setText(input, field, await readText(path, readStdin)); + return input; } //#endregion //#region src/oauth.ts @@ -660,7 +831,7 @@ async function readJson(response) { return {}; } } -const text$1 = (value) => typeof value === "string" ? value : void 0; +const text = (value) => typeof value === "string" ? value : void 0; /** * Refreshes the target's tokens. Call it while holding the store's lock. * A refresh token the server no longer accepts is dropped, and the command @@ -724,7 +895,7 @@ async function revokeTokens(target, credentials, fetchFn = fetch) { }); if (!response.ok) { const answer = await readJson(response); - failures.push(`${hint}: ${[text$1(answer.error) ?? `HTTP ${response.status}`, text$1(answer.error_description)].filter(Boolean).join(": ")}`); + failures.push(`${hint}: ${[text(answer.error) ?? `HTTP ${response.status}`, text(answer.error_description)].filter(Boolean).join(": ")}`); } } return failures.length ? { @@ -796,13 +967,23 @@ async function runApi(command, io, fetchFn = fetch) { signal: AbortSignal.timeout(3e4) }); if (!specResponse.ok) throw new CliError("API_UNAVAILABLE", "This server does not expose the app's OpenAPI specification."); - const operations = apiOperations(await specResponse.json()); + const spec = await specResponse.json(); + const operations = apiOperations(spec); if (!command.operation) return operations; const operation = operations.find((candidate) => candidate.operationId === command.operation); if (!operation) throw new CliError("NOT_FOUND", `Unknown API operation: ${command.operation}. Run fulldev api ${app.name}.`); + const { raw } = command; + if (raw) { + const reasons = Object.entries(operation.responses ?? {}).filter(([status]) => status.startsWith("2") || status === "default").flatMap(([, response]) => { + const representations = Object.values(response.content ?? {}); + return representations.length === 0 ? [void 0] : representations.map(({ schema }) => ruledOutText(schema, raw, spec)); + }); + if (reasons.length > 0 && reasons.every((reason) => reason !== void 0)) throw new UsageError(`--raw ${fieldName(raw)} can never be text: the API's response schema says ${reasons[0]}. Nothing was sent.`, `fulldev api ${app.name}`); + } const input = await readToolArguments({ json: command.json, - file: command.file + file: command.file, + textFiles: command.textFiles }); const payload = { ...input }; const path = operation.path.replace(/\{([^{}]+)\}/g, (_, name) => { @@ -858,11 +1039,14 @@ async function runApi(command, io, fetchFn = fetch) { throw new CliError("API_FAILED", `API returned a non-JSON response (${response.status}).`); }); if (!response.ok) { - const failure = body; - const error = failure?.error ?? failure ?? {}; - throw new CliError(error.code ?? "API_FAILED", error.message ?? `API request failed (${response.status}).`); + const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0; + const { code, message, defined: _defined, ...details } = record(record(body)?.error) ?? record(body) ?? {}; + throw new CliError(typeof code === "string" ? code : "API_FAILED", typeof message === "string" ? message : `API request failed (${response.status}).`, exitCodes.error, { + ...details, + status: response.status + }); } - return body; + return raw ? rawText(body, raw, reads) : body; } //#endregion //#region src/apps.ts @@ -910,6 +1094,11 @@ const apps = [ title: "Fulldev Send", description: "Manage customer message scenarios and their deliveries." }, + { + name: "meet", + title: "Fulldev Meet", + description: "Read your meetings, transcripts, recording coverage and screenshots for follow-up." + }, { name: "work", title: "Fulldev Work", @@ -944,17 +1133,31 @@ function resolveTarget(flagUrl, env) { } //#endregion //#region src/args.ts +const textDetails = ` +--text-file = puts a file's exact text in one field of the +input, such as content=page.md or changes.0.content=page.md; - reads stdin. +Nothing is trimmed or changed. The JSON must leave that field out; objects +on the way are made, arrays must be in the JSON. --raw prints one +text field of the result exactly, without JSON quotes or an added newline. +It refuses before sending when the output schema rules that field out. When +a request succeeded but the field is not text, the error has "succeeded" +and the full "result": do not send it again.`; const apiTopic = { usage: "fulldev api [.] [JSON | -] [--file ] [--org ]", summary: "List or call an operation of the app's OpenAPI API", options: [ "url", "file", + "text-file", + "raw", "org", "login", "browser" ], - details: "Without an operation, lists the API operations and their schemas. Calls use the same login and organization grants as MCP. Data is JSON on stdout; errors go to stderr." + details: `Without an operation, lists the API operations and their schemas. Calls +use the same login and organization grants as MCP. Data is JSON on stdout; +errors go to stderr. +${textDetails}` }; const organizationDetails = ` When the sign-in is for more than one organization, --org names the one to @@ -1023,6 +1226,8 @@ annotations. Each app's tools start with its name, such as cms_.` summary: "Call a tool with a JSON object", options: [ "file", + "text-file", + "raw", "org", "all-orgs", "url", @@ -1032,6 +1237,7 @@ annotations. Each app's tools start with its name, such as cms_.` details: `The tool's input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}` }, help: { @@ -1073,6 +1279,8 @@ description, input schema, output schema and annotations.` summary: "Call one of the app's tools with a JSON object", options: [ "file", + "text-file", + "raw", "org", "all-orgs", "url", @@ -1084,6 +1292,7 @@ list_repositories calls cms_list_repositories. Its input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}` } }; @@ -1104,6 +1313,9 @@ line on stderr each round, then prints the result. After --timeout minutes const optionHelp = { url: "--url Use another server, such as a deploy preview's", file: "-f, --file Read the tool's JSON input from a file", + "text-file": `--text-file = + Put a file's exact text in a field; - reads stdin`, + raw: "--raw Print a text field of the result as it is", timeout: "--timeout How long to wait (default 30)", login: "--no-login Fail with exit code 3 instead of signing in", browser: "--no-browser Print the sign-in link, for a browser here or elsewhere", @@ -1219,6 +1431,18 @@ function closestTopic(words) { return ""; } const helpFor = (topic) => topic ? `fulldev ${topic} --help` : "fulldev --help"; +/** --text-file and --raw, with errors that point to the command's help. */ +function textOptions(values, json, topic) { + try { + return { + textFiles: parseTextFiles(values["text-file"] ?? [], json), + raw: values.raw === void 0 ? void 0 : parseField(values.raw, "--raw") + }; + } catch (error) { + if (error instanceof UsageError) throw new UsageError(error.message, helpFor(topic)); + throw error; + } +} function parseCommandLine(argv, env = process.env) { let parsed; try { @@ -1233,6 +1457,11 @@ function parseCommandLine(argv, env = process.env) { type: "string", short: "f" }, + "text-file": { + type: "string", + multiple: true + }, + raw: { type: "string" }, org: { type: "string" }, "all-orgs": { type: "boolean" }, timeout: { type: "string" }, @@ -1305,7 +1534,7 @@ function parseCommandLine(argv, env = process.env) { const words = first === "api" ? rest : rest.slice(1); const operation = first === "api" ? words[0]?.split(".").slice(1).join(".") : words[0]; if (words.length > 2) throw new UsageError(`Usage: ${apiTopic.usage}`); - if (!operation && (words[1] || values.file)) throw new UsageError("Choose an API operation before giving arguments."); + if (!operation && (words[1] || values.file || values["text-file"] || values.raw)) throw new UsageError("Choose an API operation before giving arguments."); return { kind: "api", target, @@ -1314,6 +1543,7 @@ function parseCommandLine(argv, env = process.env) { operation: operation || void 0, json: words[1], file: values.file, + ...textOptions(values, words[1], "api"), organization: values.org }; } @@ -1356,6 +1586,7 @@ function parseCommandLine(argv, env = process.env) { case "call": usage(args.length === 1 || args.length === 2); if (values.org !== void 0 && values["all-orgs"]) throw new UsageError("Use --org or --all-orgs, not both.", helpFor(topic)); + if (values.raw !== void 0 && values["all-orgs"]) throw new UsageError("--raw prints one result; use it with --org, not --all-orgs.", helpFor(topic)); return { kind: "call", target, @@ -1363,6 +1594,7 @@ function parseCommandLine(argv, env = process.env) { tool: app ? toolName(app, args[0]) : args[0], json: args[1], file: values.file, + ...textOptions(values, args[1], topic), organization: values.org, allOrganizations: values["all-orgs"] }; @@ -1727,7 +1959,7 @@ async function run(command, io) { case "status": return status(command.target, io); case "api": { const data = await runApi(command, io); - io.stdout(json(data)); + io.stdout(command.raw ? data : json(data)); return exitCodes.ok; } case "instructions": { @@ -1763,19 +1995,30 @@ async function run(command, io) { case "call": { const args = await readToolArguments({ json: command.json, - file: command.file + file: command.file, + textFiles: command.textFiles }); if (command.allOrganizations) return callInEvery(command, args, io); if (command.organization !== void 0) args.organization = command.organization; - const result = await withClient(command.target, command.session, io, (client) => client.callTool({ - name: command.tool, - arguments: args - }, { timeout: requestTimeoutMs })); + const { raw } = command; + const { result, readOnly } = await withClient(command.target, command.session, io, async (client) => { + const tool = raw ? (await listTools(client)).find(({ name }) => name === command.tool) : void 0; + const ruledOut = raw && ruledOutText(tool?.outputSchema, raw); + if (raw && ruledOut) throw new UsageError(`--raw ${fieldName(raw)} can never be text: the tool's output schema says ${ruledOut}. Nothing was sent.`, `fulldev tools ${command.tool}`); + return { + result: await client.callTool({ + name: command.tool, + arguments: args + }, { timeout: requestTimeoutMs }), + readOnly: tool?.annotations?.readOnlyHint === true + }; + }); if (result.isError) { io.stderr(json(toolError(result))); return exitCodes.error; } - io.stdout(json(toolOutput(result))); + const value = toolOutput(result); + io.stdout(raw ? rawText(value, raw, readOnly) : json(value)); return exitCodes.ok; } case "form-wait": { diff --git a/package.json b/package.json index 69d255b..d60eb94 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "pages", "scan", "send", + "meet", "sites", "work", "mcp", diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index c54b454..09b4df8 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -1,6 +1,6 @@ --- name: fulldev -description: Use the Fulldev CLI to work with Fulldev apps for the person. Use cms when they want to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. Use connect when they want to use their business tools, such as Shopify, through Fulldev Connect. Use scan to scan websites, only for Fulldev administrators. Use sites when they want Fulldev to make a new website from a brief, only for Fulldev administrators for now. Use pages to publish a report or plan as a clear web page, only for Fulldev administrators for now. Use send for customer message scenarios and deliveries. Use work for tasks, projects and goals in their organization. Use contacts when they want to check, start, pause or resume the daily synchronization of their Google contacts across multiple accounts with Import and Export in Fulldev Contacts, or answer its look-alike questions. +description: Use the Fulldev CLI to work with Fulldev apps for the person. Use cms when they want to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. Use connect when they want to use their business tools, such as Shopify, through Fulldev Connect. Use scan to scan websites, only for Fulldev administrators. Use sites when they want Fulldev to make a new website from a brief, only for Fulldev administrators for now. Use pages to publish a report or plan as a clear web page, only for Fulldev administrators for now. Use send for customer message scenarios and deliveries. Use meet to read their meetings, transcripts and screenshots for follow-up. Use work for tasks, projects and goals in their organization. Use contacts when they want to check, start, pause or resume the daily synchronization of their Google contacts across multiple accounts with Import and Export in Fulldev Contacts, or answer its look-alike questions. license: MIT compatibility: Needs Node.js 24 or later and network access to app.full.dev. --- @@ -18,6 +18,7 @@ The `fulldev` CLI uses the shared HTTP APIs and the Fulldev MCP server, which ha | `pages` | Publishing a report or plan as a web page with Fulldev Pages. Only for Fulldev administrators for now. | | `contacts` | Daily synchronization of Google contacts across multiple accounts with Import and Export: runs, look-alike questions, pausing and resuming. | | `send` | Customer message scenarios and deliveries. | +| `meet` | The person's meetings, transcripts, recording coverage and screenshots, for follow-up. | | `work` | Tasks, projects and goals in their organization. | ## HTTP API @@ -81,7 +82,7 @@ Read `fulldev tools ` before the first call to a tool. ## Call tools -Give a tool's input as a JSON argument, from a file with `--file`, or from stdin with `-`. Use stdin or `--file` for long or quoted text, so the shell does not change it. +Give a tool's input as a JSON argument, from a file with `--file`, or from stdin with `-`. Put Markdown and other long text in its field with `--text-file =`, so it arrives exactly as written. ```sh fulldev cms call list_repositories @@ -91,6 +92,27 @@ fulldev cms call commit_files - <<'JSON' JSON ``` +## Exact text + +`--text-file =` puts a file's exact text in one field of the input; `-` reads stdin. Leave that field out of the JSON; arrays and their items must be in it. `--raw ` prints one text field of the result as it is, for a file. Both work with `call` and `api`. When `--raw` fails with `"succeeded": true`, the request went through: take what you need from `result`, such as a new id, and never send it again. + +```sh +fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' --raw content > home.md +fulldev cms call commit_files '{"branchId":"","expectedRevision":"","changes":[{"path":"src/content/home.md"}]}' --text-file changes.0.content=home.md +fulldev pages call export_page_markdown '{"pageId":""}' --raw markdown > page.md +``` + +The page export is for reading: it does not hold every block, so never publish it back as the page. + +To change a task's notes without overwriting someone else's change, send the `notesVersion` from the same result as the notes you read as `expectedNotesVersion`. This short token also works for large notes: + +```sh +fulldev work call get_task '{"taskId":""}' +fulldev work call update_task '{"taskId":"","expectedNotesVersion":""}' --text-file notes=notes.md +``` + +On a conflict, read again and redo the change; never drop `expectedNotesVersion`, `expectedNotes`, `expectedVersion` or `expectedRevision` to force it. + ## Forms in the CMS After `create_form`, give the person the form link. Then run: diff --git a/src/api.test.ts b/src/api.test.ts index e07dbdf..c233eb4 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -180,6 +180,28 @@ describe("HTTP API command", () => { }) expect(fetchFn).toHaveBeenCalledTimes(2) }) + it("reports the response's own HTTP status, whatever the body says", async () => { + const fetchFn = vi.fn(async (url) => + new Request(url).url.endsWith("openapi.json") + ? Response.json(spec) + : Response.json( + { + defined: true, + code: "CONFLICT", + status: 200, + message: "The record changed.", + data: { currentVersion: 4 }, + }, + { status: 409 }, + ), + ) + await expect( + runApi(command(["api", "contacts.records.update", "{}"]), io, fetchFn), + ).rejects.toMatchObject({ + code: "CONFLICT", + details: { status: 409, data: { currentVersion: 4 } }, + }) + }) it("rejects unknown operations and paths outside the app before obtaining credentials", async () => { const fetchFn = vi.fn(async () => Response.json(spec)) await expect( diff --git a/src/api.ts b/src/api.ts index 4b82434..016b9ff 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,12 @@ import type { Command } from "./args.ts" import type { Io } from "./main.ts" -import { CliError, SignInRequiredError } from "./errors.ts" +import { + CliError, + SignInRequiredError, + UsageError, + exitCodes, +} from "./errors.ts" +import { fieldName, rawText, ruledOutText } from "./fields.ts" import { readToolArguments } from "./input.ts" import { StoredTokenAuth, browserLogin } from "./oauth.ts" @@ -12,6 +18,7 @@ interface Operation { operationId?: string summary?: string parameters?: Array + responses?: Record }> } interface Specification { paths?: Record> @@ -48,7 +55,8 @@ export async function runApi( "API_UNAVAILABLE", "This server does not expose the app's OpenAPI specification.", ) - const operations = apiOperations((await specResponse.json()) as Specification) + const spec = (await specResponse.json()) as Specification + const operations = apiOperations(spec) if (!command.operation) return operations const operation = operations.find( (candidate) => candidate.operationId === command.operation, @@ -58,9 +66,27 @@ export async function runApi( "NOT_FOUND", `Unknown API operation: ${command.operation}. Run fulldev api ${app.name}.`, ) + const { raw } = command + if (raw) { + // Before anything is sent: does every success response rule out text? + const reasons = Object.entries(operation.responses ?? {}) + .filter(([status]) => status.startsWith("2") || status === "default") + .flatMap(([, response]) => { + const representations = Object.values(response.content ?? {}) + return representations.length === 0 + ? [undefined] + : representations.map(({ schema }) => ruledOutText(schema, raw, spec)) + }) + if (reasons.length > 0 && reasons.every((reason) => reason !== undefined)) + throw new UsageError( + `--raw ${fieldName(raw)} can never be text: the API's response schema says ${reasons[0]}. Nothing was sent.`, + `fulldev api ${app.name}`, + ) + } const input = await readToolArguments({ json: command.json, file: command.file, + textFiles: command.textFiles, }) const payload = { ...input } const path = operation.path.replace(/\{([^{}]+)\}/g, (_, name: string) => { @@ -154,16 +180,26 @@ export async function runApi( ) }) if (!response.ok) { - const failure = body as { - code?: string - message?: string - error?: { code?: string; message?: string } - } | null - const error = failure?.error ?? failure ?? {} + // {"error":{...}} from the server, or an oRPC error with its data, such as + // a conflict's current version: everything but code and message stays. + const record = (value: unknown) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined + const { + code, + message, + defined: _defined, + ...details + } = record(record(body)?.error) ?? record(body) ?? {} throw new CliError( - error.code ?? "API_FAILED", - error.message ?? `API request failed (${response.status}).`, + typeof code === "string" ? code : "API_FAILED", + typeof message === "string" + ? message + : `API request failed (${response.status}).`, + exitCodes.error, + { ...details, status: response.status }, ) } - return body + return raw ? rawText(body, raw, reads) : body } diff --git a/src/apps.test.ts b/src/apps.test.ts index dbcda59..2cf6dc3 100644 --- a/src/apps.test.ts +++ b/src/apps.test.ts @@ -5,7 +5,7 @@ import { UsageError } from "./errors.ts" import { apps, findApp, resolveTarget, server, toolName } from "./apps.ts" describe("apps", () => { - it("has cms, connect, scan, sites, pages and contacts on the one server", () => { + it("has every app of the one server", () => { expect(apps.map(({ name }) => name)).toEqual([ "cms", "connect", @@ -14,6 +14,7 @@ describe("apps", () => { "pages", "contacts", "send", + "meet", "work", ]) expect(server.url).toBe("https://app.full.dev/mcp") diff --git a/src/apps.ts b/src/apps.ts index 424c13c..45615bc 100644 --- a/src/apps.ts +++ b/src/apps.ts @@ -63,6 +63,12 @@ export const apps: Array = [ title: "Fulldev Send", description: "Manage customer message scenarios and their deliveries.", }, + { + name: "meet", + title: "Fulldev Meet", + description: + "Read your meetings, transcripts, recording coverage and screenshots for follow-up.", + }, { name: "work", title: "Fulldev Work", diff --git a/src/args.ts b/src/args.ts index fd73236..1f88de3 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,6 +1,10 @@ import { parseArgs } from "node:util" import { UsageError } from "./errors.ts" +import { parseField } from "./fields.ts" +import type { Field } from "./fields.ts" +import { parseTextFiles } from "./input.ts" +import type { TextFile } from "./input.ts" import { apps, findApp, @@ -20,6 +24,9 @@ export type Command = operation?: string json?: string file?: string + textFiles?: Array + /** Print this text field of the result as it is, instead of JSON. */ + raw?: Field organization?: string } | { kind: "help"; topic: string } @@ -45,6 +52,9 @@ export type Command = tool: string json?: string file?: string + textFiles?: Array + /** Print this text field of the result as it is, instead of JSON. */ + raw?: Field /** The organization to work in, by slug or id. */ organization?: string /** Every organization of the sign-in that has the tool's app. */ @@ -70,6 +80,8 @@ export interface Session { type Option = | "url" | "file" + | "text-file" + | "raw" | "timeout" | "login" | "browser" @@ -83,13 +95,25 @@ interface Topic { details?: string } +const textDetails = ` +--text-file = puts a file's exact text in one field of the +input, such as content=page.md or changes.0.content=page.md; - reads stdin. +Nothing is trimmed or changed. The JSON must leave that field out; objects +on the way are made, arrays must be in the JSON. --raw prints one +text field of the result exactly, without JSON quotes or an added newline. +It refuses before sending when the output schema rules that field out. When +a request succeeded but the field is not text, the error has "succeeded" +and the full "result": do not send it again.` + const apiTopic: Topic = { usage: "fulldev api [.] [JSON | -] [--file ] [--org ]", summary: "List or call an operation of the app's OpenAPI API", - options: ["url", "file", "org", "login", "browser"], - details: - "Without an operation, lists the API operations and their schemas. Calls use the same login and organization grants as MCP. Data is JSON on stdout; errors go to stderr.", + options: ["url", "file", "text-file", "raw", "org", "login", "browser"], + details: `Without an operation, lists the API operations and their schemas. Calls +use the same login and organization grants as MCP. Data is JSON on stdout; +errors go to stderr. +${textDetails}`, } const organizationDetails = ` @@ -151,10 +175,20 @@ annotations. Each app's tools start with its name, such as cms_.`, call: { usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call a tool with a JSON object", - options: ["file", "org", "all-orgs", "url", "login", "browser"], + options: [ + "file", + "text-file", + "raw", + "org", + "all-orgs", + "url", + "login", + "browser", + ], details: `The tool's input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}`, }, help: { @@ -185,12 +219,22 @@ description, input schema, output schema and annotations.`, usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call one of the app's tools with a JSON object", - options: ["file", "org", "all-orgs", "url", "login", "browser"], + options: [ + "file", + "text-file", + "raw", + "org", + "all-orgs", + "url", + "login", + "browser", + ], details: `The tool's name may leave out the app's prefix: fulldev cms call list_repositories calls cms_list_repositories. Its input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}`, }, } @@ -208,6 +252,9 @@ line on stderr each round, then prints the result. After --timeout minutes const optionHelp: Record = { url: "--url Use another server, such as a deploy preview's", file: "-f, --file Read the tool's JSON input from a file", + "text-file": `--text-file = + Put a file's exact text in a field; - reads stdin`, + raw: "--raw Print a text field of the result as it is", timeout: "--timeout How long to wait (default 30)", login: "--no-login Fail with exit code 3 instead of signing in", browser: @@ -347,6 +394,25 @@ function closestTopic(words: Array) { const helpFor = (topic: string) => topic ? `fulldev ${topic} --help` : "fulldev --help" +/** --text-file and --raw, with errors that point to the command's help. */ +function textOptions( + values: { "text-file"?: Array; raw?: string }, + json: string | undefined, + topic: string, +) { + try { + return { + textFiles: parseTextFiles(values["text-file"] ?? [], json), + raw: + values.raw === undefined ? undefined : parseField(values.raw, "--raw"), + } + } catch (error) { + if (error instanceof UsageError) + throw new UsageError(error.message, helpFor(topic)) + throw error + } +} + export function parseCommandLine( argv: Array, env: NodeJS.ProcessEnv = process.env, @@ -361,6 +427,8 @@ export function parseCommandLine( options: { url: { type: "string" }, file: { type: "string", short: "f" }, + "text-file": { type: "string", multiple: true }, + raw: { type: "string" }, org: { type: "string" }, "all-orgs": { type: "boolean" }, timeout: { type: "string" }, @@ -439,7 +507,10 @@ export function parseCommandLine( const operation = first === "api" ? words[0]?.split(".").slice(1).join(".") : words[0] if (words.length > 2) throw new UsageError(`Usage: ${apiTopic.usage}`) - if (!operation && (words[1] || values.file)) + if ( + !operation && + (words[1] || values.file || values["text-file"] || values.raw) + ) throw new UsageError("Choose an API operation before giving arguments.") return { kind: "api", @@ -449,6 +520,7 @@ export function parseCommandLine( operation: operation || undefined, json: words[1], file: values.file, + ...textOptions(values, words[1], "api"), organization: values.org, } } @@ -488,6 +560,11 @@ export function parseCommandLine( "Use --org or --all-orgs, not both.", helpFor(topic), ) + if (values.raw !== undefined && values["all-orgs"]) + throw new UsageError( + "--raw prints one result; use it with --org, not --all-orgs.", + helpFor(topic), + ) return { kind: "call", target, @@ -495,6 +572,7 @@ export function parseCommandLine( tool: app ? toolName(app, args[0]!) : args[0]!, json: args[1], file: values.file, + ...textOptions(values, args[1], topic), organization: values.org, allOrganizations: values["all-orgs"], } diff --git a/src/fields.test.ts b/src/fields.test.ts new file mode 100644 index 0000000..bbda14f --- /dev/null +++ b/src/fields.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vite-plus/test" + +import { parseField, ruledOutText } from "./fields.ts" + +const field = (text: string) => parseField(text, "--raw") + +describe("ruledOutText", () => { + const receipt = { + type: "object", + properties: { + pageId: { type: "string" }, + version: { type: "integer" }, + blocks: { type: "array", items: { $ref: "#/$defs/block" } }, + submittedAt: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + $defs: { + block: { + type: "object", + properties: { markdown: { type: "string" } }, + additionalProperties: false, + }, + }, + } + + it.each([ + ["version", "version is an integer"], + ["pageId.sha", "pageId is a string, so it has no sha"], + ["blocks.0.title", "blocks.0 has no title"], + ["blocks.0.markdown.x", "blocks.0.markdown is a string, so it has no x"], + ["blocks.first", "blocks is an array, so it has no first"], + ])("rules out %s", (path, reason) => { + expect(ruledOutText(receipt, field(path))).toBe(reason) + }) + + it.each([ + "pageId", + "blocks.0.markdown", + "submittedAt", + // Not declared, and the object is open. + "markdown", + "anything.deeper", + ])("allows %s", (path) => { + expect(ruledOutText(receipt, field(path))).toBeUndefined() + }) + + it.each([ + ["no schema", undefined], + ["an empty schema", {}], + ["the server's open response schema", { anyOf: [{}, { not: {} }] }], + ["an unknown reference", { $ref: "https://example.com/page.json" }], + ["a reference cycle", { $ref: "#" }], + [ + "a union with an open branch", + { anyOf: [{ type: "integer" }, { type: "object" }] }, + ], + [ + "a pattern of properties", + { + type: "object", + patternProperties: { "^m": { type: "string" } }, + additionalProperties: false, + }, + ], + [ + "properties from a composition", + { + type: "object", + allOf: [{ properties: { markdown: { type: "string" } } }], + additionalProperties: false, + }, + ], + ])("allows any field under %s", (_, schema) => { + expect(ruledOutText(schema, field("markdown"))).toBeUndefined() + }) + + it("rules out a field only when every branch of a union does", () => { + expect( + ruledOutText( + { anyOf: [{ type: "integer" }, { type: "null" }] }, + field("markdown"), + ), + ).toBe("the result is an integer, so it has no markdown") + }) + + it("resolves OpenAPI component references against the whole document", () => { + const spec = { + components: { + schemas: { + Receipt: { + type: "object", + properties: { version: { type: "integer" } }, + }, + }, + }, + } + expect( + ruledOutText( + { $ref: "#/components/schemas/Receipt" }, + field("version"), + spec, + ), + ).toBe("version is an integer") + }) +}) diff --git a/src/fields.ts b/src/fields.ts new file mode 100644 index 0000000..e6db38c --- /dev/null +++ b/src/fields.ts @@ -0,0 +1,198 @@ +import { CliError, UsageError, exitCodes } from "./errors.ts" + +/** A field of a JSON value by its dotted path, such as content.blocks.0.markdown. */ +export type Field = Array + +/** Names that would reach an object's prototype instead of its own data. */ +const unsafe = new Set(["__proto__", "prototype", "constructor"]) + +const index = /^(?:0|[1-9]\d*)$/ + +export const fieldName = (field: Field) => field.join(".") + +export function parseField(text: string, flag: string): Field { + const field = text.split(".") + if (field.some((segment) => segment === "")) + throw new UsageError( + `${flag} needs a field such as content or changes.0.content, not "${text}".`, + ) + const bad = field.find((segment) => unsafe.has(segment)) + if (bad) throw new UsageError(`${flag} cannot use the field name ${bad}.`) + return field +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +/** + * Sets `text` at `field` of the tool input. Objects on the way are created + * when missing; arrays must already be there. A field the input already has + * is a conflict: the person names each field once. + */ +export function setText( + input: Record, + field: Field, + text: string, +) { + const name = fieldName(field) + const fail = (reason: string): never => { + throw new UsageError(`Cannot set --text-file ${name}: ${reason}`) + } + let container: unknown = input + for (const [position, segment] of field.entries()) { + const last = position === field.length - 1 + const at = fieldName(field.slice(0, position + 1)) + if (Array.isArray(container)) { + if (last) fail(`${at} is an array element; set a field inside it.`) + if (!index.test(segment) || Number(segment) >= container.length) + fail(`${fieldName(field.slice(0, position))} has no item ${segment}.`) + container = container[Number(segment)] + continue + } + if (!isRecord(container)) + return fail(`${fieldName(field.slice(0, position))} is not an object.`) + if (last) { + if (Object.hasOwn(container, segment)) + fail(`the input already has ${at}. Leave it out of the JSON.`) + container[segment] = text + return + } + if (!Object.hasOwn(container, segment)) { + if (index.test(field[position + 1]!)) + fail(`${at} is missing; give the array in the JSON input.`) + container[segment] = {} + } + container = container[segment] + } +} + +/** + * --raw: the text at `field` of a successful request's result. When it is + * not text, the error says the request succeeded and keeps its result, such + * as a new page's id, so nobody sends a change twice. + */ +export function rawText( + result: unknown, + field: Field, + readOnly: boolean, +): string { + let current = result + for (const segment of field) { + current = + Array.isArray(current) && index.test(segment) + ? current[Number(segment)] + : isRecord(current) && Object.hasOwn(current, segment) + ? current[segment] + : undefined + if (current === undefined) break + } + if (typeof current === "string") return current + const kind = + current === undefined + ? "missing" + : current === null + ? "null" + : Array.isArray(current) + ? "an array" + : typeof current === "object" + ? "an object" + : `a ${typeof current}` + const name = fieldName(field) + throw new CliError( + "RAW_NOT_TEXT", + `The request succeeded${readOnly ? "" : " and any change it made is kept"}, but --raw ${name} needs text, and the result's ${name} is ${kind}. The full result is under "result"${readOnly ? "." : "; do not send the request again."}`, + exitCodes.error, + { succeeded: true, result }, + ) +} + +const article = (type: string) => + type === "null" ? "null" : /^[aeiou]/.test(type) ? `an ${type}` : `a ${type}` + +/** A local reference such as #/$defs/page or #/components/schemas/Page. */ +function resolve(root: unknown, ref: string): unknown { + if (ref !== "#" && !ref.startsWith("#/")) return undefined + let node = root + for (const part of ref === "#" ? [] : ref.slice(2).split("/")) { + const key = part.replaceAll("~1", "/").replaceAll("~0", "~") + if ((!isRecord(node) && !Array.isArray(node)) || !Object.hasOwn(node, key)) + return undefined + node = (node as Record)[key] + } + return node +} + +/** + * Why a declared output schema rules out text at `field`, or undefined when + * it may be text. It trusts only what the schema proves: types, properties + * closed with additionalProperties false, array items, local $refs, and + * unions whose every branch rules it out. An open object, a pattern or an + * unknown keyword may hold text. + */ +export function ruledOutText( + schema: unknown, + field: Field, + root: unknown = schema, +): string | undefined { + // Bounds reference cycles; running out means "may be text". + let budget = 200 + const visit = (node: unknown, position: number): string | undefined => { + if (--budget < 0 || !isRecord(node)) return undefined + if (typeof node.$ref === "string") + return visit(resolve(root, node.$ref), position) + if (Array.isArray(node.allOf)) { + const ruled = node.allOf + .map((branch) => visit(branch, position)) + .find((reason) => reason !== undefined) + if (ruled) return ruled + } + for (const branches of [node.anyOf, node.oneOf]) { + if (!Array.isArray(branches) || branches.length === 0) continue + const reasons = branches.map((branch) => visit(branch, position)) + if (reasons.every((reason) => reason !== undefined)) return reasons[0] + } + const types = + typeof node.type === "string" + ? [node.type] + : Array.isArray(node.type) + ? node.type.filter((type): type is string => typeof type === "string") + : undefined + const at = fieldName(field.slice(0, position)) || "the result" + const is = () => `${at} is ${types!.map(article).join(" or ")}` + const segment = field[position] + if (segment === undefined) + return types && !types.includes("string") ? is() : undefined + const reasons: Array = [] + if (!types || types.includes("object")) { + const properties = isRecord(node.properties) ? node.properties : {} + const composed = ["allOf", "anyOf", "oneOf", "patternProperties"].some( + (key) => node[key] !== undefined, + ) + reasons.push( + Object.hasOwn(properties, segment) + ? visit(properties[segment], position + 1) + : composed + ? undefined + : node.additionalProperties === false + ? `${at} has no ${segment}` + : visit(node.additionalProperties, position + 1), + ) + } + if (index.test(segment) && (!types || types.includes("array"))) { + const item = Number(segment) + const prefix = Array.isArray(node.prefixItems) ? node.prefixItems : [] + reasons.push( + item < prefix.length + ? visit(prefix[item], position + 1) + : node.items === false + ? `${at} has no item ${segment}` + : visit(node.items, position + 1), + ) + } + if (reasons.length === 0) return `${is()}, so it has no ${segment}` + return reasons.every((reason) => reason !== undefined) + ? reasons[0] + : undefined + } + return visit(schema, 0) +} diff --git a/src/input.test.ts b/src/input.test.ts index e2a38c0..e8d0503 100644 --- a/src/input.test.ts +++ b/src/input.test.ts @@ -5,9 +5,11 @@ import { join } from "node:path" import { describe, expect, it } from "vite-plus/test" import { UsageError } from "./errors.ts" -import { readToolArguments } from "./input.ts" +import { parseTextFiles, readToolArguments } from "./input.ts" const noStdin = () => Promise.reject(new Error("stdin was read")) +const stdin = (text: string) => () => + Promise.resolve(new TextEncoder().encode(text)) describe("readToolArguments", () => { it("defaults to an empty object", async () => { @@ -24,13 +26,13 @@ describe("readToolArguments", () => { expect( await readToolArguments({ json: "-", - readStdin: () => Promise.resolve('{"n":1}\n'), + readStdin: stdin('{"n":1}\n'), }), ).toEqual({ n: 1 }) expect( await readToolArguments({ json: "-", - readStdin: () => Promise.resolve(""), + readStdin: stdin(""), }), ).toEqual({}) }) @@ -58,3 +60,132 @@ describe("readToolArguments", () => { await expect(result).rejects.toThrow(message) }) }) + +describe("--text-file", () => { + const folder = mkdtemp(join(tmpdir(), "fulldev-text-")) + const file = async (content: string | Uint8Array) => { + const path = join(await folder, `${Math.random()}.md`) + await writeFile(path, content) + return path + } + const read = async ( + json: string | undefined, + specs: Array, + readStdin: () => Promise = noStdin, + ) => + readToolArguments({ + json, + textFiles: parseTextFiles(specs, json), + readStdin, + }) + + it.each([ + ["CRLF", "# Title\r\n\r\nLine one\r\nLine two\r\n"], + ["Unicode", "Café ✓ 👩🏽‍💻 e\u0301 日本語"], + ["indentation", " - item\n\t- tab\n code block\n"], + ["empty", ""], + ["a trailing newline", "text\n\n"], + ["no trailing newline", "text"], + ["a byte order mark", "\uFEFFtext"], + ["only whitespace", " \n\t \n"], + ])("keeps %s exactly", async (_, text) => { + const input = await read("{}", [`content=${await file(text)}`]) + expect(input.content).toBe(text) + const piped = await read("{}", ["content=-"], stdin(text)) + expect(piped.content).toBe(text) + }) + + it("sets fields inside existing arrays and new objects, keeping the rest", async () => { + const path = await file("Hello\n") + const input = await read( + '{"pageId":"p","expectedVersion":3,"content":{"blocks":[{"type":"text"}]},"changes":[{"path":"a.md"},{"path":"b.md"}]}', + [ + `content.blocks.0.markdown=${path}`, + `changes.1.content=${path}`, + `brief.about=${path}`, + ], + ) + expect(input).toEqual({ + pageId: "p", + expectedVersion: 3, + content: { blocks: [{ type: "text", markdown: "Hello\n" }] }, + changes: [{ path: "a.md" }, { path: "b.md", content: "Hello\n" }], + brief: { about: "Hello\n" }, + }) + }) + + it("reads the JSON from a file and the text from stdin", async () => { + const json = await file('{"taskId":"t"}') + expect( + await readToolArguments({ + file: json, + textFiles: parseTextFiles(["notes=-"], undefined), + readStdin: stdin("New notes\n"), + }), + ).toEqual({ taskId: "t", notes: "New notes\n" }) + }) + + it("preserves stdin read failures for both input modes", async () => { + const failure = new Error("input stream failed") + const readStdin = () => Promise.reject(failure) + await expect(read("-", [], readStdin)).rejects.toBe(failure) + await expect(read("{}", ["notes=-"], readStdin)).rejects.toBe(failure) + }) + + it.each([ + ["__proto__.polluted=x"], + ["constructor.prototype.polluted=x"], + ["a.__proto__=x"], + ["prototype=x"], + ])("refuses the prototype field %s", (spec) => { + expect(() => parseTextFiles([spec], undefined)).toThrow(UsageError) + expect(({} as Record).polluted).toBeUndefined() + }) + + it.each([ + [["content"], /=/], + [["=a.md"], /=/], + [["content="], /=/], + [["a..b=x"], /needs a field/], + [["content=a.md", "content=b.md"], /twice/], + [["content=a.md", "content.text=b.md"], /inside it/], + [["a=-", "b=-"], /one input can come from stdin/], + ])("refuses %j", (specs, message) => { + expect(() => parseTextFiles(specs, undefined)).toThrow(message) + }) + + it("refuses stdin for both the JSON and a text", () => { + expect(() => parseTextFiles(["content=-"], "-")).toThrow( + /one input can come from stdin/, + ) + }) + + it.each([ + ['{"content":"old"}', "content", /already has content/], + ['{"content":null}', "content", /already has content/], + [ + '{"changes":[{"path":"a","content":"x"}]}', + "changes.0.content", + /already/, + ], + ['{"changes":[{"path":"a"}]}', "changes.1.content", /no item 1/], + ['{"changes":[{"path":"a"}]}', "changes.first.content", /no item first/], + ['{"changes":[{"path":"a"}]}', "changes.0", /array element/], + ["{}", "changes.0.content", /changes is missing/], + ['{"title":"x"}', "title.text", /title is not an object/], + ['{"changes":["a.md"]}', "changes.0.content", /changes.0 is not an object/], + ])("refuses to set into %s at %s", async (json, field, message) => { + const path = await file("text") + await expect(read(json, [`${field}=${path}`])).rejects.toThrow(message) + }) + + it("refuses text that is not UTF-8 or a file that is missing", async () => { + const path = await file(new Uint8Array([0x61, 0xff, 0x62])) + await expect(read("{}", [`content=${path}`])).rejects.toThrow( + /not valid UTF-8/, + ) + await expect(read("{}", ["content=/does/not/exist.md"])).rejects.toThrow( + /Cannot read/, + ) + }) +}) diff --git a/src/input.ts b/src/input.ts index 2b003d8..8057186 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,20 +1,95 @@ import { readFile } from "node:fs/promises" -import { text } from "node:stream/consumers" +import { buffer } from "node:stream/consumers" import { UsageError } from "./errors.ts" +import { fieldName, parseField, setText } from "./fields.ts" +import type { Field } from "./fields.ts" + +/** --text-file =: a file's exact text as one field of the input. */ +export interface TextFile { + field: Field + /** The file, or "-" for stdin. */ + path: string +} export interface ToolInputSource { /** The JSON argument; "-" reads stdin. */ json?: string | undefined file?: string | undefined - readStdin?: () => Promise + textFiles?: Array | undefined + readStdin?: () => Promise +} + +/** Strict UTF-8 that keeps a byte order mark, so the text stays exactly as written. */ +const exact = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + +/** Parses the --text-file flags; each field once, and stdin for one input only. */ +export function parseTextFiles( + specs: Array, + json: string | undefined, +): Array { + const textFiles = specs.map((spec) => { + const split = spec.indexOf("=") + if (split <= 0 || split === spec.length - 1) + throw new UsageError( + `--text-file needs =, such as content=page.md, not "${spec}".`, + ) + return { + field: parseField(spec.slice(0, split), "--text-file"), + path: spec.slice(split + 1), + } + }) + for (const [position, { field }] of textFiles.entries()) + for (const other of textFiles.slice(position + 1)) { + const [short, long] = + field.length <= other.field.length + ? [field, other.field] + : [other.field, field] + if (short.every((segment, at) => segment === long[at])) + throw new UsageError( + short.length === long.length + ? `--text-file names ${fieldName(short)} twice.` + : `--text-file sets ${fieldName(short)} and ${fieldName(long)}, which is inside it.`, + ) + } + const stdin = textFiles.filter(({ path }) => path === "-").length + if (stdin + (json === "-" ? 1 : 0) > 1) + throw new UsageError( + "Only one input can come from stdin: the JSON with - or one --text-file.", + ) + return textFiles } -/** Reads a tool's arguments: a JSON object from the argument, a file or stdin. */ +/** A text file's exact content; no trimming and no newline changes. */ +async function readText(path: string, readStdin: () => Promise) { + let bytes: Uint8Array + if (path === "-") bytes = await readStdin() + else + try { + bytes = await readFile(path) + } catch (error) { + throw new UsageError( + `Cannot read ${path}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`, + ) + } + try { + return exact.decode(bytes) + } catch { + throw new UsageError( + `${path === "-" ? "stdin" : path} is not valid UTF-8 text.`, + ) + } +} + +/** + * Reads a tool's arguments: a JSON object from the argument, a file or stdin, + * with the exact text of each --text-file in its field. + */ export async function readToolArguments({ json, file, - readStdin = () => text(process.stdin), + textFiles = [], + readStdin = () => buffer(process.stdin), }: ToolInputSource): Promise> { if (json !== undefined && file !== undefined) throw new UsageError( @@ -33,7 +108,7 @@ export async function readToolArguments({ } } else if (json === "-") { from = "stdin" - source = await readStdin() + source = new TextDecoder().decode(await readStdin()) } else if (json !== undefined) source = json if (!source.trim()) source = "{}" @@ -47,5 +122,9 @@ export async function readToolArguments({ } if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`) - return value as Record + const input = value as Record + + for (const { field, path } of textFiles) + setText(input, field, await readText(path, readStdin)) + return input } diff --git a/src/main.ts b/src/main.ts index a4548da..c9eabcc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ import { exitCodes, isSignInRequired, } from "./errors.ts" +import { fieldName, rawText, ruledOutText } from "./fields.ts" import { waitForForm } from "./form-wait.ts" import { readToolArguments } from "./input.ts" import { @@ -344,8 +345,9 @@ async function run(command: Command, io: Io): Promise { case "status": return status(command.target, io) case "api": { + // With --raw, runApi selects the text, since it knows whether it read. const data = await runApi(command, io) - io.stdout(json(data)) + io.stdout(command.raw ? (data as string) : json(data)) return exitCodes.ok } case "instructions": { @@ -402,25 +404,45 @@ async function run(command: Command, io: Io): Promise { const args = await readToolArguments({ json: command.json, file: command.file, + textFiles: command.textFiles, }) if (command.allOrganizations) return callInEvery(command, args, io) if (command.organization !== undefined) args.organization = command.organization - const result = await withClient( + const { raw } = command + const { result, readOnly } = await withClient( command.target, command.session, io, - (client) => - client.callTool( - { name: command.tool, arguments: args }, - { timeout: requestTimeoutMs }, - ), + async (client) => { + // Only --raw lists the tools, to check its field against the + // tool's output schema before a call that may change data. + const tool = raw + ? (await listTools(client)).find( + ({ name }) => name === command.tool, + ) + : undefined + const ruledOut = raw && ruledOutText(tool?.outputSchema, raw) + if (raw && ruledOut) + throw new UsageError( + `--raw ${fieldName(raw)} can never be text: the tool's output schema says ${ruledOut}. Nothing was sent.`, + `fulldev tools ${command.tool}`, + ) + return { + result: await client.callTool( + { name: command.tool, arguments: args }, + { timeout: requestTimeoutMs }, + ), + readOnly: tool?.annotations?.readOnlyHint === true, + } + }, ) if (result.isError) { io.stderr(json(toolError(result))) return exitCodes.error } - io.stdout(json(toolOutput(result))) + const value = toolOutput(result) + io.stdout(raw ? rawText(value, raw, readOnly) : json(value)) return exitCodes.ok } case "form-wait": { diff --git a/src/text-io.test.ts b/src/text-io.test.ts new file mode 100644 index 0000000..ed3ede0 --- /dev/null +++ b/src/text-io.test.ts @@ -0,0 +1,490 @@ +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import type * as McpClient from "@modelcontextprotocol/client" +import type { CallToolResult } from "@modelcontextprotocol/client" +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" + +import type { CredentialStore } from "./credentials.ts" +import { main } from "./main.ts" +import type * as OAuth from "./oauth.ts" + +const callTool = vi.hoisted(() => vi.fn()) +const listTools = vi.hoisted(() => vi.fn()) +const connect = vi.hoisted(() => vi.fn()) +vi.mock("@modelcontextprotocol/client", async (original) => ({ + ...(await original()), + Client: class { + connect = connect + callTool = callTool + listTools = listTools + close = async () => {} + }, + StreamableHTTPClientTransport: class {}, +})) +vi.mock("./oauth.ts", async (original) => ({ + ...(await original()), + StoredTokenAuth: class { + token = async () => "private-token" + onUnauthorized = async () => {} + }, +})) + +const signedIn = { + get: async () => ({ tokens: { access_token: "t", token_type: "Bearer" } }), +} as unknown as CredentialStore + +async function run(argv: Array) { + let stdout = "" + let stderr = "" + const code = await main(argv, { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + interactive: false, + env: {}, + store: signedIn, + }) + return { code, stdout, stderr } +} + +const markdown = "# Plan\r\n\r\n\t- Café ✓ 👩🏽‍💻\n indented\n\n" + +async function textFile(content: string) { + const path = join(await mkdtemp(join(tmpdir(), "fulldev-text-")), "page.md") + await writeFile(path, content) + return path +} + +const structured = (data: Record): CallToolResult => ({ + content: [{ type: "text", text: JSON.stringify(data) }], + structuredContent: data, +}) + +beforeEach(() => { + connect.mockReset().mockResolvedValue(undefined) + callTool.mockReset() + listTools.mockReset().mockResolvedValue({ tools: [] }) +}) + +/** cms_commit_files as the server lists it: revision is a string, more may come. */ +const commitFiles = { + name: "cms_commit_files", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { + revision: { type: "string" }, + pullRequest: { + type: "object", + properties: { number: { type: "number" } }, + additionalProperties: {}, + }, + }, + required: ["revision"], + additionalProperties: {}, + }, + annotations: { readOnlyHint: false, destructiveHint: true }, +} + +describe("call with --text-file and --raw", () => { + it("sends the exact text with the JSON input, its version and organization", async () => { + callTool.mockResolvedValue(structured({ pageId: "p", version: 8 })) + const path = await textFile(markdown) + const { code, stdout } = await run([ + "pages", + "call", + "update_page", + '{"pageId":"p","expectedVersion":7,"content":{"blocks":[{"type":"text"}]}}', + "--text-file", + `content.blocks.0.markdown=${path}`, + "--org", + "acme", + ]) + expect(code).toBe(0) + expect(callTool.mock.calls[0]![0]).toEqual({ + name: "pages_update_page", + arguments: { + pageId: "p", + expectedVersion: 7, + content: { blocks: [{ type: "text", markdown }] }, + organization: "acme", + }, + }) + expect(stdout).toBe( + `${JSON.stringify({ pageId: "p", version: 8 }, null, 2)}\n`, + ) + expect(listTools).not.toHaveBeenCalled() + }) + + it("prints a text field of the result exactly, from structured or text content", async () => { + callTool.mockResolvedValueOnce(structured({ markdown, version: 3 })) + expect( + await run(["pages", "call", "export_page_markdown", "--raw", "markdown"]), + ).toEqual({ code: 0, stdout: markdown, stderr: "" }) + callTool.mockResolvedValueOnce({ + content: [{ type: "text", text: JSON.stringify({ file: { markdown } }) }], + }) + expect( + (await run(["cms", "call", "read_file", "--raw", "file.markdown"])) + .stdout, + ).toBe(markdown) + callTool.mockResolvedValueOnce(structured({ notes: "" })) + expect(await run(["work", "call", "get_task", "--raw", "notes"])).toEqual({ + code: 0, + stdout: "", + stderr: "", + }) + }) + + it.each([ + [{ pageId: "new", version: 1 }, /markdown is missing/], + [{ markdown: 3 }, /markdown is a number/], + [{ markdown: null }, /markdown is null/], + ])( + "keeps the result of a call that succeeded when the field is not text: %j", + async (data, message) => { + callTool.mockResolvedValue(structured(data)) + const { code, stdout, stderr } = await run([ + "pages", + "call", + "create_page", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error).toEqual({ + code: "RAW_NOT_TEXT", + message: expect.stringMatching(message), + succeeded: true, + result: data, + }) + expect(JSON.parse(stderr).error.message).toMatch( + /^The request succeeded and any change it made is kept.*do not send the request again\.$/, + ) + expect(callTool).toHaveBeenCalledTimes(1) + }, + ) + + it("says a read-only tool changed nothing when its field is not text", async () => { + listTools.mockResolvedValue({ + tools: [ + { + name: "pages_get_page", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + ], + }) + callTool.mockResolvedValue(structured({ pageId: "p" })) + const { stderr } = await run(["pages", "call", "get_page", "--raw", "x"]) + expect(JSON.parse(stderr).error.message).toMatch( + /^The request succeeded, but --raw x needs text.*under "result"\.$/, + ) + }) + + it("refuses a field the output schema rules out before calling the tool", async () => { + listTools.mockResolvedValue({ tools: [commitFiles] }) + for (const field of ["revision.sha", "pullRequest.number"]) { + const { code, stdout, stderr } = await run([ + "cms", + "call", + "commit_files", + '{"branchId":"b","expectedRevision":"old","changes":[{"path":"a.md","content":"x"}]}', + "--raw", + field, + ]) + expect(code).toBe(64) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error).toMatchObject({ + code: "USAGE", + message: expect.stringMatching( + /can never be text.*Nothing was sent\.$/, + ), + help: "fulldev tools cms_commit_files", + }) + } + expect(callTool).not.toHaveBeenCalled() + }) + + it("calls the tool when its output schema allows text at the field", async () => { + listTools.mockResolvedValue({ tools: [commitFiles] }) + callTool.mockResolvedValue(structured({ revision: "abc123" })) + expect( + await run(["cms", "call", "commit_files", "--raw", "revision"]), + ).toEqual({ code: 0, stdout: "abc123", stderr: "" }) + // Not declared, but the schema allows more properties: the call goes out. + const { code, stderr } = await run([ + "cms", + "call", + "commit_files", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(JSON.parse(stderr).error).toMatchObject({ + succeeded: true, + result: { revision: "abc123" }, + }) + expect(callTool).toHaveBeenCalledTimes(2) + }) + + it("prints a tool's error with its details and nothing on stdout", async () => { + const error = { + code: "VERSION_CONFLICT", + message: "The page changed.", + currentVersion: 9, + } + callTool.mockResolvedValue({ + isError: true, + content: [{ type: "text", text: JSON.stringify({ error }) }], + }) + const { code, stdout, stderr } = await run([ + "pages", + "call", + "export_page_markdown", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr)).toEqual({ error }) + }) + + it.each([ + [['{"content":"old"}', "--text-file", "content=PATH"], /already has/], + [["{}", "--text-file", "__proto__.x=PATH"], /__proto__/], + [["-", "--text-file", "content=-"], /stdin/], + [ + ["{}", "--text-file", "content=PATH", "--text-file", "content=PATH"], + /twice/, + ], + [["--raw", "markdown", "--all-orgs"], /--raw/], + ])("refuses %j before connecting", async (args, message) => { + const path = await textFile("text") + const { code, stdout, stderr } = await run([ + "pages", + "call", + "update_page", + ...args.map((arg) => arg.replace("PATH", path)), + ]) + expect(code).toBe(64) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error.message).toMatch(message) + expect(connect).not.toHaveBeenCalled() + expect(callTool).not.toHaveBeenCalled() + }) +}) + +describe("api with --text-file and --raw", () => { + const spec = { + paths: { + "/pages/update": { post: { operationId: "pages.update" } }, + "/pages/exportMarkdown": { + get: { operationId: "pages.exportMarkdown" }, + }, + }, + components: { + schemas: { + Receipt: { + type: "object", + properties: { + pageId: { type: "string" }, + version: { type: "integer" }, + }, + }, + }, + }, + } + const fetchFn = vi.fn() + beforeEach(() => { + fetchFn.mockReset() + vi.stubGlobal("fetch", fetchFn) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + const answer = ( + body: unknown, + status = 200, + create?: unknown, + mediaType = "application/json", + ) => + fetchFn.mockImplementation(async (url) => + new Request(url).url.endsWith("openapi.json") + ? Response.json({ + ...spec, + paths: { ...spec.paths, "/pages/create": { post: create } }, + }) + : Response.json(body, { + status, + headers: { "content-type": mediaType }, + }), + ) + const creates = (schema: unknown) => ({ + operationId: "pages.create", + responses: { "200": { content: { "application/json": { schema } } } }, + }) + + it("posts the exact text and prints a raw field", async () => { + answer({ markdown, version: 4 }) + const path = await textFile(markdown) + const { code, stdout } = await run([ + "api", + "pages.pages.update", + '{"pageId":"p","expectedVersion":3}', + "--text-file", + `markdown=${path}`, + "--raw", + "markdown", + ]) + expect(code).toBe(0) + expect(stdout).toBe(markdown) + const init = fetchFn.mock.calls[1]![1] + expect(JSON.parse(init!.body as string)).toEqual({ + pageId: "p", + expectedVersion: 3, + markdown, + }) + }) + + it("keeps a conflict's details and prints nothing on stdout", async () => { + answer( + { + defined: true, + code: "CONFLICT", + status: 409, + message: "The page changed.", + data: { currentVersion: 9 }, + }, + 409, + ) + const { code, stdout, stderr } = await run([ + "api", + "pages.pages.exportMarkdown", + '{"pageId":"p"}', + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr)).toEqual({ + error: { + code: "CONFLICT", + message: "The page changed.", + status: 409, + data: { currentVersion: 9 }, + }, + }) + }) + + it("refuses a field the response schema rules out before sending", async () => { + answer( + { pageId: "p-new", version: 1 }, + 200, + creates({ $ref: "#/components/schemas/Receipt" }), + ) + const { code, stdout, stderr } = await run([ + "api", + "pages.pages.create", + "{}", + "--raw", + "version", + ]) + expect(code).toBe(64) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error.message).toBe( + "--raw version can never be text: the API's response schema says version is an integer. Nothing was sent.", + ) + expect(fetchFn).toHaveBeenCalledTimes(1) + }) + + it("keeps the receipt when a change succeeded under an open schema", async () => { + // How the server describes every response today: anything. + answer( + { pageId: "p-new", version: 1 }, + 200, + creates({ anyOf: [{}, { not: {} }] }), + ) + const { code, stdout, stderr } = await run([ + "api", + "pages.pages.create", + "{}", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error).toMatchObject({ + code: "RAW_NOT_TEXT", + message: expect.stringMatching(/^The request succeeded and any change/), + succeeded: true, + result: { pageId: "p-new", version: 1 }, + }) + expect(fetchFn).toHaveBeenCalledTimes(2) + }) + + it.each([ + ["default success response", "default", "application/json", 201], + ["alternative representation", "200", "application/vnd.fulldev+json", 200], + ])( + "allows text declared by an %s", + async (_, status, mediaType, responseStatus) => { + const nontext = { + type: "object", + properties: { markdown: { type: "number" } }, + } + const text = { + type: "object", + properties: { markdown: { type: "string" } }, + } + const responses: Record< + string, + { content: Record } + > = { + "200": { content: { "application/json": { schema: nontext } } }, + } + responses[status] ??= { content: {} } + responses[status]!.content[mediaType] = { schema: text } + answer( + { markdown }, + responseStatus, + { + operationId: "pages.create", + responses, + }, + mediaType, + ) + const { code, stdout } = await run([ + "api", + "pages.pages.create", + "{}", + "--raw", + "markdown", + ]) + expect(code).toBe(0) + expect(stdout).toBe(markdown) + expect(fetchFn).toHaveBeenCalledTimes(2) + }, + ) + + it("refuses a conflicting text before sending the change", async () => { + answer({}) + const path = await textFile("text") + const { code } = await run([ + "api", + "pages.pages.update", + '{"markdown":"old"}', + "--text-file", + `markdown=${path}`, + ]) + expect(code).toBe(64) + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(new Request(fetchFn.mock.calls[0]![0]).url).toMatch(/openapi\.json$/) + }) +})