From 10e803ec8c9899fd7d1c005e3f5e77c01ec00f00 Mon Sep 17 00:00:00 2001 From: Sil Date: Sun, 11 Oct 2026 17:56:54 +0200 Subject: [PATCH 1/3] Add OpenAPI API commands with existing OAuth credentials --- .changeset/agent-http-api.md | 5 + README.md | 16 +- dist/cli.mjs | 2046 ++++++++++++++++++---------------- src/api.test.ts | 166 +++ src/api.ts | 147 +++ src/apps.test.ts | 2 + src/apps.ts | 10 + src/args.ts | 50 +- src/main.ts | 6 + 9 files changed, 1488 insertions(+), 960 deletions(-) create mode 100644 .changeset/agent-http-api.md create mode 100644 src/api.test.ts create mode 100644 src/api.ts diff --git a/.changeset/agent-http-api.md b/.changeset/agent-http-api.md new file mode 100644 index 0000000..ca0b64c --- /dev/null +++ b/.changeset/agent-http-api.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Discover and call product HTTP operations with `fulldev api`, using the server's OpenAPI specification and existing OAuth connection. Add Send and Work shortcuts. diff --git a/README.md b/README.md index 923a898..745a35e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fulldev CLI -Use Fulldev apps from a terminal or an AI agent. The CLI is a thin client of the Fulldev MCP server at `https://app.full.dev/mcp`, which has the tools of every app your organization gives you: it reads the tools, their schemas, and the instructions from the server each time, so it stays in step with the apps. Each app's tools start with its name, such as `cms_`. +Use Fulldev apps from a terminal or an AI agent. The CLI discovers the full HTTP API from OpenAPI and is a thin client of the Fulldev MCP server at `https://app.full.dev/mcp`, which has the tools of every app your organization gives you: it reads the tools, their schemas, and the instructions from the server each time, so it stays in step with the apps. Each app's tools start with its name, such as `cms_`. | App | For | | ---------- | ---------------------------------------------------------------------------------------------------- | @@ -140,3 +140,17 @@ Every change that users get adds a changeset with `pnpm changeset`. The [`fullde ## License [MIT](LICENSE) + +## Full HTTP API + +MCP shortcuts remain the curated tool interface. On a server exposing OpenAPI, the same CLI can discover and call every product HTTP operation, including ones omitted from MCP: + +```sh +fulldev api contacts +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. + +Send and Work are also available as `fulldev send` and `fulldev work`. diff --git a/dist/cli.mjs b/dist/cli.mjs index db95737..5e0eaf7 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -4,12 +4,12 @@ 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 { parseArgs } from "node:util"; import { text } from "node:stream/consumers"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { createInterface } from "node:readline"; +import { parseArgs } from "node:util"; //#region src/errors.ts const exitCodes = { ok: 0, @@ -313,1039 +313,1164 @@ var CredentialStore = class { //#region package.json var version = "0.5.0"; //#endregion -//#region src/apps.ts -/** The Fulldev MCP server: the tools of every app the person may use. */ -const server = { - title: "Fulldev", - url: "https://app.full.dev/mcp" -}; -/** The environment variable that overrides the server's URL. */ -const urlVariable = "FULLDEV_URL"; -const apps = [ - { - name: "cms", - title: "Fulldev CMS", - description: "Edit your website: text, pages, images and settings, through a pull request.", - forms: true - }, - { - name: "connect", - title: "Fulldev Connect", - description: "Use your organization's business tools, such as Shopify, with the access Fulldev grants you." - }, - { - name: "scan", - title: "Fulldev Scan", - description: "Scan whole websites for problems. For administrators only." - }, - { - name: "sites", - title: "Fulldev Sites", - description: "Have Fulldev make a finished website from a brief and tested design options. For administrators only for now." - }, - { - name: "pages", - title: "Fulldev Pages", - description: "Publish reports and plans as clear web pages, composed from ready-made blocks. For administrators only for now." - }, - { - name: "contacts", - title: "Fulldev Contacts", - description: "Look after the daily copy of your Google contacts from one Google account to another: its runs, look-alike questions, pausing and resuming." +//#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) }) { + 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"; + if (file !== void 0) { + from = file; + try { + source = await readFile(file, "utf8"); + } catch (error) { + throw new UsageError(`Cannot read ${file}: ${error.code ?? error.message}`); + } + } else if (json === "-") { + from = "stdin"; + source = await readStdin(); + } else if (json !== void 0) source = json; + if (!source.trim()) source = "{}"; + let value; + try { + value = JSON.parse(source); + } catch (error) { + throw new UsageError(`The tool input from ${from} is not valid JSON: ${error.message}`); } -]; -function findApp(name) { - return apps.find((app) => app.name === name); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`); + return value; } +//#endregion +//#region src/oauth.ts /** -* A tool's name on the server: an app's tools start with the app's name, -* which may be left out after `fulldev `. +* The Fulldev CLI's OAuth client id: the URL of its Client ID Metadata +* Document, served by full.dev from apps/website/public/oauth/cli.json. The +* CLI never registers a client, so an authorization server must support +* such documents. */ -function toolName(app, name) { - return name.startsWith(`${app.name}_`) ? name : `${app.name}_${name}`; -} -/** The server: --url, then FULLDEV_URL, then app.full.dev. */ -function resolveTarget(flagUrl, env) { - const url = flagUrl ?? env["FULLDEV_URL"] ?? server.url; - let parsed; +const clientMetadataUrl = "https://full.dev/oauth/cli.json"; +/** The claims of a JWT, or {} for an opaque token. */ +function claims(token) { try { - parsed = new URL(url); + const value = JSON.parse(Buffer.from(token?.split(".")[1] ?? "", "base64url").toString()); + return value && typeof value === "object" ? value : {}; } catch { - throw new UsageError(`Not a valid server URL: ${url}`); + return {}; } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new UsageError(`The server URL must use https: ${url}`); +} +/** When the access token expires, in milliseconds, if known. */ +function expiresAt(credentials) { + const { exp } = claims(credentials.tokens?.access_token); + if (typeof exp === "number") return exp * 1e3; + const { savedAt, tokens } = credentials; + return savedAt && tokens?.expires_in ? savedAt + tokens.expires_in * 1e3 : void 0; +} +/** A copy of the credentials without one field. */ +function without(credentials, key) { + const copy = { ...credentials }; + delete copy[key]; + return copy; +} +const refreshEarlyMs = 6e4; +function needsRefresh(credentials, now = Date.now()) { + const expires = expiresAt(credentials); + return expires !== void 0 && expires - refreshEarlyMs <= now; +} +/** +* The scopes a sign-in asks for. The SDK would take the resource's +* `scopes_supported`, which leaves out `openid`, the authorization server's +* own scope; without it there is no ID token and so no email. +*/ +const signInScope = "openid profile email offline_access"; +/** The CLI as the client of an authorization server. */ +const cliClient = (issuer) => ({ + client_id: clientMetadataUrl, + issuer +}); +/** +* Saves tokens for a target, keeping only the email from the ID token. A +* sign-in also saves the client they were issued to, and its email only: +* it may be another person's. A refresh keeps the email it had. +*/ +async function saveTokens(store, target, tokens, issuer, client) { + const { id_token: idToken, ...kept } = tokens; + const { email } = claims(idToken); + await store.update(target.url, (current) => { + const next = { + ...current, + issuer, + ...client ? { client } : {}, + tokens: kept, + savedAt: Date.now() + }; + delete next.email; + const known = typeof email === "string" ? email : client ? void 0 : current.email; + return known ? { + ...next, + email: known + } : next; + }); +} +/** Finds the target's authorization server and checks its resource. */ +async function discover(target, fetchFn) { + const info = await discoverOAuthServerInfo(target.url, { fetchFn }); + const metadata = info.authorizationServerMetadata; + const resource = info.resourceMetadata?.resource; + if (!metadata || !info.resourceMetadata || !resource) throw new CliError("DISCOVERY_FAILED", `${target.url} does not publish OAuth metadata for its MCP server.`); + if (!checkResourceAllowed({ + requestedResource: resourceUrlFromServerUrl(target.url), + configuredResource: resource + })) throw new CliError("RESOURCE_MISMATCH", `The server names its resource ${resource}, which does not match ${target.url}.`); return { - title: server.title, - url: parsed.href, - urlFromFlag: flagUrl !== void 0 + authorizationServerUrl: info.authorizationServerUrl, + metadata, + issuer: metadata.issuer, + resource, + resourceMetadata: info.resourceMetadata }; } -//#endregion -//#region src/args.ts -const organizationDetails = ` -When the sign-in is for more than one organization, --org names the one to -work in, by its slug from fulldev status. --all-orgs calls a tool that only -reads in every organization of the sign-in that has its app, and prints -{"organizations":[{"organization","result" or "error"}]}; it exits with 1 -when one failed.`; -const rootCommands = { - login: { - usage: "fulldev login [--no-browser] [--url ]", - summary: "Sign in, once for every app and organization", - options: ["browser", "url"], - details: `Signs in to the Fulldev MCP server in your browser, where you choose the -organizations fulldev may work in: all of yours at first. One sign-in covers -every app they give you. Run fulldev login again to change the choice, and -choose the organization of a call with --org. - -With --no-browser, open the link in a browser on any computer. When that is -another computer, as over SSH, the page after sign-in does not load: copy its -address from the address bar and paste it into this terminal.` - }, - logout: { - usage: "fulldev logout [--url ]", - summary: "Sign out and revoke the tokens", - options: ["url"], - details: `Revokes the refresh token at the authorization server and deletes the -tokens from this computer. The access token cannot be revoked and expires -within 15 minutes. When revoking fails, the local sign-out still happens and -the result says so.` - }, - status: { - usage: "fulldev status [--url ]", - summary: "Show the sign-in", - options: ["url"], - details: `Prints whether you are signed in, whether the sign-in still works, your -email, the organizations of the sign-in with their slugs and the apps you may -use in each, when the access token expires, and where the tokens are stored. Exits with 3 when a sign-in is needed, and with 1 when the -server could not be reached to check it.` - }, - instructions: { - usage: "fulldev instructions", - summary: "The instructions for agents, of every app you may use; read first", - options: [ - "url", - "login", - "browser" - ], - details: `Prints the instructions the server sends, as plain text: an overview, then -each app's own, under its name. fulldev instructions prints the -overview and that app's part.` - }, - tools: { - usage: "fulldev tools [name]", - summary: "List the tools of every app you may use, or show one tool", - options: [ - "url", - "login", - "browser" - ], - details: `Without a name: the name, title and first line of the description of each -tool. With a name: its description, input schema, output schema and -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" - ], - 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. -${organizationDetails}` - }, - help: { - usage: "fulldev help [command...]", - summary: "Show help for a command" - } -}; -/** The commands that use the server, also per app. */ -const serverCommands = [ - "instructions", - "tools", - "call" -]; -const appCommands = { - instructions: { - usage: "fulldev instructions", - summary: "The overview and the app's own instructions; read first", - options: [ - "url", - "login", - "browser" - ], - details: "Prints them as plain text, as the server sends them." - }, - tools: { - usage: "fulldev tools [name]", - summary: "List the app's tools, or show one tool", - options: [ - "url", - "login", - "browser" - ], - details: `Without a name: the name, title and first line of the description of each -of the app's tools. With a name, with or without the app's prefix: its -description, input schema, output schema and annotations.` - }, - call: { - 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" - ], - 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. -${organizationDetails}` +const callbackPath = "/callback"; +/** +* The SDK's view of one browser sign-in: the metadata document as client, +* with PKCE S256 and a loopback redirect, on the server that discovery +* found. +*/ +var BrowserLoginProvider = class { + store; + target; + redirectUrl; + loginState; + onAuthorizationUrl; + discovery; + verifier = ""; + constructor(store, target, redirectUrl, loginState, onAuthorizationUrl, discovery) { + this.store = store; + this.target = target; + this.redirectUrl = redirectUrl; + this.loginState = loginState; + this.onAuthorizationUrl = onAuthorizationUrl; + this.discovery = discovery; + } + /** Read by the SDK for the scope; the server reads the metadata document. */ + get clientMetadata() { + return { + client_name: "Fulldev CLI", + client_uri: "https://full.dev", + redirect_uris: [this.redirectUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none" + }; + } + state() { + return this.loginState; + } + /** Always the metadata document, so the SDK never registers a client. */ + clientInformation(ctx) { + return cliClient(ctx?.issuer ?? ""); + } + /** A sign-in always asks again, so it never reuses stored tokens. */ + tokens() {} + async saveTokens(tokens, ctx) { + const issuer = ctx?.issuer ?? tokens.issuer ?? ""; + await saveTokens(this.store, this.target, tokens, issuer, cliClient(issuer)); + } + redirectToAuthorization(authorizationUrl) { + this.onAuthorizationUrl(authorizationUrl); + } + saveCodeVerifier(verifier) { + this.verifier = verifier; + } + codeVerifier() { + return this.verifier; + } + saveDiscoveryState(state) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + invalidateCredentials(scope) { + if (scope === "verifier") this.verifier = ""; + if (scope === "discovery" || scope === "all") this.discovery = void 0; } }; -const formWait = { - usage: "fulldev form wait [--timeout ] [--org ]", - summary: "Wait until the person sends a form", - options: [ - "timeout", - "org", - "url", - "login", - "browser" - ], - details: `Calls 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 prints the form id and exits with 2, so you can run it again.` -}; -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", - 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", - org: "--org The organization to work in", - "all-orgs": "--all-orgs Every organization of the sign-in (reading tools)" -}; -const footer = `Data is JSON on stdout; progress lines go to stderr. Every error is one JSON -object on stderr: {"error":{"code","message",...}}. -Exit codes: 0 ok, 1 error, 2 form wait timed out, 3 sign-in needed, 64 usage error. -`; -const pad = (text, width) => text.padEnd(width); -function appTopics(app) { - const name = (topic) => ({ - ...topic, - usage: topic.usage.replace("", app.name) +const tile = ``; +const page = (message) => `Fulldev CLI
${tile}

${message}

`; +/** +* Listens on 127.0.0.1, on a port the system picks, for the authorization +* server's redirect (RFC 8252). +*/ +async function listenForCallback() { + let receive = () => {}; + const received = new Promise((resolve) => { + receive = resolve; }); - const topics = {}; - for (const [command, topic] of Object.entries(appCommands)) topics[`${app.name} ${command}`] = name(topic); - if (app.forms) { - const wait = name(formWait); - topics[`${app.name} form`] = wait; - topics[`${app.name} form wait`] = wait; + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== callbackPath) { + response.writeHead(404).end(); + return; + } + const failed = url.searchParams.has("error"); + response.writeHead(failed ? 400 : 200, { "content-type": "text/html" }).end(page(failed ? "Sign-in did not complete. You can close this tab and check the terminal." : "Sign-in received. You can close this tab and return to the terminal."), () => receive(url.searchParams)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + return { + port: server.address().port, + received, + close: () => { + server.closeAllConnections(); + server.close(); + } + }; +} +/** The query of a pasted address, when it is the redirect after sign-in. */ +function redirectQuery(line) { + try { + const { searchParams } = new URL(line.trim()); + return searchParams.has("state") ? searchParams : void 0; + } catch { + return; } - return topics; } -const serverLine = `Server: ${server.url} (${urlVariable} or --url overrides it)`; -function rootHelp() { - const width = Math.max(...apps.map((app) => app.name.length)) + 4; - return `Fulldev CLI: use Fulldev apps from a terminal or an AI agent, through the -Fulldev MCP server. One sign-in covers every app of the organizations you -choose; --org picks one per call. - -Usage: - fulldev [options] - fulldev [options] - -Commands: -${Object.values(rootCommands).map((topic) => ` ${pad(topic.usage.split(" [--")[0], 32)}${topic.summary}`).join("\n")} - -Apps (their tools start with the app's name, such as cms_): -${apps.map((app) => ` ${pad(app.name, width)}${app.description}`).join("\n")} - -App commands, for one app's part: -${[...Object.values(appCommands), { - ...formWait, - usage: formWait.usage.replace("", "cms") - }].map((topic) => ` ${topic.usage.split(" [--")[0]}\n ${topic.summary}`).join("\n")} - -Run fulldev instructions first and follow them. -Run fulldev help for a command's options, for example -fulldev help cms call. - -${serverLine} - -Options: - -h, --help Show help - -v, --version Show the version - -${footer}`; +/** +* Reads the address of the page after sign-in, pasted into the terminal. +* With the browser on another computer, such as over SSH, the redirect +* cannot reach this one's loopback port, but the address bar still holds +* the code. Other lines are asked for again; at the end of the input, the +* loopback redirect can still arrive. +*/ +function readPastedRedirect(input, log) { + const lines = createInterface({ + input, + terminal: false + }); + return { + received: new Promise((resolve) => { + lines.on("line", (line) => { + const query = redirectQuery(line); + if (query) resolve(query); + else if (line.trim()) log("That is not the address of the page after sign-in. Paste the whole address from the browser's address bar."); + }); + }), + close: () => lines.close() + }; } -function appHelp(app) { - const topics = Object.values(appTopics(app)).filter((topic, index, all) => all.indexOf(topic) === index); - return `${app.title} - -${app.description} - -Its tools start with ${app.name}_; after fulldev ${app.name} you may leave that out. - -Usage: -${topics.map((topic) => ` ${topic.usage}\n ${topic.summary}`).join("\n")} - -Run fulldev ${app.name} instructions first and follow them. -Sign in with fulldev login, once for every app. - -${serverLine} - -${footer}`; +/** The first of the promises to settle, or a timeout error. */ +async function firstWithin(timeoutMs, promises) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new CliError("SIGN_IN_TIMEOUT", `Sign-in timed out after ${Math.round(timeoutMs / 6e4)} minutes.`)), timeoutMs); + }); + try { + return await Promise.race([...promises, timeout]); + } finally { + clearTimeout(timer); + } } -function topicHelp(topic) { - const options = topic.options?.map((option) => ` ${optionHelp[option]}`); - return `Usage: ${topic.usage} - -${topic.summary}. -${topic.details ? `\n${topic.details}\n` : ""}${options?.length ? `\nOptions:\n${options.join("\n")}\n` : ""} -${footer}`; +/** Opens a URL in the system browser; the URL is printed as well. */ +function openBrowser(url) { + const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [ + "/c", + "start", + "", + url.replaceAll("&", "^&") + ]] : ["xdg-open", [url]]; + try { + const child = spawn(command, args, { + stdio: "ignore", + detached: true + }); + child.on("error", () => {}); + child.unref(); + } catch {} } -/** The help text for a topic such as "", "login", "cms" or "cms call". */ -function helpText(topic) { - if (topic === "") return rootHelp(); - const root = rootCommands[topic]; - if (root) return topicHelp(root); - const app = findApp(topic); - if (app) return appHelp(app); - const [name] = topic.split(" "); - const owner = findApp(name ?? ""); - const found = owner ? appTopics(owner)[topic] : void 0; - return found ? topicHelp(found) : void 0; +/** +* Signs in with the authorization code flow and PKCE S256, the system +* browser and a loopback redirect on an ephemeral port, asking for the +* app's MCP URL as the resource. Without the browser, the address after +* sign-in can also be pasted, for a browser on another computer. +*/ +async function browserLogin(target, store, { browser, log, timeoutMs = 6e5, fetchFn, input = process.stdin }) { + const discovery = await discover(target, fetchFn); + if (discovery.metadata.client_id_metadata_document_supported !== true) throw new CliError("CLIENT_METADATA_UNSUPPORTED", `The authorization server ${discovery.issuer} does not support Client ID Metadata Documents, which fulldev needs to sign in to ${target.title}.`); + const callback = await listenForCallback(); + const pasted = browser ? void 0 : readPastedRedirect(input, log); + try { + const state = randomBytes(16).toString("base64url"); + const provider = new BrowserLoginProvider(store, target, `http://127.0.0.1:${callback.port}${callbackPath}`, state, (url) => { + if (browser) { + log(`Sign in to ${target.title} in your browser. If it does not open, open:`); + log(url.href); + openBrowser(url.href); + return; + } + log(`Open this link to sign in to ${target.title}:`); + log(url.href); + log("If the browser is on another computer, the page after sign-in does not load: copy its address from the address bar and paste it here."); + }, { + authorizationServerUrl: String(discovery.authorizationServerUrl), + resourceMetadata: discovery.resourceMetadata, + authorizationServerMetadata: discovery.metadata + }); + if (await auth(provider, { + serverUrl: target.url, + scope: signInScope, + forceReauthorization: true, + ...fetchFn ? { fetchFn } : {} + }) === "AUTHORIZED") return; + const params = await firstWithin(timeoutMs, [callback.received, ...pasted ? [pasted.received] : []]); + if (params.get("state") !== state) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: the response did not match this sign-in."); + const error = params.get("error"); + if (error) throw new CliError("SIGN_IN_FAILED", `Sign-in failed: ${[error, params.get("error_description")].filter(Boolean).join(": ")}`, void 0, { oauthError: error }); + const code = params.get("code"); + if (!code) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: no authorization code."); + await auth(provider, { + serverUrl: target.url, + scope: signInScope, + authorizationCode: code, + iss: params.get("iss") ?? void 0, + ...fetchFn ? { fetchFn } : {} + }); + } finally { + callback.close(); + pasted?.close(); + } } -/** The longest leading words of `words` that name a help topic. */ -function closestTopic(words) { - for (let length = Math.min(words.length, 3); length > 0; length--) { - const topic = words.slice(0, length).join(" "); - if (helpText(topic) !== void 0) return topic; +const formHeaders = { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json" +}; +async function readJson(response) { + try { + const value = await response.json(); + return value && typeof value === "object" ? value : {}; + } catch { + return {}; } - return ""; } -const helpFor = (topic) => topic ? `fulldev ${topic} --help` : "fulldev --help"; -function parseCommandLine(argv, env = process.env) { - let parsed; +const text$1 = (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 +* then needs a new sign-in. +*/ +async function refreshTokens(target, store, fetchFn) { + const credentials = await store.get(target.url); + const refreshToken = credentials.tokens?.refresh_token; + if (!refreshToken) throw new SignInRequiredError(target); + const discovery = await discover(target, fetchFn); + const { client } = credentials; + if (!client || credentials.issuer && credentials.issuer !== discovery.issuer) throw new SignInRequiredError(target); + let tokens; try { - parsed = parseArgs({ - args: argv, - allowPositionals: true, - allowNegative: true, - strict: true, - options: { - url: { type: "string" }, - file: { - type: "string", - short: "f" - }, - org: { type: "string" }, - "all-orgs": { type: "boolean" }, - timeout: { type: "string" }, - login: { type: "boolean" }, - browser: { type: "boolean" }, - help: { - type: "boolean", - short: "h" - }, - version: { - type: "boolean", - short: "v" - } - } + tokens = await refreshAuthorization(discovery.authorizationServerUrl, { + metadata: discovery.metadata, + clientInformation: client, + refreshToken, + resource: new URL(discovery.resource), + ...fetchFn ? { fetchFn } : {} }); } catch (error) { - throw new UsageError(error.message, helpFor(closestTopic(argv.filter((word) => !word.startsWith("-"))))); - } - const { values, positionals } = parsed; - if (values.version) return { kind: "version" }; - if (positionals[0] === "help") { - const topic = positionals.slice(1).join(" "); - if (helpText(topic) === void 0) throw new UsageError(`No help for: ${topic}`); - return { - kind: "help", - topic - }; + if (error instanceof OAuthError && (error.code === OAuthErrorCode.InvalidGrant || error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient)) { + await store.update(target.url, (current) => without(current, "tokens")); + throw new SignInRequiredError(target); + } + throw error; } - if (values.help) return { - kind: "help", - topic: closestTopic(positionals) - }; - if (positionals.length === 0) return { - kind: "help", - topic: "" - }; - const [first, ...rest] = positionals; - const given = Object.keys(values).filter((key) => values[key] !== void 0); - const allow = (topic, options = []) => { - const extra = given.find((key) => !options.includes(key)); - if (extra) throw new UsageError(`--${extra === "login" || extra === "browser" ? `no-${extra}` : extra} does not work with fulldev ${topic}.`, helpFor(topic)); - }; - const target = resolveTarget(values.url, env); - const session = { - login: values.login ?? true, - browser: values.browser ?? true + await saveTokens(store, target, tokens, discovery.issuer); +} +/** +* Revokes the refresh token (RFC 7009), sending the client_id as a public +* client. Never throws: the result says what failed. +*/ +async function revokeTokens(target, credentials, fetchFn = fetch) { + const { tokens, client } = credentials; + if (!tokens) return { revoked: true }; + if (!client) return { + revoked: false, + error: "The sign-in is from an earlier version of fulldev and cannot be revoked." }; - if (first === "login" || first === "logout" || first === "status") { - allow(first, rootCommands[first].options); - if (rest.length) { - const app = findApp(rest[0]); - const command = `fulldev ${first}${target.urlFromFlag ? ` --url ${target.url}` : ""}`; - throw new UsageError(app ? `One sign-in covers every app, ${app.title} too. Run: ${command}` : `fulldev ${first} takes no app: one sign-in covers every app. Run: ${command}`, helpFor(first), { command }); - } - if (first === "login") return { - kind: "login", - target, - browser: values.browser ?? true + try { + const endpoint = (credentials.issuer ? await discoverAuthorizationServerMetadata(credentials.issuer, { fetchFn }) : (await discover(target, fetchFn)).metadata)?.revocation_endpoint; + if (!endpoint) return { + revoked: false, + error: "The authorization server has no revocation endpoint." }; + const failures = []; + for (const [token, hint] of [[tokens.refresh_token, "refresh_token"]]) { + if (!token) continue; + const body = new URLSearchParams({ + token, + token_type_hint: hint, + client_id: client.client_id + }); + if (client.client_secret) body.set("client_secret", client.client_secret); + const response = await fetchFn(endpoint, { + method: "POST", + headers: formHeaders, + body + }); + 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(": ")}`); + } + } + return failures.length ? { + revoked: false, + error: `Revocation failed for ${failures.join("; ")}` + } : { revoked: true }; + } catch (error) { return { - kind: first, - target + revoked: false, + error: error.message }; } - const app = findApp(first); - const command = app ? rest[0] : first; - const args = app ? rest.slice(1) : rest; - if (!app && !serverCommands.includes(first)) throw new UsageError(`Unknown command or app: ${first}`); - if (app && command === void 0) { - allow(app.name); - return { - kind: "help", - topic: app.name - }; +} +/** +* Gives the MCP transport the stored access token of one app, refreshing +* it when it is about to expire or the server answers 401. Refreshes happen +* under the store's lock, and a token another process refreshed meanwhile is +* used instead of refreshing again. It never starts a sign-in. +*/ +var StoredTokenAuth = class { + target; + store; + sent; + constructor(target, store) { + this.target = target; + this.store = store; } - const topic = app ? `${app.name} ${command}` : first; - const known = app ? appTopics(app)[topic] : rootCommands[topic]; - if (!known) throw new UsageError(`Unknown command: fulldev ${topic}`, helpFor(app?.name ?? "")); - allow(topic, known.options); - const usage = (count) => { - if (!count) throw new UsageError(`Usage: ${known.usage}`, helpFor(topic)); - }; - switch (command) { - case "instructions": - usage(args.length === 0); - return { - kind: "instructions", - target, - session, - app - }; - case "tools": - usage(args.length <= 1); - return { - kind: "tools", - target, - session, - app, - name: args[0] - }; - 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)); - return { - kind: "call", - target, - session, - tool: app ? toolName(app, args[0]) : args[0], - json: args[1], - file: values.file, - organization: values.org, - allOrganizations: values["all-orgs"] - }; - default: { - usage(app !== void 0 && args[0] === "wait" && args.length === 3); - const timeoutMinutes = values.timeout === void 0 ? 30 : Number(values.timeout); - if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) throw new UsageError("--timeout must be a number of minutes above 0.", helpFor(topic)); - return { - kind: "form-wait", - target, - session, - app, - branchId: args[1], - formId: args[2], - timeoutMinutes, - organization: values.org - }; - } + async refreshUnless(changed, fetchFn) { + await this.store.locked(async () => { + if (!changed(await this.store.get(this.target.url))) await refreshTokens(this.target, this.store, fetchFn); + }); } -} -//#endregion -//#region src/result.ts -/** A tool result as JSON: its structured content, or its text parsed as JSON. */ -function toolOutput(result) { - if (result.structuredContent !== void 0) return result.structuredContent; - const values = result.content.flatMap((block) => block.type === "text" ? [block.text] : []).map((text) => { - try { - return JSON.parse(text); - } catch { - return text; + async token() { + let credentials = await this.store.get(this.target.url); + if (!credentials.tokens) throw new SignInRequiredError(this.target); + if (needsRefresh(credentials)) { + const stale = credentials.tokens.access_token; + await this.refreshUnless((current) => current.tokens?.access_token !== stale && !needsRefresh(current)); + credentials = await this.store.get(this.target.url); } - }); - return values.length === 1 ? values[0] : values; -} -/** A failed tool result as {"error":{"code","message",...}}, whatever the server sent. */ -function toolError(result) { - const output = toolOutput(result); - if (output !== null && typeof output === "object" && !Array.isArray(output)) { - const { error } = output; - if (error !== null && typeof error === "object" && !Array.isArray(error)) return output; - return { error: { - code: "TOOL_ERROR", - message: "The tool failed.", - details: output - } }; + this.sent = credentials.tokens?.access_token; + return this.sent; } - return { error: { - code: "TOOL_ERROR", - message: typeof output === "string" ? output : JSON.stringify(output) - } }; -} + async onUnauthorized(ctx) { + await this.refreshUnless((current) => current.tokens?.access_token !== void 0 && current.tokens.access_token !== this.sent, ctx.fetchFn); + } +}; //#endregion -//#region src/form-wait.ts -/** The one tool the CLI names itself, without its app's prefix. */ -const waitTool = "wait_for_form"; -const maxFailures = 3; -const retryMs = 5e3; -function minutes(ms) { - const total = Math.max(0, Math.round(ms / 1e3)); - return `${Math.floor(total / 60)}m ${total % 60}s`; +//#region src/api.ts +function apiOperations(spec) { + return Object.entries(spec.paths ?? {}).flatMap(([path, methods]) => Object.entries(methods).filter(([method]) => [ + "get", + "post", + "put", + "patch", + "delete" + ].includes(method)).map(([method, operation]) => ({ + path, + method: method.toUpperCase(), + ...operation + }))); } -function wait(ms, signal) { - return new Promise((resolve) => { - const timer = setTimeout(done, ms); - signal.addEventListener("abort", done, { once: true }); - function done() { - clearTimeout(timer); - signal.removeEventListener("abort", done); - resolve(); - } +/** All HTTP operations from the server's specification, independent of its MCP tool selection. */ +async function runApi(command, io, fetchFn = fetch) { + const { target, app } = command; + const origin = new URL(target.url).origin; + const specResponse = await fetchFn(`${origin}/${app.name}/api/openapi.json`, { + redirect: "error", + signal: AbortSignal.timeout(3e4) }); -} -/** -* Calls wait_for_form while it answers waiting true. Each call waits on the -* server, so the loop does not sleep between rounds. Network failures are -* retried a few times; tool and protocol errors end the wait. The deadline -* also aborts a request that is still running. -*/ -async function waitForForm(callTool, { branchId, formId, timeoutMs, app = "cms", progress = () => {}, now = Date.now, sleep = wait }) { - const deadline = now() + timeoutMs; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const { signal } = controller; - const aborted = () => signal.aborted; - let failures = 0; - try { - for (let round = 1; now() < deadline && !aborted(); round++) { - let result; - try { - result = await callTool(`${app}_${waitTool}`, { - branchId, - formId - }, signal); - failures = 0; - } catch (error) { - if (aborted()) break; - if (isSignInRequired(error) || error instanceof ProtocolError || ++failures >= maxFailures) throw error; - progress(`The request failed (${error.message}); retrying.`); - await sleep(retryMs, signal); - continue; - } - if (result.isError) return { - status: "error", - error: toolError(result) - }; - const output = toolOutput(result); - if (!(output !== null && typeof output === "object" && output.waiting === true)) return { - status: "done", - result: output - }; - progress(`Waiting for the person to send the form (round ${round}, ${minutes(deadline - now())} left).`); - } - } finally { - clearTimeout(timer); + if (!specResponse.ok) throw new CliError("API_UNAVAILABLE", "This server does not expose the app's OpenAPI specification."); + const operations = apiOperations(await specResponse.json()); + 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 input = await readToolArguments({ + json: command.json, + file: command.file + }); + const url = new URL(`/${app.name}/api/v1${operation.path}`, origin); + if (url.origin !== origin || !url.pathname.startsWith(`/${app.name}/api/v1/`)) throw new CliError("INVALID_API", "The server returned an invalid operation path."); + const reads = operation.method === "GET"; + if (reads) for (const [key, value] of Object.entries(input)) { + if (value === void 0) continue; + if (Array.isArray(value)) value.forEach((part) => url.searchParams.append(key, typeof part === "string" ? part : JSON.stringify(part))); + else url.searchParams.set(key, typeof value === "string" ? value : JSON.stringify(value)); } - return { - status: "timeout", - result: { - waiting: true, - branchId, - formId, - message: `Stopped waiting after ${minutes(timeoutMs)}. Run fulldev ${app} form wait ${branchId} ${formId} to keep waiting, or continue when the person says they are done.` - } + const auth = new StoredTokenAuth(target, io.store); + const attempt = async () => { + const token = await auth.token(); + if (!token) throw new SignInRequiredError(target); + const headers = new Headers({ authorization: `Bearer ${token}` }); + if (command.organization) headers.set("x-fulldev-organization", command.organization); + if (!reads) headers.set("content-type", "application/json"); + return fetchFn(url, { + method: operation.method, + headers, + body: reads ? void 0 : JSON.stringify(input), + redirect: "error", + signal: AbortSignal.timeout(12e4) + }); }; -} -//#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) }) { - 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"; - if (file !== void 0) { - from = file; - try { - source = await readFile(file, "utf8"); - } catch (error) { - throw new UsageError(`Cannot read ${file}: ${error.code ?? error.message}`); - } - } else if (json === "-") { - from = "stdin"; - source = await readStdin(); - } else if (json !== void 0) source = json; - if (!source.trim()) source = "{}"; - let value; + let response; try { - value = JSON.parse(source); + response = await attempt(); } catch (error) { - throw new UsageError(`The tool input from ${from} is not valid JSON: ${error.message}`); + if (!(error instanceof SignInRequiredError) || !io.interactive || !command.session.login) throw error; + await browserLogin(target, io.store, { + browser: command.session.browser, + log: (line) => io.stderr(`${line}\n`) + }); + response = await attempt(); } - if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`); - return value; -} -//#endregion -//#region src/oauth.ts -/** -* The Fulldev CLI's OAuth client id: the URL of its Client ID Metadata -* Document, served by full.dev from apps/website/public/oauth/cli.json. The -* CLI never registers a client, so an authorization server must support -* such documents. -*/ -const clientMetadataUrl = "https://full.dev/oauth/cli.json"; -/** The claims of a JWT, or {} for an opaque token. */ -function claims(token) { - try { - const value = JSON.parse(Buffer.from(token?.split(".")[1] ?? "", "base64url").toString()); - return value && typeof value === "object" ? value : {}; - } catch { - return {}; + if (response.status === 401) { + await auth.onUnauthorized({ fetchFn }); + response = await attempt(); } + if (response.status === 401) throw new SignInRequiredError(target); + const body = response.status === 204 ? null : await response.json().catch(() => { + 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}).`); + } + return body; } -/** When the access token expires, in milliseconds, if known. */ -function expiresAt(credentials) { - const { exp } = claims(credentials.tokens?.access_token); - if (typeof exp === "number") return exp * 1e3; - const { savedAt, tokens } = credentials; - return savedAt && tokens?.expires_in ? savedAt + tokens.expires_in * 1e3 : void 0; -} -/** A copy of the credentials without one field. */ -function without(credentials, key) { - const copy = { ...credentials }; - delete copy[key]; - return copy; -} -const refreshEarlyMs = 6e4; -function needsRefresh(credentials, now = Date.now()) { - const expires = expiresAt(credentials); - return expires !== void 0 && expires - refreshEarlyMs <= now; +//#endregion +//#region src/apps.ts +/** The Fulldev MCP server: the tools of every app the person may use. */ +const server = { + title: "Fulldev", + url: "https://app.full.dev/mcp" +}; +/** The environment variable that overrides the server's URL. */ +const urlVariable = "FULLDEV_URL"; +const apps = [ + { + name: "cms", + title: "Fulldev CMS", + description: "Edit your website: text, pages, images and settings, through a pull request.", + forms: true + }, + { + name: "connect", + title: "Fulldev Connect", + description: "Use your organization's business tools, such as Shopify, with the access Fulldev grants you." + }, + { + name: "scan", + title: "Fulldev Scan", + description: "Scan whole websites for problems. For administrators only." + }, + { + name: "sites", + title: "Fulldev Sites", + description: "Have Fulldev make a finished website from a brief and tested design options. For administrators only for now." + }, + { + name: "pages", + title: "Fulldev Pages", + description: "Publish reports and plans as clear web pages, composed from ready-made blocks. For administrators only for now." + }, + { + name: "contacts", + title: "Fulldev Contacts", + description: "Look after the daily copy of your Google contacts from one Google account to another: its runs, look-alike questions, pausing and resuming." + }, + { + name: "send", + title: "Fulldev Send", + description: "Manage customer message scenarios and their deliveries." + }, + { + name: "work", + title: "Fulldev Work", + description: "Plan tasks, projects and goals in your organization." + } +]; +function findApp(name) { + return apps.find((app) => app.name === name); } /** -* The scopes a sign-in asks for. The SDK would take the resource's -* `scopes_supported`, which leaves out `openid`, the authorization server's -* own scope; without it there is no ID token and so no email. -*/ -const signInScope = "openid profile email offline_access"; -/** The CLI as the client of an authorization server. */ -const cliClient = (issuer) => ({ - client_id: clientMetadataUrl, - issuer -}); -/** -* Saves tokens for a target, keeping only the email from the ID token. A -* sign-in also saves the client they were issued to, and its email only: -* it may be another person's. A refresh keeps the email it had. +* A tool's name on the server: an app's tools start with the app's name, +* which may be left out after `fulldev `. */ -async function saveTokens(store, target, tokens, issuer, client) { - const { id_token: idToken, ...kept } = tokens; - const { email } = claims(idToken); - await store.update(target.url, (current) => { - const next = { - ...current, - issuer, - ...client ? { client } : {}, - tokens: kept, - savedAt: Date.now() - }; - delete next.email; - const known = typeof email === "string" ? email : client ? void 0 : current.email; - return known ? { - ...next, - email: known - } : next; - }); +function toolName(app, name) { + return name.startsWith(`${app.name}_`) ? name : `${app.name}_${name}`; } -/** Finds the target's authorization server and checks its resource. */ -async function discover(target, fetchFn) { - const info = await discoverOAuthServerInfo(target.url, { fetchFn }); - const metadata = info.authorizationServerMetadata; - const resource = info.resourceMetadata?.resource; - if (!metadata || !info.resourceMetadata || !resource) throw new CliError("DISCOVERY_FAILED", `${target.url} does not publish OAuth metadata for its MCP server.`); - if (!checkResourceAllowed({ - requestedResource: resourceUrlFromServerUrl(target.url), - configuredResource: resource - })) throw new CliError("RESOURCE_MISMATCH", `The server names its resource ${resource}, which does not match ${target.url}.`); +/** The server: --url, then FULLDEV_URL, then app.full.dev. */ +function resolveTarget(flagUrl, env) { + const url = flagUrl ?? env["FULLDEV_URL"] ?? server.url; + let parsed; + try { + parsed = new URL(url); + } catch { + throw new UsageError(`Not a valid server URL: ${url}`); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new UsageError(`The server URL must use https: ${url}`); return { - authorizationServerUrl: info.authorizationServerUrl, - metadata, - issuer: metadata.issuer, - resource, - resourceMetadata: info.resourceMetadata + title: server.title, + url: parsed.href, + urlFromFlag: flagUrl !== void 0 }; } -const callbackPath = "/callback"; -/** -* The SDK's view of one browser sign-in: the metadata document as client, -* with PKCE S256 and a loopback redirect, on the server that discovery -* found. -*/ -var BrowserLoginProvider = class { - store; - target; - redirectUrl; - loginState; - onAuthorizationUrl; - discovery; - verifier = ""; - constructor(store, target, redirectUrl, loginState, onAuthorizationUrl, discovery) { - this.store = store; - this.target = target; - this.redirectUrl = redirectUrl; - this.loginState = loginState; - this.onAuthorizationUrl = onAuthorizationUrl; - this.discovery = discovery; - } - /** Read by the SDK for the scope; the server reads the metadata document. */ - get clientMetadata() { - return { - client_name: "Fulldev CLI", - client_uri: "https://full.dev", - redirect_uris: [this.redirectUrl], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none" - }; - } - state() { - return this.loginState; - } - /** Always the metadata document, so the SDK never registers a client. */ - clientInformation(ctx) { - return cliClient(ctx?.issuer ?? ""); - } - /** A sign-in always asks again, so it never reuses stored tokens. */ - tokens() {} - async saveTokens(tokens, ctx) { - const issuer = ctx?.issuer ?? tokens.issuer ?? ""; - await saveTokens(this.store, this.target, tokens, issuer, cliClient(issuer)); - } - redirectToAuthorization(authorizationUrl) { - this.onAuthorizationUrl(authorizationUrl); - } - saveCodeVerifier(verifier) { - this.verifier = verifier; - } - codeVerifier() { - return this.verifier; - } - saveDiscoveryState(state) { - this.discovery = state; - } - discoveryState() { - return this.discovery; +//#endregion +//#region src/args.ts +const apiTopic = { + 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." +}; +const organizationDetails = ` +When the sign-in is for more than one organization, --org names the one to +work in, by its slug from fulldev status. --all-orgs calls a tool that only +reads in every organization of the sign-in that has its app, and prints +{"organizations":[{"organization","result" or "error"}]}; it exits with 1 +when one failed.`; +const rootCommands = { + login: { + usage: "fulldev login [--no-browser] [--url ]", + summary: "Sign in, once for every app and organization", + options: ["browser", "url"], + details: `Signs in to the Fulldev MCP server in your browser, where you choose the +organizations fulldev may work in: all of yours at first. One sign-in covers +every app they give you. Run fulldev login again to change the choice, and +choose the organization of a call with --org. + +With --no-browser, open the link in a browser on any computer. When that is +another computer, as over SSH, the page after sign-in does not load: copy its +address from the address bar and paste it into this terminal.` + }, + logout: { + usage: "fulldev logout [--url ]", + summary: "Sign out and revoke the tokens", + options: ["url"], + details: `Revokes the refresh token at the authorization server and deletes the +tokens from this computer. The access token cannot be revoked and expires +within 15 minutes. When revoking fails, the local sign-out still happens and +the result says so.` + }, + status: { + usage: "fulldev status [--url ]", + summary: "Show the sign-in", + options: ["url"], + details: `Prints whether you are signed in, whether the sign-in still works, your +email, the organizations of the sign-in with their slugs and the apps you may +use in each, when the access token expires, and where the tokens are stored. Exits with 3 when a sign-in is needed, and with 1 when the +server could not be reached to check it.` + }, + instructions: { + usage: "fulldev instructions", + summary: "The instructions for agents, of every app you may use; read first", + options: [ + "url", + "login", + "browser" + ], + details: `Prints the instructions the server sends, as plain text: an overview, then +each app's own, under its name. fulldev instructions prints the +overview and that app's part.` + }, + tools: { + usage: "fulldev tools [name]", + summary: "List the tools of every app you may use, or show one tool", + options: [ + "url", + "login", + "browser" + ], + details: `Without a name: the name, title and first line of the description of each +tool. With a name: its description, input schema, output schema and +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" + ], + 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. +${organizationDetails}` + }, + help: { + usage: "fulldev help [command...]", + summary: "Show help for a command" } - invalidateCredentials(scope) { - if (scope === "verifier") this.verifier = ""; - if (scope === "discovery" || scope === "all") this.discovery = void 0; +}; +/** The commands that use the server, also per app. */ +const serverCommands = [ + "instructions", + "tools", + "call" +]; +const appCommands = { + instructions: { + usage: "fulldev instructions", + summary: "The overview and the app's own instructions; read first", + options: [ + "url", + "login", + "browser" + ], + details: "Prints them as plain text, as the server sends them." + }, + tools: { + usage: "fulldev tools [name]", + summary: "List the app's tools, or show one tool", + options: [ + "url", + "login", + "browser" + ], + details: `Without a name: the name, title and first line of the description of each +of the app's tools. With a name, with or without the app's prefix: its +description, input schema, output schema and annotations.` + }, + call: { + 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" + ], + 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. +${organizationDetails}` } }; -const tile = ``; -const page = (message) => `Fulldev CLI
${tile}

${message}

`; -/** -* Listens on 127.0.0.1, on a port the system picks, for the authorization -* server's redirect (RFC 8252). -*/ -async function listenForCallback() { - let receive = () => {}; - const received = new Promise((resolve) => { - receive = resolve; - }); - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - if (url.pathname !== callbackPath) { - response.writeHead(404).end(); - return; - } - const failed = url.searchParams.has("error"); - response.writeHead(failed ? 400 : 200, { "content-type": "text/html" }).end(page(failed ? "Sign-in did not complete. You can close this tab and check the terminal." : "Sign-in received. You can close this tab and return to the terminal."), () => receive(url.searchParams)); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); +const formWait = { + usage: "fulldev form wait [--timeout ] [--org ]", + summary: "Wait until the person sends a form", + options: [ + "timeout", + "org", + "url", + "login", + "browser" + ], + details: `Calls 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 prints the form id and exits with 2, so you can run it again.` +}; +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", + 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", + org: "--org The organization to work in", + "all-orgs": "--all-orgs Every organization of the sign-in (reading tools)" +}; +const footer = `Data is JSON on stdout; progress lines go to stderr. Every error is one JSON +object on stderr: {"error":{"code","message",...}}. +Exit codes: 0 ok, 1 error, 2 form wait timed out, 3 sign-in needed, 64 usage error. +`; +const pad = (text, width) => text.padEnd(width); +function appTopics(app) { + const name = (topic) => ({ + ...topic, + usage: topic.usage.replace("", app.name) }); - return { - port: server.address().port, - received, - close: () => { - server.closeAllConnections(); - server.close(); - } - }; + const topics = { [`${app.name} api`]: apiTopic }; + for (const [command, topic] of Object.entries(appCommands)) topics[`${app.name} ${command}`] = name(topic); + if (app.forms) { + const wait = name(formWait); + topics[`${app.name} form`] = wait; + topics[`${app.name} form wait`] = wait; + } + return topics; +} +const serverLine = `Server: ${server.url} (${urlVariable} or --url overrides it)`; +function rootHelp() { + const width = Math.max(...apps.map((app) => app.name.length)) + 4; + return `Fulldev CLI: use Fulldev apps from a terminal or an AI agent, through the +Fulldev MCP server. One sign-in covers every app of the organizations you +choose; --org picks one per call. + +Usage: + fulldev [options] + fulldev [options] + +Commands: + fulldev api [.] List or call an OpenAPI operation +${Object.values(rootCommands).map((topic) => ` ${pad(topic.usage.split(" [--")[0], 32)}${topic.summary}`).join("\n")} + +Apps (their tools start with the app's name, such as cms_): +${apps.map((app) => ` ${pad(app.name, width)}${app.description}`).join("\n")} + +App commands, for one app's part: +${[...Object.values(appCommands), { + ...formWait, + usage: formWait.usage.replace("", "cms") + }].map((topic) => ` ${topic.usage.split(" [--")[0]}\n ${topic.summary}`).join("\n")} + +Run fulldev instructions first and follow them. +Run fulldev help for a command's options, for example +fulldev help cms call. + +${serverLine} + +Options: + -h, --help Show help + -v, --version Show the version + +${footer}`; } -/** The query of a pasted address, when it is the redirect after sign-in. */ -function redirectQuery(line) { - try { - const { searchParams } = new URL(line.trim()); - return searchParams.has("state") ? searchParams : void 0; - } catch { - return; - } +function appHelp(app) { + const topics = Object.values(appTopics(app)).filter((topic, index, all) => all.indexOf(topic) === index); + return `${app.title} + +${app.description} + +Its tools start with ${app.name}_; after fulldev ${app.name} you may leave that out. + +Usage: +${topics.map((topic) => ` ${topic.usage}\n ${topic.summary}`).join("\n")} + +Run fulldev ${app.name} instructions first and follow them. +Sign in with fulldev login, once for every app. + +${serverLine} + +${footer}`; } -/** -* Reads the address of the page after sign-in, pasted into the terminal. -* With the browser on another computer, such as over SSH, the redirect -* cannot reach this one's loopback port, but the address bar still holds -* the code. Other lines are asked for again; at the end of the input, the -* loopback redirect can still arrive. -*/ -function readPastedRedirect(input, log) { - const lines = createInterface({ - input, - terminal: false - }); - return { - received: new Promise((resolve) => { - lines.on("line", (line) => { - const query = redirectQuery(line); - if (query) resolve(query); - else if (line.trim()) log("That is not the address of the page after sign-in. Paste the whole address from the browser's address bar."); - }); - }), - close: () => lines.close() - }; +function topicHelp(topic) { + const options = topic.options?.map((option) => ` ${optionHelp[option]}`); + return `Usage: ${topic.usage} + +${topic.summary}. +${topic.details ? `\n${topic.details}\n` : ""}${options?.length ? `\nOptions:\n${options.join("\n")}\n` : ""} +${footer}`; } -/** The first of the promises to settle, or a timeout error. */ -async function firstWithin(timeoutMs, promises) { - let timer; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new CliError("SIGN_IN_TIMEOUT", `Sign-in timed out after ${Math.round(timeoutMs / 6e4)} minutes.`)), timeoutMs); - }); - try { - return await Promise.race([...promises, timeout]); - } finally { - clearTimeout(timer); - } +/** The help text for a topic such as "", "login", "cms" or "cms call". */ +function helpText(topic) { + if (topic === "") return rootHelp(); + if (topic === "api" || topic.endsWith(" api")) return topicHelp(apiTopic); + const root = rootCommands[topic]; + if (root) return topicHelp(root); + const app = findApp(topic); + if (app) return appHelp(app); + const [name] = topic.split(" "); + const owner = findApp(name ?? ""); + const found = owner ? appTopics(owner)[topic] : void 0; + return found ? topicHelp(found) : void 0; } -/** Opens a URL in the system browser; the URL is printed as well. */ -function openBrowser(url) { - const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [ - "/c", - "start", - "", - url.replaceAll("&", "^&") - ]] : ["xdg-open", [url]]; - try { - const child = spawn(command, args, { - stdio: "ignore", - detached: true - }); - child.on("error", () => {}); - child.unref(); - } catch {} +/** The longest leading words of `words` that name a help topic. */ +function closestTopic(words) { + for (let length = Math.min(words.length, 3); length > 0; length--) { + const topic = words.slice(0, length).join(" "); + if (helpText(topic) !== void 0) return topic; + } + return ""; } -/** -* Signs in with the authorization code flow and PKCE S256, the system -* browser and a loopback redirect on an ephemeral port, asking for the -* app's MCP URL as the resource. Without the browser, the address after -* sign-in can also be pasted, for a browser on another computer. -*/ -async function browserLogin(target, store, { browser, log, timeoutMs = 6e5, fetchFn, input = process.stdin }) { - const discovery = await discover(target, fetchFn); - if (discovery.metadata.client_id_metadata_document_supported !== true) throw new CliError("CLIENT_METADATA_UNSUPPORTED", `The authorization server ${discovery.issuer} does not support Client ID Metadata Documents, which fulldev needs to sign in to ${target.title}.`); - const callback = await listenForCallback(); - const pasted = browser ? void 0 : readPastedRedirect(input, log); +const helpFor = (topic) => topic ? `fulldev ${topic} --help` : "fulldev --help"; +function parseCommandLine(argv, env = process.env) { + let parsed; try { - const state = randomBytes(16).toString("base64url"); - const provider = new BrowserLoginProvider(store, target, `http://127.0.0.1:${callback.port}${callbackPath}`, state, (url) => { - if (browser) { - log(`Sign in to ${target.title} in your browser. If it does not open, open:`); - log(url.href); - openBrowser(url.href); - return; + parsed = parseArgs({ + args: argv, + allowPositionals: true, + allowNegative: true, + strict: true, + options: { + url: { type: "string" }, + file: { + type: "string", + short: "f" + }, + org: { type: "string" }, + "all-orgs": { type: "boolean" }, + timeout: { type: "string" }, + login: { type: "boolean" }, + browser: { type: "boolean" }, + help: { + type: "boolean", + short: "h" + }, + version: { + type: "boolean", + short: "v" + } } - log(`Open this link to sign in to ${target.title}:`); - log(url.href); - log("If the browser is on another computer, the page after sign-in does not load: copy its address from the address bar and paste it here."); - }, { - authorizationServerUrl: String(discovery.authorizationServerUrl), - resourceMetadata: discovery.resourceMetadata, - authorizationServerMetadata: discovery.metadata - }); - if (await auth(provider, { - serverUrl: target.url, - scope: signInScope, - forceReauthorization: true, - ...fetchFn ? { fetchFn } : {} - }) === "AUTHORIZED") return; - const params = await firstWithin(timeoutMs, [callback.received, ...pasted ? [pasted.received] : []]); - if (params.get("state") !== state) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: the response did not match this sign-in."); - const error = params.get("error"); - if (error) throw new CliError("SIGN_IN_FAILED", `Sign-in failed: ${[error, params.get("error_description")].filter(Boolean).join(": ")}`, void 0, { oauthError: error }); - const code = params.get("code"); - if (!code) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: no authorization code."); - await auth(provider, { - serverUrl: target.url, - scope: signInScope, - authorizationCode: code, - iss: params.get("iss") ?? void 0, - ...fetchFn ? { fetchFn } : {} }); - } finally { - callback.close(); - pasted?.close(); + } catch (error) { + throw new UsageError(error.message, helpFor(closestTopic(argv.filter((word) => !word.startsWith("-"))))); + } + const { values, positionals } = parsed; + if (values.version) return { kind: "version" }; + if (positionals[0] === "help") { + const topic = positionals.slice(1).join(" "); + if (helpText(topic) === void 0) throw new UsageError(`No help for: ${topic}`); + return { + kind: "help", + topic + }; + } + if (values.help) return { + kind: "help", + topic: closestTopic(positionals) + }; + if (positionals.length === 0) return { + kind: "help", + topic: "" + }; + const [first, ...rest] = positionals; + const given = Object.keys(values).filter((key) => values[key] !== void 0); + const allow = (topic, options = []) => { + const extra = given.find((key) => !options.includes(key)); + if (extra) throw new UsageError(`--${extra === "login" || extra === "browser" ? `no-${extra}` : extra} does not work with fulldev ${topic}.`, helpFor(topic)); + }; + const target = resolveTarget(values.url, env); + const session = { + login: values.login ?? true, + browser: values.browser ?? true + }; + if (first === "login" || first === "logout" || first === "status") { + allow(first, rootCommands[first].options); + if (rest.length) { + const app = findApp(rest[0]); + const command = `fulldev ${first}${target.urlFromFlag ? ` --url ${target.url}` : ""}`; + throw new UsageError(app ? `One sign-in covers every app, ${app.title} too. Run: ${command}` : `fulldev ${first} takes no app: one sign-in covers every app. Run: ${command}`, helpFor(first), { command }); + } + if (first === "login") return { + kind: "login", + target, + browser: values.browser ?? true + }; + return { + kind: first, + target + }; + } + if (first === "api" || findApp(first) && rest[0] === "api") { + allow("api", apiTopic.options); + const owner = first === "api" ? rest[0]?.split(".")[0] : first; + const app = owner && findApp(owner); + if (!app) throw new UsageError("Use fulldev api [.].", "fulldev api --help"); + 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."); + return { + kind: "api", + target, + session, + app, + operation: operation || void 0, + json: words[1], + file: values.file, + organization: values.org + }; + } + const app = findApp(first); + const command = app ? rest[0] : first; + const args = app ? rest.slice(1) : rest; + if (!app && !serverCommands.includes(first)) throw new UsageError(`Unknown command or app: ${first}`); + if (app && command === void 0) { + allow(app.name); + return { + kind: "help", + topic: app.name + }; + } + const topic = app ? `${app.name} ${command}` : first; + const known = app ? appTopics(app)[topic] : rootCommands[topic]; + if (!known) throw new UsageError(`Unknown command: fulldev ${topic}`, helpFor(app?.name ?? "")); + allow(topic, known.options); + const usage = (count) => { + if (!count) throw new UsageError(`Usage: ${known.usage}`, helpFor(topic)); + }; + switch (command) { + case "instructions": + usage(args.length === 0); + return { + kind: "instructions", + target, + session, + app + }; + case "tools": + usage(args.length <= 1); + return { + kind: "tools", + target, + session, + app, + name: args[0] + }; + 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)); + return { + kind: "call", + target, + session, + tool: app ? toolName(app, args[0]) : args[0], + json: args[1], + file: values.file, + organization: values.org, + allOrganizations: values["all-orgs"] + }; + default: { + usage(app !== void 0 && args[0] === "wait" && args.length === 3); + const timeoutMinutes = values.timeout === void 0 ? 30 : Number(values.timeout); + if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) throw new UsageError("--timeout must be a number of minutes above 0.", helpFor(topic)); + return { + kind: "form-wait", + target, + session, + app, + branchId: args[1], + formId: args[2], + timeoutMinutes, + organization: values.org + }; + } } } -const formHeaders = { - "content-type": "application/x-www-form-urlencoded", - accept: "application/json" -}; -async function readJson(response) { - try { - const value = await response.json(); - return value && typeof value === "object" ? value : {}; - } catch { - return {}; +//#endregion +//#region src/result.ts +/** A tool result as JSON: its structured content, or its text parsed as JSON. */ +function toolOutput(result) { + if (result.structuredContent !== void 0) return result.structuredContent; + const values = result.content.flatMap((block) => block.type === "text" ? [block.text] : []).map((text) => { + try { + return JSON.parse(text); + } catch { + return text; + } + }); + return values.length === 1 ? values[0] : values; +} +/** A failed tool result as {"error":{"code","message",...}}, whatever the server sent. */ +function toolError(result) { + const output = toolOutput(result); + if (output !== null && typeof output === "object" && !Array.isArray(output)) { + const { error } = output; + if (error !== null && typeof error === "object" && !Array.isArray(error)) return output; + return { error: { + code: "TOOL_ERROR", + message: "The tool failed.", + details: output + } }; } + return { error: { + code: "TOOL_ERROR", + message: typeof output === "string" ? output : JSON.stringify(output) + } }; } -const text$1 = (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 -* then needs a new sign-in. -*/ -async function refreshTokens(target, store, fetchFn) { - const credentials = await store.get(target.url); - const refreshToken = credentials.tokens?.refresh_token; - if (!refreshToken) throw new SignInRequiredError(target); - const discovery = await discover(target, fetchFn); - const { client } = credentials; - if (!client || credentials.issuer && credentials.issuer !== discovery.issuer) throw new SignInRequiredError(target); - let tokens; - try { - tokens = await refreshAuthorization(discovery.authorizationServerUrl, { - metadata: discovery.metadata, - clientInformation: client, - refreshToken, - resource: new URL(discovery.resource), - ...fetchFn ? { fetchFn } : {} - }); - } catch (error) { - if (error instanceof OAuthError && (error.code === OAuthErrorCode.InvalidGrant || error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient)) { - await store.update(target.url, (current) => without(current, "tokens")); - throw new SignInRequiredError(target); +//#endregion +//#region src/form-wait.ts +/** The one tool the CLI names itself, without its app's prefix. */ +const waitTool = "wait_for_form"; +const maxFailures = 3; +const retryMs = 5e3; +function minutes(ms) { + const total = Math.max(0, Math.round(ms / 1e3)); + return `${Math.floor(total / 60)}m ${total % 60}s`; +} +function wait(ms, signal) { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); } - throw error; - } - await saveTokens(store, target, tokens, discovery.issuer); + }); } /** -* Revokes the refresh token (RFC 7009), sending the client_id as a public -* client. Never throws: the result says what failed. +* Calls wait_for_form while it answers waiting true. Each call waits on the +* server, so the loop does not sleep between rounds. Network failures are +* retried a few times; tool and protocol errors end the wait. The deadline +* also aborts a request that is still running. */ -async function revokeTokens(target, credentials, fetchFn = fetch) { - const { tokens, client } = credentials; - if (!tokens) return { revoked: true }; - if (!client) return { - revoked: false, - error: "The sign-in is from an earlier version of fulldev and cannot be revoked." - }; +async function waitForForm(callTool, { branchId, formId, timeoutMs, app = "cms", progress = () => {}, now = Date.now, sleep = wait }) { + const deadline = now() + timeoutMs; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const { signal } = controller; + const aborted = () => signal.aborted; + let failures = 0; try { - const endpoint = (credentials.issuer ? await discoverAuthorizationServerMetadata(credentials.issuer, { fetchFn }) : (await discover(target, fetchFn)).metadata)?.revocation_endpoint; - if (!endpoint) return { - revoked: false, - error: "The authorization server has no revocation endpoint." - }; - const failures = []; - for (const [token, hint] of [[tokens.refresh_token, "refresh_token"]]) { - if (!token) continue; - const body = new URLSearchParams({ - token, - token_type_hint: hint, - client_id: client.client_id - }); - if (client.client_secret) body.set("client_secret", client.client_secret); - const response = await fetchFn(endpoint, { - method: "POST", - headers: formHeaders, - body - }); - 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(": ")}`); + for (let round = 1; now() < deadline && !aborted(); round++) { + let result; + try { + result = await callTool(`${app}_${waitTool}`, { + branchId, + formId + }, signal); + failures = 0; + } catch (error) { + if (aborted()) break; + if (isSignInRequired(error) || error instanceof ProtocolError || ++failures >= maxFailures) throw error; + progress(`The request failed (${error.message}); retrying.`); + await sleep(retryMs, signal); + continue; } + if (result.isError) return { + status: "error", + error: toolError(result) + }; + const output = toolOutput(result); + if (!(output !== null && typeof output === "object" && output.waiting === true)) return { + status: "done", + result: output + }; + progress(`Waiting for the person to send the form (round ${round}, ${minutes(deadline - now())} left).`); } - return failures.length ? { - revoked: false, - error: `Revocation failed for ${failures.join("; ")}` - } : { revoked: true }; - } catch (error) { - return { - revoked: false, - error: error.message - }; - } -} -/** -* Gives the MCP transport the stored access token of one app, refreshing -* it when it is about to expire or the server answers 401. Refreshes happen -* under the store's lock, and a token another process refreshed meanwhile is -* used instead of refreshing again. It never starts a sign-in. -*/ -var StoredTokenAuth = class { - target; - store; - sent; - constructor(target, store) { - this.target = target; - this.store = store; - } - async refreshUnless(changed, fetchFn) { - await this.store.locked(async () => { - if (!changed(await this.store.get(this.target.url))) await refreshTokens(this.target, this.store, fetchFn); - }); + } finally { + clearTimeout(timer); } - async token() { - let credentials = await this.store.get(this.target.url); - if (!credentials.tokens) throw new SignInRequiredError(this.target); - if (needsRefresh(credentials)) { - const stale = credentials.tokens.access_token; - await this.refreshUnless((current) => current.tokens?.access_token !== stale && !needsRefresh(current)); - credentials = await this.store.get(this.target.url); + return { + status: "timeout", + result: { + waiting: true, + branchId, + formId, + message: `Stopped waiting after ${minutes(timeoutMs)}. Run fulldev ${app} form wait ${branchId} ${formId} to keep waiting, or continue when the person says they are done.` } - this.sent = credentials.tokens?.access_token; - return this.sent; - } - async onUnauthorized(ctx) { - await this.refreshUnless((current) => current.tokens?.access_token !== void 0 && current.tokens.access_token !== this.sent, ctx.fetchFn); - } -}; + }; +} //#endregion //#region src/main.ts /** A tool call can wait on the server for up to a minute. */ @@ -1583,6 +1708,11 @@ async function run(command, io) { } case "logout": return logout(command.target, io); case "status": return status(command.target, io); + case "api": { + const data = await runApi(command, io); + io.stdout(json(data)); + return exitCodes.ok; + } case "instructions": { const text = await withClient(command.target, command.session, io, async (client) => client.getInstructions()); if (!text) log("The server sent no instructions."); diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..6967055 --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi, beforeEach } from "vite-plus/test" + +import { runApi } from "./api.ts" +import { parseCommandLine } from "./args.ts" +import type { Io } from "./main.ts" +import { CredentialStore } from "./credentials.ts" + +const token = vi.hoisted(() => + vi.fn(async () => "private-token" as string | undefined), +) +vi.mock("./oauth.ts", () => ({ + StoredTokenAuth: class { + token = token + }, + browserLogin: vi.fn(), +})) + +const io: Io = { + interactive: false, + stdout: vi.fn(), + stderr: vi.fn(), + env: {}, + store: new CredentialStore("/unused"), +} +const spec = { + paths: { + "/records/list": { + get: { operationId: "records.list", summary: "List records" }, + }, + "/records/update": { post: { operationId: "records.update" } }, + }, +} +const command = (args: Array) => { + const parsed = parseCommandLine(args, {}) + if (parsed.kind !== "api") throw new Error("Expected an API command") + return parsed +} + +beforeEach(() => { + token.mockReset().mockResolvedValue("private-token") +}) + +describe("HTTP API command", () => { + it("supports both command forms, input files and organization selection", () => { + expect( + command(["api", "contacts.records.list", "{}", "--org", "first"]), + ).toMatchObject({ + app: { name: "contacts" }, + operation: "records.list", + organization: "first", + }) + expect( + command(["contacts", "api", "records.list", "--file", "input.json"]), + ).toMatchObject({ operation: "records.list", file: "input.json" }) + }) + it("discovers all HTTP operations without signing in", async () => { + const fetchFn = vi.fn(async () => Response.json(spec)) + expect(await runApi(command(["api", "contacts"]), io, fetchFn)).toEqual([ + { + path: "/records/list", + method: "GET", + operationId: "records.list", + summary: "List records", + }, + { + path: "/records/update", + method: "POST", + operationId: "records.update", + }, + ]) + expect(token).not.toHaveBeenCalled() + }) + it("encodes GET input and sends credentials only to the chosen server", async () => { + const fetchFn = vi.fn(async (url, init) => { + if (new Request(url).url.endsWith("openapi.json")) + return Response.json(spec) + const actual = new URL(new Request(url).url) + expect(actual.origin).toBe("https://preview.example") + expect(actual.searchParams.get("enabled")).toBe("false") + expect(actual.searchParams.get("limit")).toBe("3") + expect(init?.redirect).toBe("error") + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer private-token", + ) + expect(new Headers(init?.headers).get("x-fulldev-organization")).toBe( + "first", + ) + return Response.json({ records: [] }) + }) + expect( + await runApi( + command([ + "api", + "contacts.records.list", + '{"limit":3,"enabled":false}', + "--org", + "first", + "--url", + "https://preview.example/mcp", + ]), + io, + fetchFn, + ), + ).toEqual({ records: [] }) + }) + it("sends POST input and preserves actionable permission failures", async () => { + const fetchFn = vi.fn(async (url, init) => { + if (new Request(url).url.endsWith("openapi.json")) + return Response.json(spec) + expect(init?.method).toBe("POST") + expect(init?.body).toBe('{"id":"r"}') + return Response.json( + { code: "FORBIDDEN", message: "Admin access required." }, + { status: 403 }, + ) + }) + await expect( + runApi( + command(["api", "contacts.records.update", '{"id":"r"}']), + io, + fetchFn, + ), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin access required.", + }) + expect(fetchFn).toHaveBeenCalledTimes(2) + }) + it("rejects unknown operations and paths outside the app before obtaining credentials", async () => { + const fetchFn = vi.fn(async () => Response.json(spec)) + await expect( + runApi(command(["api", "contacts.nope"]), io, fetchFn), + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + const invalid = vi.fn(async () => + Response.json({ + paths: { "/../../elsewhere": { post: { operationId: "escape" } } }, + }), + ) + await expect( + runApi(command(["api", "contacts.escape"]), io, invalid), + ).rejects.toMatchObject({ code: "INVALID_API" }) + expect(token).not.toHaveBeenCalled() + }) + it("reports a sign-in requirement without an interactive prompt", async () => { + token.mockResolvedValue(undefined) + const fetchFn = vi.fn(async () => Response.json(spec)) + await expect( + runApi(command(["api", "contacts.records.list"]), io, fetchFn), + ).rejects.toMatchObject({ code: "SIGN_IN_REQUIRED" }) + expect(fetchFn).toHaveBeenCalledTimes(1) + }) + it("reports non-JSON gateway errors without retrying a mutation", async () => { + const fetchFn = vi.fn(async (url) => + new Request(url).url.endsWith("openapi.json") + ? Response.json(spec) + : new Response("Bad gateway", { status: 502 }), + ) + await expect( + runApi(command(["api", "contacts.records.update", "{}"]), io, fetchFn), + ).rejects.toMatchObject({ + code: "API_FAILED", + message: "API returned a non-JSON response (502).", + }) + expect(fetchFn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..467f934 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,147 @@ +import type { Command } from "./args.ts" +import type { Io } from "./main.ts" +import { CliError, SignInRequiredError } from "./errors.ts" +import { readToolArguments } from "./input.ts" +import { StoredTokenAuth, browserLogin } from "./oauth.ts" + +interface Parameter { + name: string + in: string +} +interface Operation { + operationId?: string + summary?: string + parameters?: Array +} +interface Specification { + paths?: Record> +} + +export function apiOperations(spec: Specification) { + return Object.entries(spec.paths ?? {}).flatMap(([path, methods]) => + Object.entries(methods) + .filter(([method]) => + ["get", "post", "put", "patch", "delete"].includes(method), + ) + .map(([method, operation]) => ({ + path, + method: method.toUpperCase(), + ...operation, + })), + ) +} + +/** All HTTP operations from the server's specification, independent of its MCP tool selection. */ +export async function runApi( + command: Extract, + io: Io, + fetchFn: typeof fetch = fetch, +) { + const { target, app } = command + const origin = new URL(target.url).origin + const specResponse = await fetchFn(`${origin}/${app.name}/api/openapi.json`, { + redirect: "error", + signal: AbortSignal.timeout(30_000), + }) + if (!specResponse.ok) + throw new CliError( + "API_UNAVAILABLE", + "This server does not expose the app's OpenAPI specification.", + ) + const operations = apiOperations((await specResponse.json()) as Specification) + 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 input = await readToolArguments({ + json: command.json, + file: command.file, + }) + const url = new URL(`/${app.name}/api/v1${operation.path}`, origin) + if (url.origin !== origin || !url.pathname.startsWith(`/${app.name}/api/v1/`)) + throw new CliError( + "INVALID_API", + "The server returned an invalid operation path.", + ) + const reads = operation.method === "GET" + if (reads) + for (const [key, value] of Object.entries(input)) { + if (value === undefined) continue + if (Array.isArray(value)) + value.forEach((part) => + url.searchParams.append( + key, + typeof part === "string" ? part : JSON.stringify(part), + ), + ) + else + url.searchParams.set( + key, + typeof value === "string" ? value : JSON.stringify(value), + ) + } + const auth = new StoredTokenAuth(target, io.store) + const attempt = async () => { + const token = await auth.token() + if (!token) throw new SignInRequiredError(target) + const headers = new Headers({ authorization: `Bearer ${token}` }) + if (command.organization) + headers.set("x-fulldev-organization", command.organization) + if (!reads) headers.set("content-type", "application/json") + return fetchFn(url, { + method: operation.method, + headers, + body: reads ? undefined : JSON.stringify(input), + redirect: "error", + signal: AbortSignal.timeout(120_000), + }) + } + let response: Response + try { + response = await attempt() + } catch (error) { + if ( + !(error instanceof SignInRequiredError) || + !io.interactive || + !command.session.login + ) + throw error + await browserLogin(target, io.store, { + browser: command.session.browser, + log: (line) => io.stderr(`${line}\n`), + }) + response = await attempt() + } + if (response.status === 401) { + await auth.onUnauthorized({ fetchFn }) + response = await attempt() + } + if (response.status === 401) throw new SignInRequiredError(target) + const body: unknown = + response.status === 204 + ? null + : await response.json().catch(() => { + throw new CliError( + "API_FAILED", + `API returned a non-JSON response (${response.status}).`, + ) + }) + if (!response.ok) { + const failure = body as { + code?: string + message?: string + error?: { code?: string; message?: string } + } | null + const error = failure?.error ?? failure ?? {} + throw new CliError( + error.code ?? "API_FAILED", + error.message ?? `API request failed (${response.status}).`, + ) + } + return body +} diff --git a/src/apps.test.ts b/src/apps.test.ts index dea538e..dbcda59 100644 --- a/src/apps.test.ts +++ b/src/apps.test.ts @@ -13,6 +13,8 @@ describe("apps", () => { "sites", "pages", "contacts", + "send", + "work", ]) expect(server.url).toBe("https://app.full.dev/mcp") for (const app of apps) { diff --git a/src/apps.ts b/src/apps.ts index b2d5090..933af20 100644 --- a/src/apps.ts +++ b/src/apps.ts @@ -58,6 +58,16 @@ export const apps: Array = [ description: "Look after the daily copy of your Google contacts from one Google account to another: its runs, look-alike questions, pausing and resuming.", }, + { + name: "send", + title: "Fulldev Send", + description: "Manage customer message scenarios and their deliveries.", + }, + { + name: "work", + title: "Fulldev Work", + description: "Plan tasks, projects and goals in your organization.", + }, ] /** The server this run uses. */ diff --git a/src/args.ts b/src/args.ts index 70633ba..aa91cd4 100644 --- a/src/args.ts +++ b/src/args.ts @@ -12,6 +12,16 @@ import { import type { App, Target } from "./apps.ts" export type Command = + | { + kind: "api" + target: Target + session: Session + app: App + operation?: string + json?: string + file?: string + organization?: string + } | { kind: "help"; topic: string } | { kind: "version" } | { kind: "login"; target: Target; browser: boolean } @@ -73,6 +83,15 @@ interface Topic { details?: string } +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.", +} + const organizationDetails = ` When the sign-in is for more than one organization, --org names the one to work in, by its slug from fulldev status. --all-orgs calls a tool that only @@ -210,7 +229,7 @@ function appTopics(app: App): Record { ...topic, usage: topic.usage.replace("", app.name), }) - const topics: Record = {} + const topics: Record = { [`${app.name} api`]: apiTopic } for (const [command, topic] of Object.entries(appCommands)) topics[`${app.name} ${command}`] = name(topic) if (app.forms) { @@ -234,6 +253,7 @@ Usage: fulldev [options] Commands: + fulldev api [.] List or call an OpenAPI operation ${Object.values(rootCommands) .map((topic) => ` ${pad(topic.usage.split(" [--")[0]!, 32)}${topic.summary}`) .join("\n")} @@ -295,6 +315,7 @@ ${footer}` /** The help text for a topic such as "", "login", "cms" or "cms call". */ export function helpText(topic: string): string | undefined { if (topic === "") return rootHelp() + if (topic === "api" || topic.endsWith(" api")) return topicHelp(apiTopic) const root = rootCommands[topic] if (root) return topicHelp(root) const app = findApp(topic) @@ -396,6 +417,33 @@ export function parseCommandLine( return { kind: first, target } } + if (first === "api" || (findApp(first) && rest[0] === "api")) { + allow("api", apiTopic.options) + const owner = first === "api" ? rest[0]?.split(".")[0] : first + const app = owner && findApp(owner) + if (!app) + throw new UsageError( + "Use fulldev api [.].", + "fulldev api --help", + ) + 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.") + return { + kind: "api", + target, + session, + app, + operation: operation || undefined, + json: words[1], + file: values.file, + organization: values.org, + } + } + const app = findApp(first) const command = app ? rest[0] : first const args = app ? rest.slice(1) : rest diff --git a/src/main.ts b/src/main.ts index 8e4123c..a4548da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { import type { Tool } from "@modelcontextprotocol/client" import pkg from "../package.json" with { type: "json" } +import { runApi } from "./api.ts" import { helpText, parseCommandLine } from "./args.ts" import type { Command, Session } from "./args.ts" import type { CredentialStore } from "./credentials.ts" @@ -342,6 +343,11 @@ async function run(command: Command, io: Io): Promise { return logout(command.target, io) case "status": return status(command.target, io) + case "api": { + const data = await runApi(command, io) + io.stdout(json(data)) + return exitCodes.ok + } case "instructions": { const text = await withClient( command.target, From 6933f7dd002439179ffaec00fc0e3f5fb1c5b498 Mon Sep 17 00:00:00 2001 From: Sil Date: Sun, 11 Oct 2026 18:05:26 +0200 Subject: [PATCH 2/3] Restore interactive login after rejected API credentials --- dist/cli.mjs | 18 ++++++++++------- src/api.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++++- src/api.ts | 18 ++++++++++------- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/dist/cli.mjs b/dist/cli.mjs index 5e0eaf7..db00801 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -827,22 +827,26 @@ async function runApi(command, io, fetchFn = fetch) { signal: AbortSignal.timeout(12e4) }); }; + const authenticated = async () => { + let response = await attempt(); + if (response.status === 401) { + await auth.onUnauthorized({ fetchFn }); + response = await attempt(); + } + if (response.status === 401) throw new SignInRequiredError(target); + return response; + }; let response; try { - response = await attempt(); + response = await authenticated(); } catch (error) { if (!(error instanceof SignInRequiredError) || !io.interactive || !command.session.login) throw error; await browserLogin(target, io.store, { browser: command.session.browser, log: (line) => io.stderr(`${line}\n`) }); - response = await attempt(); - } - if (response.status === 401) { - await auth.onUnauthorized({ fetchFn }); - response = await attempt(); + response = await authenticated(); } - if (response.status === 401) throw new SignInRequiredError(target); const body = response.status === 204 ? null : await response.json().catch(() => { throw new CliError("API_FAILED", `API returned a non-JSON response (${response.status}).`); }); diff --git a/src/api.test.ts b/src/api.test.ts index 6967055..a99f9ed 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -2,17 +2,21 @@ import { describe, expect, it, vi, beforeEach } from "vite-plus/test" import { runApi } from "./api.ts" import { parseCommandLine } from "./args.ts" +import { SignInRequiredError } from "./errors.ts" import type { Io } from "./main.ts" import { CredentialStore } from "./credentials.ts" const token = vi.hoisted(() => vi.fn(async () => "private-token" as string | undefined), ) +const refresh = vi.hoisted(() => vi.fn(async () => {})) +const login = vi.hoisted(() => vi.fn(async () => {})) vi.mock("./oauth.ts", () => ({ StoredTokenAuth: class { token = token + onUnauthorized = refresh }, - browserLogin: vi.fn(), + browserLogin: login, })) const io: Io = { @@ -38,6 +42,8 @@ const command = (args: Array) => { beforeEach(() => { token.mockReset().mockResolvedValue("private-token") + refresh.mockReset().mockResolvedValue(undefined) + login.mockReset().mockResolvedValue(undefined) }) describe("HTTP API command", () => { @@ -149,6 +155,50 @@ describe("HTTP API command", () => { ).rejects.toMatchObject({ code: "SIGN_IN_REQUIRED" }) expect(fetchFn).toHaveBeenCalledTimes(1) }) + it("starts interactive login when a rejected token cannot be refreshed", async () => { + const parsed = command(["api", "contacts.records.list"]) + refresh.mockRejectedValueOnce(new SignInRequiredError(parsed.target)) + const fetchFn = vi + .fn() + .mockResolvedValueOnce(Response.json(spec)) + .mockResolvedValueOnce(Response.json({}, { status: 401 })) + .mockResolvedValueOnce(Response.json({ records: [] })) + expect(await runApi(parsed, { ...io, interactive: true }, fetchFn)).toEqual( + { records: [] }, + ) + expect(login).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledTimes(1) + }) + it("starts login if a refreshed token is also rejected, with bounded retries", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(Response.json(spec)) + .mockResolvedValueOnce(Response.json({}, { status: 401 })) + .mockResolvedValueOnce(Response.json({}, { status: 401 })) + .mockResolvedValueOnce(Response.json({ records: [] })) + expect( + await runApi( + command(["api", "contacts.records.list"]), + { ...io, interactive: true }, + fetchFn, + ), + ).toEqual({ records: [] }) + expect(login).toHaveBeenCalledTimes(1) + expect(fetchFn).toHaveBeenCalledTimes(4) + }) + it("respects --no-login when a rejected token cannot be refreshed", async () => { + const parsed = command(["api", "contacts.records.list", "--no-login"]) + refresh.mockRejectedValueOnce(new SignInRequiredError(parsed.target)) + const fetchFn = vi + .fn() + .mockResolvedValueOnce(Response.json(spec)) + .mockResolvedValueOnce(Response.json({}, { status: 401 })) + await expect( + runApi(parsed, { ...io, interactive: true }, fetchFn), + ).rejects.toMatchObject({ code: "SIGN_IN_REQUIRED" }) + expect(login).not.toHaveBeenCalled() + expect(fetchFn).toHaveBeenCalledTimes(2) + }) it("reports non-JSON gateway errors without retrying a mutation", async () => { const fetchFn = vi.fn(async (url) => new Request(url).url.endsWith("openapi.json") diff --git a/src/api.ts b/src/api.ts index 467f934..12608d6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -101,9 +101,18 @@ export async function runApi( signal: AbortSignal.timeout(120_000), }) } + const authenticated = async () => { + let response = await attempt() + if (response.status === 401) { + await auth.onUnauthorized({ fetchFn }) + response = await attempt() + } + if (response.status === 401) throw new SignInRequiredError(target) + return response + } let response: Response try { - response = await attempt() + response = await authenticated() } catch (error) { if ( !(error instanceof SignInRequiredError) || @@ -115,13 +124,8 @@ export async function runApi( browser: command.session.browser, log: (line) => io.stderr(`${line}\n`), }) - response = await attempt() - } - if (response.status === 401) { - await auth.onUnauthorized({ fetchFn }) - response = await attempt() + response = await authenticated() } - if (response.status === 401) throw new SignInRequiredError(target) const body: unknown = response.status === 204 ? null From 9f0846194c782081a6a08ca1e32de0d759b81eb1 Mon Sep 17 00:00:00 2001 From: Sil Date: Sun, 11 Oct 2026 21:00:37 +0200 Subject: [PATCH 3/3] Encode HTTP API path parameters and clarify app command help --- dist/cli.mjs | 23 ++++++++++++++++++----- src/api.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 24 +++++++++++++++++++++--- src/args.test.ts | 8 ++++++++ src/args.ts | 13 +++++++++++-- 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/dist/cli.mjs b/dist/cli.mjs index db00801..33e3b21 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -804,10 +804,17 @@ async function runApi(command, io, fetchFn = fetch) { json: command.json, file: command.file }); - const url = new URL(`/${app.name}/api/v1${operation.path}`, origin); + const payload = { ...input }; + const path = operation.path.replace(/\{([^{}]+)\}/g, (_, name) => { + const value = input[name]; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" || value === "" || value === "." || value === "..") throw new CliError("INVALID_INPUT", `Provide a scalar value for path parameter: ${name}.`); + delete payload[name]; + return encodeURIComponent(String(value)); + }); + const url = new URL(`/${app.name}/api/v1${path}`, origin); if (url.origin !== origin || !url.pathname.startsWith(`/${app.name}/api/v1/`)) throw new CliError("INVALID_API", "The server returned an invalid operation path."); const reads = operation.method === "GET"; - if (reads) for (const [key, value] of Object.entries(input)) { + if (reads) for (const [key, value] of Object.entries(payload)) { if (value === void 0) continue; if (Array.isArray(value)) value.forEach((part) => url.searchParams.append(key, typeof part === "string" ? part : JSON.stringify(part))); else url.searchParams.set(key, typeof value === "string" ? value : JSON.stringify(value)); @@ -822,7 +829,7 @@ async function runApi(command, io, fetchFn = fetch) { return fetchFn(url, { method: operation.method, headers, - body: reads ? void 0 : JSON.stringify(input), + body: reads ? void 0 : JSON.stringify(payload), redirect: "error", signal: AbortSignal.timeout(12e4) }); @@ -1108,12 +1115,18 @@ object on stderr: {"error":{"code","message",...}}. Exit codes: 0 ok, 1 error, 2 form wait timed out, 3 sign-in needed, 64 usage error. `; const pad = (text, width) => text.padEnd(width); +function appApiTopic(app) { + return { + ...apiTopic, + usage: `fulldev ${app.name} api [operation] [JSON | -] [--file ] [--org ]` + }; +} function appTopics(app) { const name = (topic) => ({ ...topic, usage: topic.usage.replace("", app.name) }); - const topics = { [`${app.name} api`]: apiTopic }; + const topics = { [`${app.name} api`]: appApiTopic(app) }; for (const [command, topic] of Object.entries(appCommands)) topics[`${app.name} ${command}`] = name(topic); if (app.forms) { const wait = name(formWait); @@ -1187,7 +1200,7 @@ ${footer}`; /** The help text for a topic such as "", "login", "cms" or "cms call". */ function helpText(topic) { if (topic === "") return rootHelp(); - if (topic === "api" || topic.endsWith(" api")) return topicHelp(apiTopic); + if (topic === "api") return topicHelp(apiTopic); const root = rootCommands[topic]; if (root) return topicHelp(root); const app = findApp(topic); diff --git a/src/api.test.ts b/src/api.test.ts index a99f9ed..e07dbdf 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -47,6 +47,54 @@ beforeEach(() => { }) describe("HTTP API command", () => { + it.each(["get", "post"])( + "encodes %s path input separately from query and body", + async (method) => { + const fetchFn = vi.fn(async (url, init) => { + const actual = new URL(new Request(url).url) + if (actual.pathname.endsWith("openapi.json")) + return Response.json({ + paths: { + "/records/{id}": { [method]: { operationId: "records.item" } }, + }, + }) + expect(actual.pathname).toBe("/contacts/api/v1/records/a%2Fb%20%3F%23") + expect(actual.searchParams.has("id")).toBe(false) + if (method === "get") { + expect(actual.searchParams.get("enabled")).toBe("false") + expect(init?.body).toBeUndefined() + } else expect(init?.body).toBe('{"enabled":false}') + return Response.json({ ok: true }) + }) + await runApi( + command([ + "api", + "contacts.records.item", + '{"id":"a/b ?#","enabled":false}', + ]), + io, + fetchFn, + ) + }, + ) + it.each([{}, { id: null }, { id: {} }, { id: ".." }])( + "rejects missing or invalid path input before authenticating: %j", + async (input) => { + const fetchFn = vi.fn(async () => + Response.json({ + paths: { "/records/{id}": { get: { operationId: "records.item" } } }, + }), + ) + await expect( + runApi( + command(["api", "contacts.records.item", JSON.stringify(input)]), + io, + fetchFn, + ), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }) + expect(token).not.toHaveBeenCalled() + }, + ) it("supports both command forms, input files and organization selection", () => { expect( command(["api", "contacts.records.list", "{}", "--org", "first"]), diff --git a/src/api.ts b/src/api.ts index 12608d6..4b82434 100644 --- a/src/api.ts +++ b/src/api.ts @@ -62,7 +62,25 @@ export async function runApi( json: command.json, file: command.file, }) - const url = new URL(`/${app.name}/api/v1${operation.path}`, origin) + const payload = { ...input } + const path = operation.path.replace(/\{([^{}]+)\}/g, (_, name: string) => { + const value = input[name] + if ( + (typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean") || + value === "" || + value === "." || + value === ".." + ) + throw new CliError( + "INVALID_INPUT", + `Provide a scalar value for path parameter: ${name}.`, + ) + delete payload[name] + return encodeURIComponent(String(value)) + }) + const url = new URL(`/${app.name}/api/v1${path}`, origin) if (url.origin !== origin || !url.pathname.startsWith(`/${app.name}/api/v1/`)) throw new CliError( "INVALID_API", @@ -70,7 +88,7 @@ export async function runApi( ) const reads = operation.method === "GET" if (reads) - for (const [key, value] of Object.entries(input)) { + for (const [key, value] of Object.entries(payload)) { if (value === undefined) continue if (Array.isArray(value)) value.forEach((part) => @@ -96,7 +114,7 @@ export async function runApi( return fetchFn(url, { method: operation.method, headers, - body: reads ? undefined : JSON.stringify(input), + body: reads ? undefined : JSON.stringify(payload), redirect: "error", signal: AbortSignal.timeout(120_000), }) diff --git a/src/args.test.ts b/src/args.test.ts index 3ca6e9c..f314480 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -206,6 +206,14 @@ describe("parseCommandLine", () => { }) describe("helpText", () => { + it("shows the app API syntax only for known apps", () => { + expect(helpText("contacts api")).toContain( + "fulldev contacts api [operation]", + ) + expect(helpText("contacts")).toContain("fulldev contacts api [operation]") + expect(helpText("api")).toContain("fulldev api [.]") + expect(helpText("unknown api")).toBeUndefined() + }) it("lists every app, the server and the exit codes at the root", () => { const text = helpText("")! for (const app of apps) expect(text).toContain(app.name) diff --git a/src/args.ts b/src/args.ts index aa91cd4..fd73236 100644 --- a/src/args.ts +++ b/src/args.ts @@ -224,12 +224,21 @@ Exit codes: 0 ok, 1 error, 2 form wait timed out, 3 sign-in needed, 64 usage err const pad = (text: string, width: number) => text.padEnd(width) +function appApiTopic(app: App): Topic { + return { + ...apiTopic, + usage: `fulldev ${app.name} api [operation] [JSON | -] [--file ] [--org ]`, + } +} + function appTopics(app: App): Record { const name = (topic: Topic): Topic => ({ ...topic, usage: topic.usage.replace("", app.name), }) - const topics: Record = { [`${app.name} api`]: apiTopic } + const topics: Record = { + [`${app.name} api`]: appApiTopic(app), + } for (const [command, topic] of Object.entries(appCommands)) topics[`${app.name} ${command}`] = name(topic) if (app.forms) { @@ -315,7 +324,7 @@ ${footer}` /** The help text for a topic such as "", "login", "cms" or "cms call". */ export function helpText(topic: string): string | undefined { if (topic === "") return rootHelp() - if (topic === "api" || topic.endsWith(" api")) return topicHelp(apiTopic) + if (topic === "api") return topicHelp(apiTopic) const root = rootCommands[topic] if (root) return topicHelp(root) const app = findApp(topic)