diff --git a/.changeset/products-and-auth.md b/.changeset/products-and-auth.md new file mode 100644 index 0000000..48fba21 --- /dev/null +++ b/.changeset/products-and-auth.md @@ -0,0 +1,14 @@ +--- +"fulldev": minor +--- + +Work with every Fulldev product, and sign in per product with tokens in the OS keychain. + +Breaking: commands now start with the product. `fulldev tools`, `fulldev call`, `fulldev instructions` and `fulldev form wait` are now `fulldev cms tools`, `fulldev cms call`, `fulldev cms instructions` and `fulldev cms form wait`, and the same commands work for `connect` and `scan`. `FULLDEV_URL` is replaced by `FULLDEV_CMS_URL`, `FULLDEV_CONNECT_URL` and `FULLDEV_SCAN_URL`. The old commands are gone. + +- `fulldev login [product...]`, `fulldev logout [product...]` and `fulldev status` work per product, for all products by default. +- Sign-in uses Fulldev's pre-registered OAuth client, and each token is only valid for its product. Logout revokes the refresh token. +- Tokens are stored in the OS keychain, with a private file only where no keychain is available. The sign-in from 0.1.0 is removed; run `fulldev login cms` once. +- Without a terminal, commands never start a sign-in: they exit with 3 and name the login to run. +- Every error, including a wrong command, is one JSON object on stderr. A wrong command exits with 64. `--help` works for every command, and `fulldev tools ` also shows the output schema and annotations. +- `fulldev cms form wait` stops at its timeout also in the middle of a request. diff --git a/README.md b/README.md index 67f5e9b..a57222a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,27 @@ # Fulldev CLI -Edit your website through the [Fulldev CMS](https://cms.full.dev) from a terminal or an AI agent. The CLI is a thin client of the CMS's MCP server: it reads the tools, their input schemas, and the instructions from the server each time, so it stays in step with the CMS. +Use Fulldev products from a terminal or an AI agent. The CLI is a thin client of each product's MCP server: it reads the tools, their schemas, and the instructions from the server each time, so it stays in step with the product. + +| Product | Server | For | +| --------- | ------------------------------ | ----------------------------------------------------------------------------------------- | +| `cms` | `https://cms.full.dev/mcp` | Editing your website through the [Fulldev CMS](https://cms.full.dev) | +| `connect` | `https://connect.full.dev/mcp` | Your business tools, such as Shopify, through [Fulldev Connect](https://connect.full.dev) | +| `scan` | `https://scan.full.dev/mcp` | Scanning websites with [Fulldev Scan](https://scan.full.dev), for administrators only | ## Install Run it from npm. It needs Node.js 24 or later. ```sh -npx -y fulldev help -pnpm dlx fulldev help +npx -y fulldev --help +pnpm dlx fulldev --help ``` Or install it once: ```sh npm install -g fulldev -fulldev help +fulldev --help ``` Add the agent skill, which tells an agent when and how to use the CLI: @@ -27,59 +33,95 @@ npx skills add fulldotdev/cli ## Sign in ```sh -fulldev login +fulldev login # every product, one after the other +fulldev login cms # one product +fulldev status # per product: signed in, valid, email, organization, expiry +fulldev logout # every product +fulldev logout cms # one product ``` -This opens your browser to sign in with your Fulldev account and choose your organization. Any other command starts a sign-in too when you are not signed in; add `--no-login` to fail with exit code 3 instead, and `--no-browser` to only print the link. +`fulldev login` opens your browser to sign in with your Fulldev account and choose your organization. Add `--no-browser` to only print the link. -```sh -fulldev status # who is signed in, and whether the sign-in still works -fulldev logout # forget the sign-in for this server -``` +There is one Fulldev account for all products, but each product gets its own tokens, which only that product accepts. The browser remembers your account, so signing in to the next product is quick. The organization is chosen at sign-in; to switch organization, run `fulldev login ` again. + +`fulldev logout` revokes the product's refresh token at the authorization server (access tokens cannot be revoked and expire within a day) and deletes them from this computer. When revoking fails, for example offline, it still deletes them and says so. + +How it works: -The sign-in uses OAuth with PKCE and a redirect to `127.0.0.1`. The CLI registers itself with the authorization server the first time. Tokens are saved in `~/.config/fulldev/credentials.json` (or `$XDG_CONFIG_HOME/fulldev/credentials.json`), readable only by you, per server, and refreshed when they expire. +- Browser sign-in uses OAuth with PKCE (S256) and a redirect to `http://127.0.0.1:/callback`. It asks for the product's MCP URL as the resource, so the token is only valid for that product. +- The CLI is a pre-registered public OAuth client of [Clerk](https://clerk.full.dev). For another authorization server, such as the Clerk development instance behind a deploy preview, it registers a client itself the first time. +- Tokens are stored in the OS keychain (macOS Keychain, Windows Credential Manager, or the Secret Service on Linux) under the service `fulldev`, one entry per product server. Where no keychain is available, such as in many containers and CI runners, they go to `~/.config/fulldev/auth.json` (or `$XDG_CONFIG_HOME/fulldev/auth.json`), readable only by you; `fulldev status` says which one is used. +- Access tokens are refreshed when they expire. Parallel commands take turns through a lock file next to it, so they never refresh the same token twice. +- Version 0.1.0 kept a sign-in for the CMS in `~/.config/fulldev/credentials.json`. The first command of this version removes that file and asks you to sign in again with `fulldev login cms`. + +Product commands never start a sign-in without a terminal (when stdin or stdout is not a TTY): they fail at once with exit code 3 and say which `fulldev login ` to run. At a terminal they open the browser sign-in themselves; `--no-login` turns that off. ## Use ```sh -fulldev instructions # how to use the tools, from the server -fulldev tools # name, title, and description of each tool -fulldev tools commit_files # one tool's description and input schema -fulldev call list_projects -fulldev call read_file '{"branchId":"","path":"src/content/home.md"}' -fulldev call commit_files --file changes.json -echo '{"branchId":""}' | fulldev call get_preview - -fulldev form wait --timeout 60 +fulldev cms instructions # how to use the tools, from the server; read first +fulldev cms tools # name, title and description of each tool +fulldev cms tools commit_files # description, input schema, output schema, annotations +fulldev cms call list_projects +fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' +fulldev cms call commit_files --file changes.json +echo '{"branchId":""}' | fulldev cms call get_preview - +fulldev cms form wait --timeout 60 +fulldev connect tools +fulldev help cms call # help for any command ``` -`call` prints the tool's result as JSON on stdout. When the tool fails, it prints the error as JSON on stderr and exits with 1. +`call` prints the tool's result as JSON on stdout. When the tool fails, it prints the error on stderr and exits with 1. + +`fulldev cms form wait` keeps calling `wait_for_form` until the person sends the form, with a progress line on stderr each round, then prints the result. After `--timeout` minutes (default 30) it stops, also in the middle of a request, prints the form id, and exits with 2, so you can run it again. + +## Output and exit codes -`form wait` keeps calling `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. +Data is JSON on stdout. `instructions` and help are plain text. Progress lines go to stderr. Every error, including a wrong command, is one JSON object on stderr: + +```json +{ + "error": { + "code": "SIGN_IN_REQUIRED", + "message": "Not signed in to Fulldev CMS. Run: fulldev login cms", + "command": "fulldev login cms" + } +} +``` -Exit codes: 0 done, 1 error, 2 form wait timed out, 3 sign-in needed. +| Code | Meaning | +| ---- | ------------------------------- | +| 0 | Done | +| 1 | Error, from the tool or the CLI | +| 2 | `form wait` timed out | +| 3 | Sign-in needed | +| 64 | Usage error | -## Other servers +## Deploy previews -`--url ` or `FULLDEV_URL` selects another server, such as a deploy preview of the CMS. The default is `https://cms.full.dev/mcp`. Each server has its own sign-in. +`--url ` or `FULLDEV__URL` (such as `FULLDEV_CMS_URL` or `FULLDEV_CONNECT_URL`) selects another server for one product. Each server has its own sign-in. ```sh -FULLDEV_URL=https://deploy-preview-12--fulldev-cms.netlify.app/mcp fulldev login +FULLDEV_CMS_URL=https://deploy-preview-12--fulldev-cms.netlify.app/mcp fulldev login cms +fulldev cms tools --url https://deploy-preview-12--fulldev-cms.netlify.app/mcp ``` ## Develop ```sh pnpm install -pnpm check # format, lint, type check, and tests +pnpm check # format, lint, type check, tests, and that dist/ is up to date pnpm build # writes dist/cli.mjs ``` -`dist/` is committed, because `npx github:fulldotdev/cli` runs it without a build step. Run `pnpm build` and commit `dist/` with every source change. - -## License +A product is one entry in `src/products.ts`. The pre-registered OAuth client ids are in `clientIds` in `src/oauth.ts`. -[MIT](LICENSE) +`dist/` is committed, because `npx github:fulldotdev/cli` runs it without a build step. Run `pnpm build` and commit `dist/` with every source change. ## Releases Every pull request that changes what users get adds a changeset with `pnpm changeset`. Merging to `main` opens a release pull request; merging that publishes the [`fulldev` package](https://www.npmjs.com/package/fulldev) to npm from GitHub Actions with trusted publishing and provenance, without an npm token. `dist/` stays committed, so `npx github:fulldotdev/cli` keeps working; `pnpm check` fails when it is out of date. + +## License + +[MIT](LICENSE) diff --git a/dist/cli.mjs b/dist/cli.mjs index 1dbfbe1..35f9363 100755 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -1,67 +1,571 @@ #!/usr/bin/env node -import { Client, ProtocolError, SdkError, StreamableHTTPClientTransport, UnauthorizedError, auth } from "@modelcontextprotocol/client"; -import { parseArgs } from "node:util"; -import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; 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 { text } from "node:stream/consumers"; +//#region src/errors.ts +const exitCodes = { + ok: 0, + error: 1, + timeout: 2, + signIn: 3, + usage: 64 +}; +/** An error the CLI reports as {"error":{"code","message",...}} with an exit code. */ +var CliError = class extends Error { + code; + exitCode; + details; + constructor(code, message, exitCode = exitCodes.error, details = {}) { + super(message); + this.code = code; + this.exitCode = exitCode; + this.details = details; + this.name = "CliError"; + } +}; +/** A mistake in how the CLI was called. */ +var UsageError = class extends CliError { + constructor(message, help = "fulldev --help") { + super("USAGE", message, exitCodes.usage, { help }); + this.name = "UsageError"; + } +}; +/** A command needs a sign-in that it may not start itself. */ +var SignInRequiredError = class extends CliError { + target; + constructor(target) { + const command = `fulldev login ${target.name}${target.urlFromFlag ? ` --url ${target.url}` : ""}`; + super("SIGN_IN_REQUIRED", `Not signed in to ${target.title}. Run: ${command}`, exitCodes.signIn, { + product: target.name, + server: target.url, + command + }); + this.target = target; + this.name = "SignInRequiredError"; + } +}; +function isSignInRequired(error) { + return findCause(error, SignInRequiredError) !== void 0; +} +/** The first error in the cause chain that is an instance of `type`. */ +function findCause(error, type) { + for (let cause = error; cause instanceof Error; cause = cause.cause) if (cause instanceof type) return cause; +} +/** Any error as the one JSON object the CLI prints on stderr, with its exit code. */ +function describeError(error) { + const cli = findCause(error, CliError); + if (cli) return { + body: { error: { + code: cli.code, + message: cli.message, + ...cli.details + } }, + exitCode: cli.exitCode + }; + const message = error instanceof Error ? error.message : String(error); + if (error instanceof OAuthError) return { + body: { error: { + code: "OAUTH_ERROR", + message, + oauthError: error.code + } }, + exitCode: exitCodes.error + }; + if (error instanceof ProtocolError) return { + body: { error: { + code: "PROTOCOL_ERROR", + message, + protocolCode: error.code + } }, + exitCode: exitCodes.error + }; + if (error instanceof TypeError && /fetch failed/i.test(message)) return { + body: { error: { + code: "NETWORK_ERROR", + message: `${message}${error.cause instanceof Error ? `: ${error.cause.message}` : ""}` + } }, + exitCode: exitCodes.error + }; + return { + body: { error: { + code: "ERROR", + message + } }, + exitCode: exitCodes.error + }; +} +//#endregion +//#region src/credentials.ts +function withoutStamp(stored) { + const credentials = { ...stored }; + delete credentials.writtenAt; + return credentials; +} +const keychainService = "fulldev"; +/** The OS keychain through @napi-rs/keyring; rejects when it cannot load. */ +async function openKeychain() { + const { AsyncEntry } = await import("@napi-rs/keyring"); + const entry = (account) => new AsyncEntry(keychainService, account); + return { + get: async (account) => await entry(account).getPassword() ?? void 0, + set: (account, secret) => entry(account).setPassword(secret), + delete: async (account) => { + await entry(account).deleteCredential(); + } + }; +} +function configDirectory(env = process.env) { + return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "fulldev"); +} +const held = new AsyncLocalStorage(); +function processAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === "EPERM"; + } +} +/** +* Runs `action` while holding a lock file, so parallel fulldev processes take +* turns updating credentials, for example when two refresh the same token. +* Nested calls in the same action reuse the lock. A lock left by a process +* that ended, or older than `staleMs`, is taken over. +*/ +async function withLock(path, action, { timeoutMs = 6e4, staleMs = 12e4 } = {}) { + if (held.getStore()) return action(); + await mkdir(dirname(path), { + recursive: true, + mode: 448 + }); + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + const handle = await open(path, "wx", 384); + await handle.writeFile(String(process.pid)); + await handle.close(); + break; + } catch (error) { + if (error.code !== "EEXIST") throw error; + } + const [owner, info] = await Promise.all([readFile(path, "utf8").catch(() => ""), stat(path).catch(() => void 0)]); + const pid = Number(owner); + if (Number.isInteger(pid) && pid > 0 && !processAlive(pid) || info !== void 0 && Date.now() - info.mtimeMs > staleMs) { + await rm(path, { force: true }); + continue; + } + if (Date.now() > deadline) throw new Error(`Another fulldev process holds ${path}. Try again, or delete the file if no fulldev command is running.`); + await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 50)); + } + try { + return await held.run(true, action); + } finally { + await rm(path, { force: true }); + } +} +const isEmpty = (credentials) => Object.keys(credentials).length === 0; +/** +* Keeps credentials per product server in the OS keychain (service "fulldev", +* account = the MCP URL). When the keychain is unavailable or rejects a write, +* it uses a JSON file readable only by this user instead. +*/ +var CredentialStore = class { + directory; + loadKeychain; + filePath; + lockPath; + /** The 0.1.0 file, removed once by migrate(). */ + legacyPath; + /** Why the keychain is not used, once it failed. */ + keychainError; + keychain; + constructor(directory = configDirectory(), loadKeychain = openKeychain) { + this.directory = directory; + this.loadKeychain = loadKeychain; + this.filePath = join(directory, "auth.json"); + this.lockPath = join(directory, "auth.lock"); + this.legacyPath = join(directory, "credentials.json"); + } + /** Where new credentials go. */ + async storage() { + return await this.openKeychain() ? "keychain" : "file"; + } + openKeychain() { + this.keychain ??= this.loadKeychain().catch((error) => { + this.keychainError = error.message; + }); + return this.keychain; + } + disableKeychain(error) { + this.keychainError = error.message; + this.keychain = Promise.resolve(void 0); + } + locked(action) { + return withLock(this.lockPath, action); + } + /** The server's credentials and where they were found. */ + async read(serverUrl) { + let fromKeychain; + const keychain = await this.openKeychain(); + if (keychain) try { + const secret = await keychain.get(serverUrl); + if (secret) fromKeychain = JSON.parse(secret); + } catch (error) { + this.disableKeychain(error); + } + const fromFile = (await this.readFile()).servers[serverUrl]; + if (fromKeychain && (!fromFile || (fromKeychain.writtenAt ?? 0) >= (fromFile.writtenAt ?? 0))) return { + credentials: withoutStamp(fromKeychain), + storage: "keychain" + }; + return fromFile ? { + credentials: withoutStamp(fromFile), + storage: "file" + } : { credentials: {} }; + } + async get(serverUrl) { + return (await this.read(serverUrl)).credentials; + } + /** Changes one server's credentials while holding the lock. */ + update(serverUrl, change) { + return this.locked(async () => { + const next = change(await this.get(serverUrl)); + if (isEmpty(next)) await this.delete(serverUrl); + else await this.write(serverUrl, next); + }); + } + async write(serverUrl, record) { + const credentials = { + ...record, + writtenAt: Date.now() + }; + const keychain = await this.openKeychain(); + if (keychain) try { + await keychain.set(serverUrl, JSON.stringify(credentials)); + await this.changeFile((servers) => { + delete servers[serverUrl]; + }); + return; + } catch (error) { + this.disableKeychain(error); + await keychain.delete(serverUrl).catch(() => {}); + } + await this.changeFile((servers) => { + servers[serverUrl] = credentials; + }); + } + async delete(serverUrl) { + await this.changeFile((servers) => { + delete servers[serverUrl]; + }); + const keychain = await this.openKeychain(); + if (!keychain) return; + try { + await keychain.delete(serverUrl); + } catch (error) { + if (await keychain.get(serverUrl).catch(() => void 0)) throw new CliError("KEYCHAIN_ERROR", `The sign-in could not be removed from the OS keychain: ${error.message}. Remove the entry for ${serverUrl} under the service fulldev, or run this command in a terminal on this computer.`); + this.disableKeychain(error); + } + } + async readFile() { + try { + const data = JSON.parse(await readFile(this.filePath, "utf8")); + if (data.servers && typeof data.servers === "object") return { + version: 2, + servers: data.servers + }; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + return { + version: 2, + servers: {} + }; + } + /** Rewrites the file readable only by this user, or deletes it when empty. */ + async changeFile(change) { + const data = await this.readFile(); + const before = JSON.stringify(data); + change(data.servers); + if (JSON.stringify(data) === before) return; + if (Object.keys(data.servers).length === 0) { + await rm(this.filePath, { force: true }); + return; + } + await mkdir(this.directory, { + recursive: true, + mode: 448 + }); + const temporary = `${this.filePath}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(data, null, 2)}\n`, { mode: 384 }); + await chmod(temporary, 384); + await rename(temporary, this.filePath); + } + /** + * Removes the credentials file of fulldev 0.1.0 once. Its tokens belong to + * a dynamically registered client that the CLI no longer uses, so they are + * dropped. Returns the servers that were signed in with their credentials, + * so their tokens can be revoked and a new sign-in asked for. + */ + async migrate() { + if (!await stat(this.legacyPath).catch(() => void 0)) return []; + return this.locked(async () => { + let data; + try { + data = JSON.parse(await readFile(this.legacyPath, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return []; + if (!(error instanceof SyntaxError)) throw error; + } + const servers = data && typeof data === "object" && "servers" in data ? data.servers : {}; + await rm(this.legacyPath, { force: true }); + return Object.entries(servers).filter(([, credentials]) => credentials.tokens).map(([url, credentials]) => ({ + url, + credentials + })); + }); + } +}; +//#endregion //#region package.json var version = "0.1.0"; //#endregion +//#region src/products.ts +const products = [ + { + name: "cms", + title: "Fulldev CMS", + url: "https://cms.full.dev/mcp", + description: "Edit your website: text, pages, images and settings, through a pull request.", + forms: true + }, + { + name: "connect", + title: "Fulldev Connect", + url: "https://connect.full.dev/mcp", + description: "Use your organization's business tools, such as Shopify, with the access Fulldev grants you." + }, + { + name: "scan", + title: "Fulldev Scan", + url: "https://scan.full.dev/mcp", + description: "Scan whole websites for problems. For administrators only." + } +]; +function findProduct(name) { + return products.find((product) => product.name === name); +} +/** The environment variable that overrides a product's URL, such as FULLDEV_CMS_URL. */ +function urlVariable(product) { + return `FULLDEV_${product.name.toUpperCase().replaceAll(/[^A-Z0-9]/g, "_")}_URL`; +} +/** The product's server: --url, then FULLDEV__URL, then the default. */ +function resolveTarget(product, flagUrl, env) { + const url = flagUrl ?? env[urlVariable(product)] ?? product.url; + let parsed; + try { + parsed = new URL(url); + } catch { + throw new UsageError(`Not a valid server URL for ${product.name}: ${url}`); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new UsageError(`The server URL for ${product.name} must use https: ${url}`); + return { + ...product, + url: parsed.href, + urlFromFlag: flagUrl !== void 0 + }; +} +//#endregion //#region src/args.ts -const defaultUrl = "https://cms.full.dev/mcp"; -/** A mistake in how the CLI was called. */ -var UsageError = class extends Error { - constructor(message) { - super(message); - this.name = "UsageError"; +const rootCommands = { + login: { + usage: "fulldev login [product...] [--no-browser] [--url ]", + summary: "Sign in (all products by default)", + options: ["browser", "url"], + details: `Signs in to each product in turn in your browser, where you choose your +organization. Each product gets its own tokens; the browser session is shared, +so after the first product the others are quick. Run fulldev login +again to switch that product to another organization.` + }, + logout: { + usage: "fulldev logout [product...] [--url ]", + summary: "Sign out and revoke the tokens (all products by default)", + options: ["url"], + details: `Revokes the product's refresh token at the authorization server and deletes +the tokens from this computer. The access token cannot be revoked and expires +within a day. When revoking fails, the local sign-out still happens and the +result says so.` + }, + status: { + usage: "fulldev status [product...] [--url ]", + summary: "Show the sign-in of each product", + options: ["url"], + details: `Prints, per product, whether you are signed in, whether the sign-in still +works, your email, organization and when the access token expires, and where +the tokens are stored. Exits with 3 when none of the listed products can be +used, and with 1 when a server could not be reached to check it. Read each +product's signedIn and valid to see which one needs fulldev login.` + }, + help: { + usage: "fulldev help [command...]", + summary: "Show help for a command" } }; -const expected = { - login: [ - 0, - 0, - "fulldev login" - ], - logout: [ - 0, - 0, - "fulldev logout" - ], - status: [ - 0, - 0, - "fulldev status" - ], - instructions: [ - 0, - 0, - "fulldev instructions" - ], - tools: [ - 0, - 1, - "fulldev tools [name]" - ], - call: [ - 1, - 2, - "fulldev call [json | -] [--file ]" - ], - form: [ - 3, - 3, - "fulldev form wait [--timeout ]" +const productCommands = { + instructions: { + usage: "fulldev instructions", + summary: "The product's instructions for agents; read them first", + options: [ + "url", + "login", + "browser" + ], + details: "Prints the instructions the server sends, as plain text." + }, + tools: { + usage: "fulldev tools [name]", + summary: "List the tools, 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.` + }, + call: { + usage: "fulldev call [json | -] [--file ]", + summary: "Call a tool with a JSON object", + options: [ + "file", + "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.` + } +}; +const formWait = { + usage: "fulldev form wait [--timeout ]", + summary: "Wait until the person sends a form", + options: [ + "timeout", + "url", + "login", + "browser" ], - help: [ - 0, - 0, - "fulldev help" - ] + details: `Calls 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 for the product, such as a deploy preview", + 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 without opening a browser" }; +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 productTopics(product) { + const name = (topic) => ({ + ...topic, + usage: topic.usage.replace("", product.name) + }); + const topics = {}; + for (const [command, topic] of Object.entries(productCommands)) topics[`${product.name} ${command}`] = name(topic); + if (product.forms) { + const wait = name(formWait); + topics[`${product.name} form`] = wait; + topics[`${product.name} form wait`] = wait; + } + return topics; +} +function rootHelp() { + const width = Math.max(...products.map((product) => product.name.length)) + 4; + return `Fulldev CLI: use Fulldev products from a terminal or an AI agent. + +Usage: + fulldev [options] +${Object.values(rootCommands).map((topic) => ` ${pad(topic.usage.split(" [--")[0], 32)}${topic.summary}`).join("\n")} + +Products: +${products.map((product) => ` ${pad(product.name, width)}${product.description}`).join("\n")} + +Product commands: +${[...Object.values(productCommands), { + ...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. + +Options: + -h, --help Show help + -v, --version Show the version + +${footer}`; +} +function productHelp(product) { + const topics = Object.values(productTopics(product)).filter((topic, index, all) => all.indexOf(topic) === index); + return `${product.title} + +${product.description} + +Server: ${product.url} (${urlVariable(product)} or --url overrides it) + +Usage: +${topics.map((topic) => ` ${topic.usage}\n ${topic.summary}`).join("\n")} + +Run fulldev ${product.name} instructions first and follow them. +Sign in with fulldev login ${product.name}. + +${footer}`; +} +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 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 product = findProduct(topic); + if (product) return productHelp(product); + const [name] = topic.split(" "); + const owner = findProduct(name ?? ""); + const found = owner ? productTopics(owner)[topic] : void 0; + return found ? topicHelp(found) : void 0; +} +/** 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 ""; +} +const helpFor = (topic) => topic ? `fulldev ${topic} --help` : "fulldev --help"; function parseCommandLine(argv, env = process.env) { let parsed; try { @@ -77,144 +581,337 @@ function parseCommandLine(argv, env = process.env) { short: "f" }, timeout: { type: "string" }, - login: { - type: "boolean", - default: true - }, - browser: { - type: "boolean", - default: true - }, + login: { type: "boolean" }, + browser: { type: "boolean" }, help: { type: "boolean", - short: "h", - default: false + short: "h" }, version: { type: "boolean", - short: "v", - default: false + short: "v" } } }); } catch (error) { - throw new UsageError(error.message); + throw new UsageError(error.message, helpFor(closestTopic(argv.filter((word) => !word.startsWith("-"))))); } const { values, positionals } = parsed; - const [command, ...args] = positionals; - const url = values.url ?? env.FULLDEV_URL ?? "https://cms.full.dev/mcp"; - let parsedUrl; - try { - parsedUrl = new URL(url); - } catch { - throw new UsageError(`Not a valid server URL: ${url}`); - } - if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") throw new UsageError(`The server URL must use https: ${url}`); - 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.`); - const result = { - command, - args, - url: parsedUrl.href, - file: values.file, - timeoutMinutes, - login: values.login, - browser: values.browser, - help: values.help, - version: values.version + 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 (!command || result.help || result.version) return result; - const rule = expected[command]; - if (!rule) throw new UsageError(`Unknown command: ${command}`); - const [min, max, usage] = rule; - if (command === "form" && args[0] !== "wait") throw new UsageError(`Usage: ${usage}`); - if (args.length < min || args.length > max) throw new UsageError(`Usage: ${usage}`); - if (values.file !== void 0 && command !== "call") throw new UsageError("--file only works with fulldev call."); - if (values.timeout !== void 0 && command !== "form") throw new UsageError("--timeout only works with fulldev form wait."); - return result; -} -const help = `Fulldev CLI: edit your website through the Fulldev CMS. - -Usage: - fulldev login Sign in (opens your browser) - fulldev logout Forget the saved sign-in for this server - fulldev status Show who is signed in and whether it works - fulldev instructions Print the server's instructions for agents - fulldev tools [name] List tools, or show one tool's input schema - fulldev call [json | -] Call a tool with a JSON object (- reads stdin) - fulldev form wait - Wait until the person sends a form - -Options: - --url Server to use (default ${defaultUrl}, or FULLDEV_URL) - -f, --file Read the tool's JSON input from a file (call) - --timeout How long form wait waits (default 30) - --no-login Fail instead of opening a sign-in when not signed in - --no-browser Print the sign-in link without opening a browser - -h, --help Show this help - -v, --version Show the version - -Results are JSON on stdout; hints and errors go to stderr. -Exit codes: 0 done, 1 error, 2 form wait timed out, 3 sign-in needed. -`; + 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)); + }; + if (first === "login" || first === "logout" || first === "status") { + allow(first, rootCommands[first].options); + const named = [...new Set(rest)].map((name) => { + const product = findProduct(name); + if (!product) throw new UsageError(`Unknown product: ${name}`, helpFor(first)); + return product; + }); + if (values.url !== void 0 && named.length !== 1) throw new UsageError(`--url needs exactly one product, for example fulldev ${first} cms --url .`, helpFor(first)); + const targets = (named.length ? named : products).map((product) => resolveTarget(product, values.url, env)); + if (first === "login") return { + kind: "login", + targets, + browser: values.browser ?? true + }; + return { + kind: first, + targets + }; + } + const product = findProduct(first); + if (!product) throw new UsageError(`Unknown command or product: ${first}`); + const [command, ...args] = rest; + if (command === void 0) { + allow(product.name); + return { + kind: "help", + topic: product.name + }; + } + const topic = `${product.name} ${command}`; + const known = productTopics(product)[topic]; + if (!known) throw new UsageError(`Unknown command: fulldev ${topic}`, helpFor(product.name)); + allow(topic, known.options); + const usage = (count) => { + if (!count) throw new UsageError(`Usage: ${known.usage}`, helpFor(topic)); + }; + const target = resolveTarget(product, values.url, env); + const session = { + login: values.login ?? true, + browser: values.browser ?? true + }; + switch (command) { + case "instructions": + usage(args.length === 0); + return { + kind: "instructions", + target, + session + }; + case "tools": + usage(args.length <= 1); + return { + kind: "tools", + target, + session, + name: args[0] + }; + case "call": + usage(args.length === 1 || args.length === 2); + return { + kind: "call", + target, + session, + tool: args[0], + json: args[1], + file: values.file + }; + default: { + usage(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, + branchId: args[1], + formId: args[2], + timeoutMinutes + }; + } + } +} //#endregion -//#region src/credentials.ts -function credentialsPath(env = process.env) { - const base = env.XDG_CONFIG_HOME || join(homedir(), ".config"); - return join(base, "fulldev", "credentials.json"); +//#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; } -async function readAll(path) { - try { - const data = JSON.parse(await readFile(path, "utf8")); - if (data && typeof data === "object" && "servers" in data && data.servers && typeof data.servers === "object") return data; - } catch (error) { - if (error.code !== "ENOENT") throw error; +/** 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 { servers: {} }; + return { error: { + code: "TOOL_ERROR", + message: typeof output === "string" ? output : JSON.stringify(output) + } }; } -/** Writes the whole file readable only by this user, replacing it atomically. */ -async function writeAll(path, data) { - await mkdir(dirname(path), { - recursive: true, - mode: 448 +//#endregion +//#region src/form-wait.ts +/** The one tool the CLI names itself. */ +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(); + } }); - const temporary = `${path}.${process.pid}.tmp`; - await writeFile(temporary, `${JSON.stringify(data, null, 2)}\n`, { mode: 384 }); - await chmod(temporary, 384); - await rename(temporary, path); } -/** Reads and writes the credentials of one server, keyed by its MCP URL. */ -var CredentialStore = class { - serverUrl; - path; - constructor(serverUrl, path = credentialsPath()) { - this.serverUrl = serverUrl; - this.path = path; - } - async get() { - return (await readAll(this.path)).servers[this.serverUrl] ?? {}; - } - async update(change) { - const data = await readAll(this.path); - const next = change(data.servers[this.serverUrl] ?? {}); - if (Object.keys(next).length) data.servers[this.serverUrl] = next; - else delete data.servers[this.serverUrl]; - if (Object.keys(data.servers).length) await writeAll(this.path, data); - else await rm(this.path, { force: true }); +/** +* 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, product = "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(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); } -}; + return { + status: "timeout", + result: { + waiting: true, + branchId, + formId, + message: `Stopped waiting after ${minutes(timeoutMs)}. Run fulldev ${product} form wait ${branchId} ${formId} to keep waiting, or continue when the person says they are done.` + } + }; +} +//#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; + try { + value = JSON.parse(source); + } catch (error) { + throw new UsageError(`The tool input from ${from} is not valid JSON: ${error.message}`); + } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`); + return value; +} //#endregion //#region src/oauth.ts -/** Thrown when a command needs a sign-in that it may not start itself. */ -var LoginRequiredError = class extends Error { - constructor(serverUrl) { - super(`Not signed in to ${serverUrl}. Run: fulldev login`); - this.name = "LoginRequiredError"; +/** +* The Fulldev CLI's pre-registered public OAuth client per authorization +* server issuer. An issuer that is not listed, such as the Clerk development +* instance behind a deploy preview, gets a dynamically registered client. +*/ +const clientIds = { "https://clerk.full.dev": "3Gjxf97mGc2QVnTv" }; +const trimSlash = (url) => url.replace(/\/+$/, ""); +/** The pre-registered client for an issuer, if there is one. */ +function fixedClient(issuer) { + const clientId = issuer ? clientIds[trimSlash(issuer)] : void 0; + return clientId ? { + client_id: clientId, + issuer + } : void 0; +} +/** The client to use with an issuer: the fixed one, else the registered one. */ +function clientFor(issuer, credentials) { + return fixedClient(issuer) ?? credentials.client; +} +/** 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 {}; } -}; -function isLoginRequired(error) { - for (let cause = error; cause instanceof Error; cause = cause.cause) if (cause instanceof LoginRequiredError) return true; - return false; +} +/** 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; +} +/** Saves tokens for a target, keeping only the email from the ID token. */ +async function saveTokens(store, target, tokens, issuer) { + const { id_token: idToken, ...kept } = tokens; + const { email } = claims(idToken); + await store.update(target.url, (current) => ({ + ...current, + issuer, + tokens: kept, + email: typeof email === "string" ? email : current.email, + savedAt: Date.now() + })); +} +/** 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 { + authorizationServerUrl: info.authorizationServerUrl, + metadata, + issuer: metadata.issuer, + resource, + resourceMetadata: info.resourceMetadata + }; } const callbackPath = "/callback"; /** The redirect URIs a dynamically registered client was registered with. */ @@ -222,21 +919,23 @@ function redirectUris(client) { return client && "redirect_uris" in client ? client.redirect_uris : []; } /** -* Keeps the OAuth client and tokens in the credentials file. Without a login -* context it only uses and refreshes what is stored: it never registers a -* client or starts a sign-in, so a normal command cannot hang on a browser. +* The SDK's view of one browser sign-in: the fixed client, or a registered +* one for an unknown issuer, with PKCE S256 and a loopback redirect. */ -var CliOAuthProvider = class { +var BrowserLoginProvider = class { store; - login; + target; + redirectUrl; + loginState; + onAuthorizationUrl; verifier = ""; discovery; - constructor(store, login) { + constructor(store, target, redirectUrl, loginState, onAuthorizationUrl) { this.store = store; - this.login = login; - } - get redirectUrl() { - return this.login?.redirectUrl ?? `http://127.0.0.1${callbackPath}`; + this.target = target; + this.redirectUrl = redirectUrl; + this.loginState = loginState; + this.onAuthorizationUrl = onAuthorizationUrl; } get clientMetadata() { return { @@ -249,35 +948,29 @@ var CliOAuthProvider = class { }; } state() { - return this.login?.state ?? randomBytes(16).toString("base64url"); + return this.loginState; } - async clientInformation() { - const { client } = await this.store.get(); - if (!this.login) { - if (!client) throw new LoginRequiredError(this.store.serverUrl); - return client; - } - return redirectUris(client).includes(this.login.redirectUrl) ? client : void 0; + async clientInformation(ctx) { + const fixed = fixedClient(ctx?.issuer); + if (fixed) return fixed; + const { client } = await this.store.get(this.target.url); + return redirectUris(client).includes(this.redirectUrl) ? client : void 0; } - async saveClientInformation(client) { - await this.store.update((current) => ({ + async saveClientInformation(client, ctx) { + if (fixedClient(ctx?.issuer ?? client.issuer)) return; + await this.store.update(this.target.url, (current) => ({ ...current, client })); } - async tokens() { - return (await this.store.get()).tokens; - } - async saveTokens(tokens) { - await this.store.update((current) => ({ - ...current, - tokens, - savedAt: Date.now() - })); + /** 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 ?? ""); } - async redirectToAuthorization(authorizationUrl) { - if (!this.login) throw new LoginRequiredError(this.store.serverUrl); - await this.login.onAuthorizationUrl(authorizationUrl); + redirectToAuthorization(authorizationUrl) { + this.onAuthorizationUrl(authorizationUrl); } saveCodeVerifier(verifier) { this.verifier = verifier; @@ -294,14 +987,11 @@ var CliOAuthProvider = class { async invalidateCredentials(scope) { if (scope === "verifier") this.verifier = ""; if (scope === "discovery" || scope === "all") this.discovery = void 0; - if (scope === "all" || scope === "client" || scope === "tokens") await this.store.update(({ client, tokens }) => ({ - ...scope === "tokens" && client ? { client } : {}, - ...scope === "client" && tokens ? { tokens } : {} - })); + if (scope === "client" || scope === "all") await this.store.update(this.target.url, (current) => without(current, "client")); } }; const page = (message) => `Fulldev CLI

${message}

`; -/** Listens on 127.0.0.1 for the authorization server's redirect. */ +/** Listens on 127.0.0.1 for the authorization server's redirect (RFC 8252). */ async function listenForCallback(preferredPort) { let receive = () => {}; const received = new Promise((resolve) => { @@ -314,8 +1004,7 @@ async function listenForCallback(preferredPort) { 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); + 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)); }); const listen = (port) => new Promise((resolve, reject) => { server.once("error", reject); @@ -335,7 +1024,7 @@ async function listenForCallback(preferredPort) { wait: async (timeoutMs) => { let timer; const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Sign-in timed out after ${Math.round(timeoutMs / 6e4)} minutes.`)), timeoutMs); + timer = setTimeout(() => reject(new CliError("SIGN_IN_TIMEOUT", `Sign-in timed out after ${Math.round(timeoutMs / 6e4)} minutes.`)), timeoutMs); }); try { return await Promise.race([received, timeout]); @@ -367,187 +1056,204 @@ function openBrowser(url) { } catch {} } /** -* Signs in with the authorization code flow and PKCE: discovery from the MCP -* URL, dynamic client registration when needed, the system browser and a -* loopback redirect. The registered client and its port are reused. +* Signs in with the authorization code flow and PKCE S256, the system +* browser and a loopback redirect on an ephemeral port, asking for the +* product's MCP URL as the resource. */ -async function login(serverUrl, store, { browser, log, timeoutMs = 3e5 }) { - const { client } = await store.get(); - const registered = redirectUris(client)[0]; +async function browserLogin(target, store, { browser, log, timeoutMs = 3e5, fetchFn }) { + const registered = redirectUris((await store.get(target.url)).client)[0]; const callback = await listenForCallback(registered ? Number(new URL(registered).port) || void 0 : void 0); try { const state = randomBytes(16).toString("base64url"); - const provider = new CliOAuthProvider(store, { - redirectUrl: `http://127.0.0.1:${callback.port}${callbackPath}`, - state, - onAuthorizationUrl: (url) => { - log(`Sign in to Fulldev in your browser. If it does not open, open:`); - log(url.href); - if (browser) openBrowser(url.href); - } + const provider = new BrowserLoginProvider(store, target, `http://127.0.0.1:${callback.port}${callbackPath}`, state, (url) => { + log(`Sign in to ${target.title} in your browser. If it does not open, open:`); + log(url.href); + if (browser) openBrowser(url.href); }); if (await auth(provider, { - serverUrl, - forceReauthorization: true + serverUrl: target.url, + forceReauthorization: true, + ...fetchFn ? { fetchFn } : {} }) === "AUTHORIZED") return; const params = await callback.wait(timeoutMs); - if (params.get("state") !== state) throw new Error("Sign-in failed: the response did not match this login."); + 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 Error(`Sign-in failed: ${[error, params.get("error_description")].filter(Boolean).join(": ")}`); + 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 Error("Sign-in failed: no authorization code."); + if (!code) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: no authorization code."); await auth(provider, { - serverUrl, + serverUrl: target.url, authorizationCode: code, - iss: params.get("iss") ?? void 0 + iss: params.get("iss") ?? void 0, + ...fetchFn ? { fetchFn } : {} }); } finally { callback.close(); } } -//#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 an error object, also when the server sent plain text. */ -function toolError(result) { - const output = toolOutput(result); - return output !== null && typeof output === "object" && !Array.isArray(output) ? output : { error: { - code: "TOOL_ERROR", - message: output - } }; +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/form-wait.ts -/** The one tool the CLI names itself. */ -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`; +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 = clientFor(discovery.issuer, 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); + } + throw error; + } + await saveTokens(store, target, tokens, discovery.issuer); } /** -* 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. +* Revokes the refresh token (RFC 7009), sending the client_id as a public +* client. Never throws: the result says what failed. */ -async function waitForForm(callTool, { branchId, formId, timeoutMs, progress = () => {}, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) { - const deadline = now() + timeoutMs; - let failures = 0; - for (let round = 1; now() < deadline; round++) { - let result; - try { - result = await callTool(waitTool, { - branchId, - formId +async function revokeTokens(target, credentials, fetchFn = fetch) { + const { tokens } = credentials; + if (!tokens) return { revoked: true }; + try { + const metadata = credentials.issuer ? await discoverAuthorizationServerMetadata(credentials.issuer, { fetchFn }) : (await discover(target, fetchFn)).metadata; + const endpoint = metadata?.revocation_endpoint; + if (!endpoint) return { + revoked: false, + error: "The authorization server has no revocation endpoint." + }; + const client = credentials.client ?? fixedClient(metadata.issuer); + if (!client) return { + revoked: false, + error: "No OAuth client to revoke with." + }; + 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 }); - failures = 0; - } catch (error) { - if (isLoginRequired(error) || error instanceof ProtocolError || ++failures >= maxFailures) throw error; - progress(`The request failed (${error.message}); retrying.`); - await sleep(retryMs); - continue; + 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(": ")}`); + } } - 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 + return failures.length ? { + revoked: false, + error: `Revocation failed for ${failures.join("; ")}` + } : { revoked: true }; + } catch (error) { + return { + revoked: false, + error: error.message }; - progress(`Waiting for the person to send the form (round ${round}, ${minutes(deadline - now())} left).`); } - return { - status: "timeout", - result: { - waiting: true, - branchId, - formId, - message: `Stopped waiting after ${minutes(timeoutMs)}. Run fulldev form wait ${branchId} ${formId} to keep waiting, or continue when the person says they are done.` - } - }; } -//#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}`); +/** +* Gives the MCP transport the stored access token of one product, 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); + }); + } + 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); } - } 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}`); + 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); } - 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/cli.ts -const exit = { - error: 1, - timeout: 2, - login: 3 }; -const print = (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -const printError = (value) => process.stderr.write(`${JSON.stringify(value, null, 2)}\n`); -const hint = (line) => process.stderr.write(`${line}\n`); +//#endregion +//#region src/main.ts /** A tool call can wait on the server for up to a minute. */ const requestTimeoutMs = 12e4; -async function connect(serverUrl, store) { - if (!(await store.get()).tokens) throw new LoginRequiredError(serverUrl); +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; +/** A rejected or expired sign-in, from any layer, becomes SignInRequiredError. */ +function signInError(error, target) { + if (isSignInRequired(error)) return error; + if (error instanceof UnauthorizedError || error instanceof SdkError && /\b401\b|unauthori[sz]ed/i.test(error.message)) return new SignInRequiredError(target); + return error; +} +async function connect(target, io) { + if (!(await io.store.get(target.url)).tokens) throw new SignInRequiredError(target); const client = new Client({ name: "fulldev-cli", version }); - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: new CliOAuthProvider(store) }); + const transport = new StreamableHTTPClientTransport(new URL(target.url), { authProvider: new StoredTokenAuth(target, io.store) }); try { await client.connect(transport, { timeout: requestTimeoutMs }); } catch (error) { - throw signInError(error, serverUrl); + throw signInError(error, target); } return client; } -/** A rejected or expired sign-in, from any layer, becomes LoginRequiredError. */ -function signInError(error, serverUrl) { - if (isLoginRequired(error)) return error; - if (error instanceof UnauthorizedError || error instanceof SdkError && /401|unauthori[sz]ed/i.test(error.message)) return new LoginRequiredError(serverUrl); - return error; -} -/** Runs an action with a connected client, signing in first when allowed. */ -async function withClient(line, store, action) { +/** +* Runs an action with a connected client. When a sign-in is needed it starts +* one in the browser only for a person at a terminal; otherwise it fails at +* once with exit code 3. +*/ +async function withClient(target, session, io, action) { const attempt = async () => { - const client = await connect(line.url, store); + const client = await connect(target, io); try { return await action(client); } catch (error) { - throw signInError(error, line.url); + throw signInError(error, target); } finally { await client.close().catch(() => {}); } @@ -555,11 +1261,11 @@ async function withClient(line, store, action) { try { return await attempt(); } catch (error) { - if (!isLoginRequired(error) || !line.login) throw error; - hint(`Not signed in to ${line.url}.`); - await login(line.url, store, { - browser: line.browser, - log: hint + if (!isSignInRequired(error) || !session.login || !io.interactive) throw error; + io.stderr(`Not signed in to ${target.title}.\n`); + await browserLogin(target, io.store, { + browser: session.browser, + log: (line) => io.stderr(`${line}\n`) }); return attempt(); } @@ -574,176 +1280,204 @@ async function listTools(client) { } while (cursor); return tools; } -function claims(token) { - try { - const value = JSON.parse(Buffer.from(token?.split(".")[1] ?? "", "base64url").toString()); - return value && typeof value === "object" ? value : {}; - } catch { - return {}; - } -} -async function status(line, store) { +async function productStatus(target, io) { + const { credentials, storage } = await io.store.read(target.url); const base = { - server: line.url, - credentials: store.path + product: target.name, + server: target.url }; - if (!(await store.get()).tokens) { - print({ - ...base, - signedIn: false - }); - return exit.login; - } - let server; + if (!credentials.tokens) return { + ...base, + signedIn: false + }; + let valid = true; + let error; try { - const client = await connect(line.url, store); - server = client.getServerVersion(); - await client.close(); - } catch (error) { - if (!isLoginRequired(error)) throw error; - print({ - ...base, - signedIn: true, - valid: false - }); - return exit.login; + await (await connect(target, io)).close().catch(() => {}); + } catch (cause) { + if (isSignInRequired(cause)) valid = false; + else { + valid = void 0; + error = cause.message; + } } - const { tokens, savedAt } = await store.get(); - const access = claims(tokens?.access_token); - const identity = claims(tokens?.id_token); - const expires = typeof access.exp === "number" ? access.exp * 1e3 : savedAt && tokens?.expires_in ? savedAt + tokens.expires_in * 1e3 : void 0; - print({ + const current = await io.store.get(target.url); + const access = claims(current.tokens?.access_token); + const expires = expiresAt(current); + return { ...base, - signedIn: true, - valid: true, - email: identity.email, - name: identity.name, - userId: access.sub ?? identity.sub, + signedIn: Boolean(current.tokens), + valid, + ...error ? { error } : {}, + email: current.email, organizationId: access.org_id, expiresAt: expires ? new Date(expires).toISOString() : void 0, - serverName: server?.title ?? server?.name, - serverVersion: server?.version - }); - return 0; + storage + }; } -async function run(line) { - if (line.version) { - process.stdout.write(`${version}\n`); - return 0; +async function status(targets, io) { + const results = []; + for (const target of targets) results.push(await productStatus(target, io)); + const storage = await io.store.storage(); + io.stdout(json({ + storage: storage === "keychain" ? { + kind: "keychain", + service: "fulldev" + } : { + kind: "file", + path: io.store.filePath, + reason: `The OS keychain is unavailable: ${io.store.keychainError}` + }, + products: results + })); + if (!results.some((result) => result.signedIn && !("valid" in result && result.valid === false))) return exitCodes.signIn; + return results.some((result) => "error" in result) ? exitCodes.error : exitCodes.ok; +} +async function logout(targets, io) { + const results = []; + for (const target of targets) { + const credentials = await io.store.get(target.url); + if (!credentials.tokens && !credentials.client) { + results.push({ + product: target.name, + signedIn: false + }); + continue; + } + const revocation = await revokeTokens(target, credentials); + if (!revocation.revoked) io.stderr(`Could not revoke the tokens of ${target.title}: ${revocation.error} They are deleted from this computer anyway.\n`); + await io.store.update(target.url, () => ({})); + results.push({ + product: target.name, + signedIn: false, + revoked: revocation.revoked, + ...revocation.error ? { revocationError: revocation.error } : {} + }); } - if (!line.command || line.help || line.command === "help") { - process.stdout.write(help); - return 0; + io.stdout(json({ products: results })); + return exitCodes.ok; +} +async function run(command, io) { + const log = (line) => io.stderr(`${line}\n`); + switch (command.kind) { + case "version": + io.stdout(`${version}\n`); + return exitCodes.ok; + case "help": + io.stdout(helpText(command.topic) ?? ""); + return exitCodes.ok; + } + for (const { url, credentials } of await io.store.migrate()) { + const product = products.find((candidate) => candidate.url === url); + const name = product?.name; + if (product) await revokeTokens({ + ...product, + urlFromFlag: false + }, credentials); + log(`fulldev now signs in per product and keeps tokens in the OS keychain. The sign-in from fulldev 0.1.0 for ${url} was removed; run fulldev login${name ? ` ${name}` : ""} to sign in again.`); } - const store = new CredentialStore(line.url); - const [first, second, third] = line.args; - switch (line.command) { + switch (command.kind) { case "login": - await login(line.url, store, { - browser: line.browser, - log: hint - }); - hint("Signed in."); - return status(line, store); - case "logout": - await store.update(({ client }) => client ? { client } : {}); - print({ - server: line.url, - signedIn: false - }); - return 0; - case "status": return status(line, store); + for (const target of command.targets) { + const previous = await io.store.get(target.url); + await browserLogin(target, io.store, { + browser: command.browser, + log + }); + if (previous.tokens) { + const revocation = await revokeTokens(target, previous); + if (!revocation.revoked) log(`Could not revoke the previous sign-in to ${target.title}: ${revocation.error}`); + } + log(`Signed in to ${target.title}.`); + } + return status(command.targets, io); + case "logout": return logout(command.targets, io); + case "status": return status(command.targets, io); case "instructions": { - const text = await withClient(line, store, async (client) => client.getInstructions()); - if (!text) hint("The server sent no instructions."); - else process.stdout.write(`${text}\n`); - return 0; + const text = await withClient(command.target, command.session, io, async (client) => client.getInstructions()); + if (!text) log("The server sent no instructions."); + else io.stdout(`${text}\n`); + return exitCodes.ok; } case "tools": { - const tools = await withClient(line, store, listTools); - if (!first) { - print(tools.map((tool) => ({ + const tools = await withClient(command.target, command.session, io, listTools); + if (!command.name) { + io.stdout(json(tools.map((tool) => ({ name: tool.name, title: tool.title ?? tool.annotations?.title, description: tool.description?.split("\n")[0] - }))); - hint("Run fulldev tools for a tool's input schema."); - return 0; + })))); + return exitCodes.ok; } - const tool = tools.find((candidate) => candidate.name === first); - if (!tool) { - printError({ error: { - code: "NOT_FOUND", - message: `No tool named ${first}.` - } }); - return exit.error; - } - print({ + const tool = tools.find((candidate) => candidate.name === command.name); + if (!tool) throw new CliError("NOT_FOUND", `${command.target.title} has no tool named ${command.name}. Run fulldev ${command.target.name} tools for the list.`); + io.stdout(json({ name: tool.name, title: tool.title ?? tool.annotations?.title, description: tool.description, - inputSchema: tool.inputSchema - }); - return 0; + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations + })); + return exitCodes.ok; } case "call": { const args = await readToolArguments({ - json: second, - file: line.file + json: command.json, + file: command.file }); - const result = await withClient(line, store, (client) => client.callTool({ - name: first, + const result = await withClient(command.target, command.session, io, (client) => client.callTool({ + name: command.tool, arguments: args }, { timeout: requestTimeoutMs })); if (result.isError) { - printError(toolError(result)); - return exit.error; + io.stderr(json(toolError(result))); + return exitCodes.error; } - print(toolOutput(result)); - return 0; + io.stdout(json(toolOutput(result))); + return exitCodes.ok; } - case "form": { - const outcome = await withClient(line, store, (client) => waitForForm((name, args) => client.callTool({ + case "form-wait": { + const outcome = await withClient(command.target, command.session, io, (client) => waitForForm((name, args, signal) => client.callTool({ name, arguments: args - }, { timeout: requestTimeoutMs }), { - branchId: second, - formId: third, - timeoutMs: line.timeoutMinutes * 6e4, - progress: hint + }, { + timeout: requestTimeoutMs, + signal + }), { + branchId: command.branchId, + formId: command.formId, + timeoutMs: command.timeoutMinutes * 6e4, + product: command.target.name, + progress: log })); if (outcome.status === "error") { - printError(outcome.error); - return exit.error; + io.stderr(json(outcome.error)); + return exitCodes.error; } - print(outcome.result); - return outcome.status === "timeout" ? exit.timeout : 0; + io.stdout(json(outcome.result)); + return outcome.status === "timeout" ? exitCodes.timeout : exitCodes.ok; } } - throw new UsageError(`Unknown command: ${line.command}`); } -async function main() { +/** Runs the CLI and returns the exit code; every error becomes one JSON object on stderr. */ +async function main(argv, io) { try { - process.exitCode = await run(parseCommandLine(process.argv.slice(2))); + return await run(parseCommandLine(argv, io.env), io); } catch (error) { - if (error instanceof UsageError) { - hint(`${error.message}\nRun fulldev help for usage.`); - process.exitCode = exit.error; - } else if (isLoginRequired(error)) { - printError({ error: { - code: "UNAUTHORIZED", - message: error.message - } }); - process.exitCode = exit.login; - } else { - printError({ error: { - code: error instanceof ProtocolError ? error.code : error instanceof Error ? error.name : "ERROR", - message: error instanceof Error ? error.message : String(error) - } }); - process.exitCode = exit.error; - } + const { body, exitCode } = describeError(error); + io.stderr(json(body)); + return exitCode; } } -await main(); +//#endregion +//#region src/cli.ts +process.exitCode = await main(process.argv.slice(2), { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + env: process.env, + store: new CredentialStore() +}); //#endregion export {}; diff --git a/package.json b/package.json index 1b8a051..5388029 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fulldev", "version": "0.1.0", - "description": "Edit your Fulldev website from the command line, through the Fulldev CMS MCP server.", + "description": "Use Fulldev products, such as the CMS, Connect and Scan, from the command line through their MCP servers.", "license": "MIT", "repository": { "type": "git", @@ -29,7 +29,8 @@ "node": ">=24" }, "dependencies": { - "@modelcontextprotocol/client": "^2.3.0" + "@modelcontextprotocol/client": "^2.3.0", + "@napi-rs/keyring": "2.1.0" }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", @@ -50,6 +51,8 @@ "keywords": [ "fulldev", "cms", + "connect", + "scan", "mcp", "cli", "agent-skills" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3245ac6..f434ecf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: '@modelcontextprotocol/client': specifier: ^2.3.0 version: 2.3.0 + '@napi-rs/keyring': + specifier: 2.1.0 + version: 2.1.0 devDependencies: '@changesets/changelog-github': specifier: ^0.7.0 @@ -297,6 +300,87 @@ packages: resolution: {integrity: sha512-09BHFaNVBe5pB2AhzCfLj0cqmsKLERvZQ4FblzP90UMGLaXVqdZPByFjURwsUYCmxonUW2yAwgTTvle+8O2/8w==} engines: {node: '>=20'} + '@napi-rs/keyring-darwin-arm64@2.1.0': + resolution: {integrity: sha512-j6ATTOhmPW+N5EKwi8Yzn6P4Bz/uqnxzyYvqOL73/dgzDSYG5pS5eprqpVmOLse68WUTf79ZMqz97HnfvirJ9g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@2.1.0': + resolution: {integrity: sha512-54Q803nguiOaQzBG2h4uIkacI0eLGAlzzdpxPNi6IZtXbjqbPI4AQ86GrQy25tw3czBqRPBoGu0lHP2U+/f5iA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@2.1.0': + resolution: {integrity: sha512-BL7ngJAYQBrUp1PTF8enOXOIVfxN51RbwVOEQ+8eAjWypF/5Ipqs8oQoMqkuc0wAbk+vatnNVjdKCIFa/HPglg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@2.1.0': + resolution: {integrity: sha512-JYgbCDk+giss7lKla8NTXhM/fLj+Hrfgr3xwKQ8jKn8qMD6vXLwYcfK8HJ9VoAcjIeYEUpCL6dRszJC5M4zFNg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@2.1.0': + resolution: {integrity: sha512-+PMONFQ+2GkBXG7vRG2spgR73bNCMeVpNopHoSmhj2E+5Gu6zJooRBjoaIZKj6hCyrOU8pXAhoZ22EEFQ/4YeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@2.1.0': + resolution: {integrity: sha512-dKjiXKTHQjAS57hL0HVzLpW9XYNGFpfYkr/51FzIIvbZR08PAnzIDYKwShoNYGvjQP6+rDFf1a5Utl0aWwcFrA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@2.1.0': + resolution: {integrity: sha512-tqhb0ofhtEJ2+ZfmlKTDHbmIoNSz+SSTPWRgMfSVCgI4JVkqDdfcmIOcThZfRffJ3QI9C3Tng+rZL24acmD+GA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@2.1.0': + resolution: {integrity: sha512-7ZA0ssxfpuGXV8S5Ct1ZJoEL3sKg/8u7mIL4SK9/PwKBhxIq7K+FVcjnggNsuFnEhHTX+HYl9hHDrlX0odZnHg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@2.1.0': + resolution: {integrity: sha512-dI74Fzl03CrN2TEP7YVBtUBFgyRTRPyK1LMcIgAUfgW3o6f5SD1a8EsfT82+brpFOltjOhacIlFiYOkYtSI5Fg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@2.1.0': + resolution: {integrity: sha512-SltrXXkEe9a+Jv8ConDkZW2Mdr0srpWurS7oYkYP1Mp07DNBpFyqawRnyo4p7ZvQU3e+R6irj/Fq0epCCvVUXA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@2.1.0': + resolution: {integrity: sha512-l5wJQhqXkAfQ4FGxoRh0wMpL9VxVmfiYI5TTeOGNupuVsQvbHmysh8gfDQBBWVhyMENHJS2OqOSYIuqVjumAtA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@2.1.0': + resolution: {integrity: sha512-3zYwO7dhJiUNyv2te81ujXm2vGxHwgx+z2eBm0dTYe3aWpvKQVzpfxCp/7cViJs8duVYE04wVOgXiRhRCMEq2w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@2.1.0': + resolution: {integrity: sha512-km9J3fomkGSLpImpelq0cMvLBrJ5eVlTzuWdG3Iy0laP6gU90MmHYjqhUhG32aYq0/0++r2TLuAA9xlnh4XU1g==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1811,6 +1895,57 @@ snapshots: dependencies: zod: 4.6.5 + '@napi-rs/keyring-darwin-arm64@2.1.0': + optional: true + + '@napi-rs/keyring-darwin-x64@2.1.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@2.1.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@2.1.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@2.1.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@2.1.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@2.1.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@2.1.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@2.1.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@2.1.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@2.1.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@2.1.0': + optional: true + + '@napi-rs/keyring@2.1.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 2.1.0 + '@napi-rs/keyring-darwin-x64': 2.1.0 + '@napi-rs/keyring-freebsd-x64': 2.1.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 2.1.0 + '@napi-rs/keyring-linux-arm64-gnu': 2.1.0 + '@napi-rs/keyring-linux-arm64-musl': 2.1.0 + '@napi-rs/keyring-linux-riscv64-gnu': 2.1.0 + '@napi-rs/keyring-linux-x64-gnu': 2.1.0 + '@napi-rs/keyring-linux-x64-musl': 2.1.0 + '@napi-rs/keyring-win32-arm64-msvc': 2.1.0 + '@napi-rs/keyring-win32-ia32-msvc': 2.1.0 + '@napi-rs/keyring-win32-x64-msvc': 2.1.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index de7d053..62e74f7 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -1,50 +1,86 @@ --- name: fulldev -description: Use when the person wants to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. +description: Use the Fulldev CLI to work with Fulldev products for the person. Use cms when they want to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. Use connect when they want to use their business tools, such as Shopify, through Fulldev Connect. Use scan to scan websites, only for Fulldev administrators. +license: MIT +compatibility: Needs Node.js 24 or later and network access to cms.full.dev, connect.full.dev, scan.full.dev and clerk.full.dev. --- # Fulldev -The `fulldev` CLI edits the person's website repository through the Fulldev CMS. Every change goes to a branch and a draft pull request, within the file permissions Fulldev grants the person. +The `fulldev` CLI talks to the MCP server of each Fulldev product. The tools, their schemas and the instructions come from the server, so they are always current. + +| Product | For | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `cms` | Editing the person's website. Every change goes to a branch and a draft pull request, within the file permissions Fulldev grants the person. | +| `connect` | The person's business tools, such as Shopify, with the access their organization grants them. | +| `scan` | Scanning websites. Only for Fulldev administrators. | ## Run Use `fulldev` when it is installed. Otherwise run every command as `npx -y fulldev `. -Results are JSON on stdout. Hints and errors go to stderr. Exit codes: 0 done, 1 error, 2 form wait timed out, 3 sign-in needed. +Every product has the same commands: + +```sh +fulldev instructions # read first +fulldev tools # list the tools +fulldev tools # one tool: description, input and output schema +fulldev call '' # call a tool +``` + +Data is JSON on stdout. Progress lines go to stderr. Every error is one JSON object on stderr, `{"error":{"code":"...","message":"..."}}`. + +Exit codes: + +| Code | Meaning | +| ---- | --------------------------------------------------------------------- | +| 0 | Done | +| 1 | Error, from the tool or the CLI | +| 2 | `form wait` stopped waiting | +| 3 | Sign-in needed; the error says which `fulldev login ` to run | +| 64 | The command was called wrongly; the error names the help to read | + +`fulldev --help`, `fulldev --help` and `fulldev --help` explain every command. ## Sign in -Run `fulldev login`. It opens the browser, where the person signs in and picks their organization, and waits up to five minutes. Tell the person a sign-in page opened. Any other command also starts a sign-in when needed. `fulldev status` shows who is signed in. +The person signs in once per product with their Fulldev account and picks an organization: + +```sh +fulldev login cms # opens the browser +fulldev login # every product, one after the other +fulldev status # per product: signed in, still valid, email, organization, expiry +fulldev logout cms # revokes the sign-in and deletes the tokens +``` + +Tell the person a sign-in page opened, or give them the link from stderr. Login waits up to five minutes. + +Without a terminal, as when you run commands, a product command never starts a sign-in: it exits with 3 at once. Then run the `fulldev login ` from the error and ask the person to finish it. The organization is chosen at sign-in; to switch organization, run `fulldev login ` again. ## First, read the instructions -Run `fulldev instructions` at the start of every session and follow them. They come from the server and describe the workflow, permissions, allowance, previews, reviews, and merging. They win over anything in this skill. +Run `fulldev instructions` at the start of every session and follow them. They come from the server and describe the workflow, permissions and limits. They win over anything in this skill. -`fulldev tools` lists the tools. `fulldev tools ` shows a tool's description and input schema; read it before the first call to a tool. +Read `fulldev tools ` before the first call to a tool. -## Edit +## Call tools -Call a tool with a JSON object as the argument, from a file with `--file`, or from stdin with `-`: +Give a tool's input as a JSON argument, from a file with `--file`, or from stdin with `-`. Use stdin or `--file` for long or quoted text, so the shell does not change it. ```sh -fulldev call list_projects -fulldev call open_branch '{"projectId":"","name":"Update opening hours"}' -fulldev call read_file '{"branchId":"","path":"src/content/home.md"}' -fulldev call commit_files - <<'JSON' +fulldev cms call list_projects +fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' +fulldev cms call commit_files - <<'JSON' {"branchId":"","expectedRevision":"","changes":[{"path":"src/content/home.md","edits":[{"oldText":"Open 9 to 5","newText":"Open 8 to 6"}]}]} JSON -fulldev call get_preview '{"branchId":""}' ``` -Pass the latest revision as `expectedRevision`. Use stdin or `--file` for long or quoted text, so the shell does not change it. - -## Forms +## Forms in the CMS After `create_form`, give the person the form link. Then run: ```sh -fulldev form wait +fulldev cms form wait ``` Run it in the background when you can, so you can keep talking to the person. It prints the answers when the person sends the form. On exit code 2 it stopped waiting; run it again, or continue when the person says they are done. diff --git a/src/args.test.ts b/src/args.test.ts index 1096a33..f62f9be 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -1,80 +1,192 @@ import { describe, expect, it } from "vite-plus/test" -import { defaultUrl, parseCommandLine, UsageError } from "./args.ts" +import { helpText, parseCommandLine } from "./args.ts" +import { UsageError } from "./errors.ts" +import { products } from "./products.ts" describe("parseCommandLine", () => { - it("uses the default server and options", () => { - const line = parseCommandLine(["tools"], {}) - expect(line).toMatchObject({ - command: "tools", - args: [], - url: defaultUrl, - login: true, - browser: true, - timeoutMinutes: 30, + it("reads product commands with the product's default server", () => { + expect(parseCommandLine(["cms", "tools"], {})).toMatchObject({ + kind: "tools", + target: { name: "cms", url: "https://cms.full.dev/mcp" }, + session: { login: true, browser: true }, + }) + expect(parseCommandLine(["connect", "tools", "x"], {})).toMatchObject({ + kind: "tools", + name: "x", + target: { name: "connect", url: "https://connect.full.dev/mcp" }, + }) + expect(parseCommandLine(["scan", "instructions"], {})).toMatchObject({ + kind: "instructions", + target: { url: "https://scan.full.dev/mcp" }, }) }) - it("takes the server from --url before FULLDEV_URL", () => { - const env = { FULLDEV_URL: "https://preview.example.com/mcp" } - expect(parseCommandLine(["status"], env).url).toBe(env.FULLDEV_URL) + it("reads call input from the argument, stdin and --file", () => { + expect( + parseCommandLine(["cms", "call", "read_file", "-"], {}), + ).toMatchObject({ kind: "call", tool: "read_file", json: "-" }) expect( - parseCommandLine( - ["status", "--url", "https://other.example.com/mcp"], - env, - ).url, - ).toBe("https://other.example.com/mcp") + parseCommandLine(["cms", "call", "read_file", "-f", "in.json"], {}), + ).toMatchObject({ file: "in.json" }) + expect(parseCommandLine(["cms", "call", "x", '{"a":1}'], {})).toMatchObject( + { + json: '{"a":1}', + }, + ) }) - it("reads call arguments, stdin and --file", () => { - expect(parseCommandLine(["call", "read_file", "-"], {}).args).toEqual([ - "read_file", - "-", - ]) + it("reads cms form wait with a timeout", () => { expect( - parseCommandLine(["call", "read_file", "-f", "input.json"], {}).file, - ).toBe("input.json") - expect(parseCommandLine(["call", "x", '{"a":1}'], {}).args[1]).toBe( - '{"a":1}', - ) + parseCommandLine(["cms", "form", "wait", "b", "f", "--timeout", "5"], {}), + ).toMatchObject({ + kind: "form-wait", + branchId: "b", + formId: "f", + timeoutMinutes: 5, + }) + expect( + parseCommandLine(["cms", "form", "wait", "b", "f"], {}), + ).toMatchObject({ timeoutMinutes: 30 }) }) - it("reads form wait with a timeout", () => { - const line = parseCommandLine( - ["form", "wait", "branch", "form", "--timeout", "5"], - {}, - ) - expect(line.args).toEqual(["wait", "branch", "form"]) - expect(line.timeoutMinutes).toBe(5) + it("defaults login, logout and status to every product", () => { + for (const kind of ["login", "logout", "status"] as const) { + const command = parseCommandLine([kind], {}) + expect(command.kind).toBe(kind) + expect( + "targets" in command && command.targets.map((t) => t.name), + ).toEqual(products.map((product) => product.name)) + } + expect(parseCommandLine(["login", "cms", "cms"], {})).toMatchObject({ + targets: [{ name: "cms" }], + browser: true, + }) + expect( + parseCommandLine(["login", "connect", "--no-browser"], {}), + ).toMatchObject({ browser: false }) }) - it("turns off sign-in and the browser", () => { - const line = parseCommandLine(["tools", "--no-login", "--no-browser"], {}) - expect(line.login).toBe(false) - expect(line.browser).toBe(false) + it("turns off sign-in and the browser for product commands", () => { + expect( + parseCommandLine(["cms", "tools", "--no-login", "--no-browser"], {}), + ).toMatchObject({ session: { login: false, browser: false } }) }) - it("allows help and version without a command", () => { - expect(parseCommandLine([], {}).command).toBeUndefined() - expect(parseCommandLine(["--help"], {}).help).toBe(true) - expect(parseCommandLine(["-v"], {}).version).toBe(true) + it("shows help for each command and the version", () => { + expect(parseCommandLine([], {})).toEqual({ kind: "help", topic: "" }) + expect(parseCommandLine(["--help"], {})).toEqual({ + kind: "help", + topic: "", + }) + expect(parseCommandLine(["cms"], {})).toEqual({ + kind: "help", + topic: "cms", + }) + expect(parseCommandLine(["cms", "--help"], {})).toEqual({ + kind: "help", + topic: "cms", + }) + expect(parseCommandLine(["cms", "call", "x", "-h"], {})).toEqual({ + kind: "help", + topic: "cms call", + }) + expect(parseCommandLine(["help", "cms", "form", "wait"], {})).toEqual({ + kind: "help", + topic: "cms form wait", + }) + expect(parseCommandLine(["help", "login"], {})).toEqual({ + kind: "help", + topic: "login", + }) + expect(parseCommandLine(["-v"], {})).toEqual({ kind: "version" }) + expect(parseCommandLine(["cms", "--version"], {})).toEqual({ + kind: "version", + }) }) it.each([ [["frobnicate"]], - [["call"]], - [["call", "a", "b", "c"]], - [["tools", "a", "b"]], - [["form", "wait", "branch"]], - [["form", "send", "a", "b"]], - [["tools", "--file", "x.json"]], - [["call", "x", "--timeout", "3"]], - [["form", "wait", "a", "b", "--timeout", "0"]], - [["form", "wait", "a", "b", "--timeout", "soon"]], - [["tools", "--url", "not a url"]], - [["tools", "--url", "ftp://example.com/mcp"]], - [["tools", "--unknown"]], - ])("rejects %j", (argv) => { + [["tools"]], + [["call", "x"]], + [["cms", "frobnicate"]], + [["cms", "call"]], + [["cms", "call", "a", "b", "c"]], + [["cms", "tools", "a", "b"]], + [["cms", "instructions", "a"]], + [["cms", "form", "wait", "branch"]], + [["cms", "form", "send", "a", "b"]], + [["connect", "form", "wait", "a", "b"]], + [["scan", "form", "wait", "a", "b"]], + [["cms", "tools", "--file", "x.json"]], + [["cms", "call", "x", "--timeout", "3"]], + [["cms", "form", "wait", "a", "b", "--timeout", "0"]], + [["cms", "form", "wait", "a", "b", "--timeout", "soon"]], + [["cms", "tools", "--device"]], + [["cms", "tools", "--unknown"]], + [["login", "nope"]], + [["login", "--no-login"]], + [["login", "--device"]], + [["login", "--url", "https://x.example/mcp"]], + [["login", "cms", "connect", "--url", "https://x.example/mcp"]], + [["cms", "tools", "--url", "not a url"]], + [["cms", "tools", "--url", "ftp://example.com/mcp"]], + [["help", "cms", "frobnicate"]], + [["help", "connect", "form"]], + ])("rejects %j as a usage error", (argv) => { expect(() => parseCommandLine(argv, {})).toThrow(UsageError) }) + + it("points a usage error to the help of its command", () => { + try { + parseCommandLine(["cms", "call"], {}) + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(UsageError) + expect((error as UsageError).details.help).toBe("fulldev cms call --help") + expect((error as UsageError).exitCode).toBe(64) + } + }) +}) + +describe("helpText", () => { + it("lists every product and the exit codes at the root", () => { + const text = helpText("")! + for (const product of products) expect(text).toContain(product.name) + expect(text).toContain("64 usage error") + expect(text).toContain("fulldev cms form wait") + }) + + it("shows form wait only for cms", () => { + expect(helpText("cms")).toContain("fulldev cms form wait") + expect(helpText("connect")).not.toContain("connect form") + expect(helpText("connect form wait")).toBeUndefined() + }) + + it("explains a subcommand with its options", () => { + const text = helpText("cms call")! + expect(text).toContain("Usage: fulldev cms call ") + expect(text).toContain("--file") + expect(text).not.toContain("--timeout") + expect(helpText("login")).toContain("--no-browser") + }) + + it("never uses an em dash", () => { + const topics = [ + "", + "login", + "logout", + "status", + "help", + ...products.flatMap((product) => [ + product.name, + `${product.name} tools`, + `${product.name} call`, + `${product.name} instructions`, + ]), + "cms form wait", + ] + for (const topic of topics) + expect(helpText(topic)).not.toContain(String.fromCharCode(0x2014)) + }) }) diff --git a/src/args.ts b/src/args.ts index dff0ec4..375e495 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,42 +1,245 @@ import { parseArgs } from "node:util" -export const defaultUrl = "https://cms.full.dev/mcp" +import { UsageError } from "./errors.ts" +import { + findProduct, + products, + resolveTarget, + urlVariable, +} from "./products.ts" +import type { Product, Target } from "./products.ts" -/** A mistake in how the CLI was called. */ -export class UsageError extends Error { - constructor(message: string) { - super(message) - this.name = "UsageError" - } -} +export type Command = + | { kind: "help"; topic: string } + | { kind: "version" } + | { kind: "login"; targets: Array; browser: boolean } + | { kind: "logout"; targets: Array } + | { kind: "status"; targets: Array } + | { kind: "instructions"; target: Target; session: Session } + | { kind: "tools"; target: Target; session: Session; name?: string } + | { + kind: "call" + target: Target + session: Session + tool: string + json?: string + file?: string + } + | { + kind: "form-wait" + target: Target + session: Session + branchId: string + formId: string + timeoutMinutes: number + } -export interface CommandLine { - command: string | undefined - args: Array - url: string - file: string | undefined - timeoutMinutes: number +/** How a product command may sign in when it needs to. */ +export interface Session { login: boolean browser: boolean - help: boolean - version: boolean } -const expected: Record = { - login: [0, 0, "fulldev login"], - logout: [0, 0, "fulldev logout"], - status: [0, 0, "fulldev status"], - instructions: [0, 0, "fulldev instructions"], - tools: [0, 1, "fulldev tools [name]"], - call: [1, 2, "fulldev call [json | -] [--file ]"], - form: [3, 3, "fulldev form wait [--timeout ]"], - help: [0, 0, "fulldev help"], +type Option = "url" | "file" | "timeout" | "login" | "browser" + +interface Topic { + usage: string + summary: string + options?: Array