diff --git a/.changeset/account-sign-in.md b/.changeset/account-sign-in.md new file mode 100644 index 0000000..7a56f12 --- /dev/null +++ b/.changeset/account-sign-in.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Sign in through Fulldev's own account pages at `https://app.full.dev`; existing sign-ins must sign in again. diff --git a/.changeset/apps.md b/.changeset/apps.md new file mode 100644 index 0000000..50a01de --- /dev/null +++ b/.changeset/apps.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Fulldev's products are now called apps. The help lists them under "Apps" and reads `fulldev `, and the JSON of `fulldev status`, `fulldev logout` and a sign-in error names each one as `app` (a list under `apps`) instead of `product` (`products`). Commands, names and servers stay the same. diff --git a/.changeset/contacts-app.md b/.changeset/contacts-app.md new file mode 100644 index 0000000..619e49e --- /dev/null +++ b/.changeset/contacts-app.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Add Fulldev Contacts as the app `contacts`: check, pause and resume the daily copy of your Google contacts, and answer its look-alike questions. diff --git a/.changeset/one-server.md b/.changeset/one-server.md new file mode 100644 index 0000000..f8107de --- /dev/null +++ b/.changeset/one-server.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Every app through one Fulldev MCP server, at `https://app.full.dev/mcp`, with one sign-in. `fulldev login`, `fulldev status` and `fulldev logout` no longer take an app, and `status` and `logout` print that one sign-in as one object instead of a list under `apps`; a sign-in error names `fulldev login`. Each app's tools start with its name, such as `cms_list_repositories`. `fulldev instructions`, `fulldev tools` and `fulldev call` work across every app you may use; `fulldev instructions|tools|call` show and call that app's part, with tool names with or without its prefix. `FULLDEV_URL` replaces `FULLDEV__URL`. diff --git a/.changeset/pages-app.md b/.changeset/pages-app.md new file mode 100644 index 0000000..a3a8eca --- /dev/null +++ b/.changeset/pages-app.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Add Fulldev Pages as the app `pages`. diff --git a/.changeset/paste-sign-in.md b/.changeset/paste-sign-in.md new file mode 100644 index 0000000..21b7afa --- /dev/null +++ b/.changeset/paste-sign-in.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Sign in over SSH: with `--no-browser`, paste the address of the page after sign-in into the terminal when the browser is on another computer. fulldev no longer registers an OAuth client itself and fails with `CLIENT_METADATA_UNSUPPORTED` on a server without Client ID Metadata Documents. It no longer removes the sign-in file of version 0.1.0; delete `~/.config/fulldev/credentials.json` if it is still there. diff --git a/.changeset/token-lifetime.md b/.changeset/token-lifetime.md new file mode 100644 index 0000000..09d5c03 --- /dev/null +++ b/.changeset/token-lifetime.md @@ -0,0 +1,5 @@ +--- +"fulldev": patch +--- + +Access tokens now last 15 minutes; the help of `fulldev logout` says so. diff --git a/README.md b/README.md index 66228ae..9329a09 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # Fulldev CLI -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. +Use Fulldev apps from a terminal or an AI agent. The CLI is a thin client of the Fulldev MCP server at `https://app.full.dev/mcp`, which has the tools of every app your organization gives you: it reads the tools, their schemas, and the instructions from the server each time, so it stays in step with the apps. Each app's tools start with its name, such as `cms_`. -| 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 | -| `sites` | `https://sites.full.dev/mcp` | A finished website from [Fulldev Sites](https://sites.full.dev), for administrators only | +| App | For | +| ---------- | ---------------------------------------------------------------------------------------------------- | +| `cms` | Editing your website through the [Fulldev CMS](https://cms.full.dev) | +| `connect` | Your business tools, such as Shopify, through [Fulldev Connect](https://connect.full.dev) | +| `scan` | Scanning websites with [Fulldev Scan](https://scan.full.dev), for administrators only | +| `sites` | A finished website from [Fulldev Sites](https://sites.full.dev), for administrators only | +| `pages` | Reports and plans as web pages with [Fulldev Pages](https://pages.full.dev), for administrators only | +| `contacts` | The daily copy of your Google contacts with [Fulldev Contacts](https://contacts.full.dev) | ## Install @@ -34,47 +36,50 @@ npx skills add fulldotdev/cli ## Sign in ```sh -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 +fulldev login # once, for every app +fulldev status # signed in, valid, email, organization, expiry +fulldev logout ``` -`fulldev login` opens your browser to sign in with your Fulldev account and choose your organization. Add `--no-browser` to only print the link. +`fulldev login` opens your browser at [app.full.dev](https://app.full.dev/sign-in), where you sign in with your Fulldev account, with a code sent to your email or with Google, and choose your organization when you are in more than one. Add `--no-browser` to only print the link. -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. +On a computer without a browser, such as over SSH, run `fulldev login --no-browser` and open the link on your own computer. The page after sign-in does not load there, because it is meant for the computer that runs `fulldev`: copy its address from the address bar and paste it into the terminal. -`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. +One sign-in covers every app the organization gives you: the tokens are for the Fulldev MCP server, which checks on every call which apps you may use. When you are in more than one organization, you choose one at every sign-in; to switch organization, run `fulldev login` again. + +`fulldev logout` revokes the refresh token at the authorization server (access tokens cannot be revoked and expire within 15 minutes) and deletes the tokens from this computer. When revoking fails, for example offline, it still deletes them and says so. How it works: -- 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. +- Browser sign-in uses OAuth with PKCE (S256) and a redirect to `http://127.0.0.1:/callback`, or the pasted address of that redirect, after checking that it belongs to this sign-in. It asks for the server's MCP URL as the resource, so the token is only valid there. +- The CLI is a public OAuth client that needs no registration: its client id is the address of its [Client ID Metadata Document](https://full.dev/oauth/cli.json), which the authorization server at `https://app.full.dev/api/auth` reads. It never registers a client, so sign-in to a server that does not support such documents fails with `CLIENT_METADATA_UNSUPPORTED`. +- 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 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`. +- Sign-ins from fulldev 0.3 and earlier were per app and made with Clerk, and no longer work: run `fulldev login` again. -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. +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 to run `fulldev login`. At a terminal they open the browser sign-in themselves; `--no-login` turns that off. ## Use ```sh -fulldev cms instructions # how to use the tools, from the server; read first -fulldev cms tools # name, title and description of each tool +fulldev instructions # how to use every app you may use, from the server; read first +fulldev tools # name, title and description of every tool +fulldev call cms_list_repositories +fulldev cms instructions # the overview and the CMS's own part +fulldev cms tools # the CMS's tools only 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 ``` +After `fulldev `, a tool's name may leave out the app's prefix: `fulldev cms call read_file` calls `cms_read_file`. + `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. +`fulldev cms form wait` keeps calling `cms_wait_for_form` until the person sends the form, with a progress line on stderr each round, then prints the result. After `--timeout` minutes (default 30) it stops, also in the middle of a request, prints the form id, and exits with 2, so you can run it again. ## Output and exit codes @@ -84,8 +89,8 @@ Data is JSON on stdout. `instructions` and help are plain text. Progress lines g { "error": { "code": "SIGN_IN_REQUIRED", - "message": "Not signed in to Fulldev CMS. Run: fulldev login cms", - "command": "fulldev login cms" + "message": "Not signed in to Fulldev. Run: fulldev login", + "command": "fulldev login" } } ``` @@ -100,11 +105,11 @@ Data is JSON on stdout. `instructions` and help are plain text. Progress lines g ## Deploy previews -`--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. +`--url ` or `FULLDEV_URL` selects another server, such as a deploy preview's, which serves it at `/mcp`. Each server has its own sign-in. ```sh -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 +FULLDEV_URL=https://deploy-preview-12--fulldev-app.netlify.app/mcp fulldev login +fulldev cms tools --url https://deploy-preview-12--fulldev-app.netlify.app/mcp ``` ## Develop @@ -115,13 +120,13 @@ pnpm check # format, lint, type check, tests, and that dist/ is up to date pnpm build # writes dist/cli.mjs ``` -A product is one entry in `src/products.ts`. The pre-registered OAuth client ids are in `clientIds` in `src/oauth.ts`. +An app is one entry in `src/apps.ts`; the server's address is `server` there. The OAuth client id is `clientMetadataUrl` in `src/oauth.ts`; the document at that address is published with the full.dev website. `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. +Every change that users get adds a changeset with `pnpm changeset`. The [`fulldev` package](https://www.npmjs.com/package/fulldev) is published only from [fulldotdev/cli](https://github.com/fulldotdev/cli): merging a change with its changeset to `main` opens a release pull request, and merging that publishes to npm from GitHub Actions with trusted publishing and provenance, without an npm token. ## License diff --git a/dist/cli.mjs b/dist/cli.mjs index 0655948..44df5df 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -9,6 +9,7 @@ import { text } from "node:stream/consumers"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; +import { createInterface } from "node:readline"; //#region src/errors.ts const exitCodes = { ok: 0, @@ -41,9 +42,8 @@ var UsageError = class extends CliError { var SignInRequiredError = class extends CliError { target; constructor(target) { - const command = `fulldev login ${target.name}${target.urlFromFlag ? ` --url ${target.url}` : ""}`; + const command = `fulldev login${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 }); @@ -172,7 +172,7 @@ async function withLock(path, action, { timeoutMs = 6e4, staleMs = 12e4 } = {}) } const isEmpty = (credentials) => Object.keys(credentials).length === 0; /** -* Keeps credentials per product server in the OS keychain (service "fulldev", +* Keeps credentials per app 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. */ @@ -181,8 +181,6 @@ var CredentialStore = class { loadKeychain; filePath; lockPath; - /** The 0.1.0 file, removed once by migrate(). */ - legacyPath; /** Why the keychain is not used, once it failed. */ keychainError; keychain; @@ -191,7 +189,6 @@ var CredentialStore = class { 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() { @@ -308,82 +305,74 @@ var CredentialStore = class { 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.3.0"; //#endregion -//#region src/products.ts -const products = [ +//#region src/apps.ts +/** The Fulldev MCP server: the tools of every app the person may use. */ +const server = { + title: "Fulldev", + url: "https://app.full.dev/mcp" +}; +/** The environment variable that overrides the server's URL. */ +const urlVariable = "FULLDEV_URL"; +const apps = [ { name: "cms", title: "Fulldev CMS", - 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." }, { name: "sites", title: "Fulldev Sites", - url: "https://sites.full.dev/mcp", description: "Have Fulldev make a finished website from a brief and tested design options. For administrators only for now." + }, + { + name: "pages", + title: "Fulldev Pages", + description: "Publish reports and plans as clear web pages, composed from ready-made blocks. For administrators only for now." + }, + { + name: "contacts", + title: "Fulldev Contacts", + description: "Look after the daily copy of your Google contacts from one Google account to another: its runs, look-alike questions, pausing and resuming." } ]; -function findProduct(name) { - return products.find((product) => product.name === name); +function findApp(name) { + return apps.find((app) => app.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`; +/** +* A tool's name on the server: an app's tools start with the app's name, +* which may be left out after `fulldev `. +*/ +function toolName(app, name) { + return name.startsWith(`${app.name}_`) ? name : `${app.name}_${name}`; } -/** The product's server: --url, then FULLDEV__URL, then the default. */ -function resolveTarget(product, flagUrl, env) { - const url = flagUrl ?? env[urlVariable(product)] ?? product.url; +/** The server: --url, then FULLDEV_URL, then app.full.dev. */ +function resolveTarget(flagUrl, env) { + const url = flagUrl ?? env["FULLDEV_URL"] ?? server.url; let parsed; try { parsed = new URL(url); } catch { - throw new UsageError(`Not a valid server URL for ${product.name}: ${url}`); + throw new UsageError(`Not a valid server URL: ${url}`); } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new UsageError(`The server URL for ${product.name} must use https: ${url}`); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new UsageError(`The server URL must use https: ${url}`); return { - ...product, + title: server.title, url: parsed.href, urlFromFlag: flagUrl !== void 0 }; @@ -392,52 +381,50 @@ function resolveTarget(product, flagUrl, env) { //#region src/args.ts const rootCommands = { login: { - usage: "fulldev login [product...] [--no-browser] [--url ]", - summary: "Sign in (all products by default)", + usage: "fulldev login [--no-browser] [--url ]", + summary: "Sign in, once for every app", 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.` + details: `Signs in to the Fulldev MCP server in your browser, where you choose your +organization when you are in more than one. One sign-in covers every app +the organization gives you. Run fulldev login again to switch organization. + +With --no-browser, open the link in a browser on any computer. When that is +another computer, as over SSH, the page after sign-in does not load: copy its +address from the address bar and paste it into this terminal.` }, logout: { - usage: "fulldev logout [product...] [--url ]", - summary: "Sign out and revoke the tokens (all products by default)", + usage: "fulldev logout [--url ]", + summary: "Sign out and revoke the tokens", 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.` + details: `Revokes the refresh token at the authorization server and deletes the +tokens from this computer. The access token cannot be revoked and expires +within 15 minutes. When revoking fails, the local sign-out still happens and +the result says so.` }, status: { - usage: "fulldev status [product...] [--url ]", - summary: "Show the sign-in of each product", + usage: "fulldev status [--url ]", + summary: "Show the sign-in", 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.` + details: `Prints 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 a sign-in is needed, and with 1 when the +server could not be reached to check it.` }, - help: { - usage: "fulldev help [command...]", - summary: "Show help for a command" - } -}; -const productCommands = { instructions: { - usage: "fulldev instructions", - summary: "The product's instructions for agents; read them first", + usage: "fulldev instructions", + summary: "The instructions for agents, of every app you may use; read first", options: [ "url", "login", "browser" ], - details: "Prints the instructions the server sends, as plain text." + details: `Prints the instructions the server sends, as plain text: an overview, then +each app's own, under its name. fulldev instructions prints the +overview and that app's part.` }, tools: { - usage: "fulldev tools [name]", - summary: "List the tools, or show one tool", + usage: "fulldev tools [name]", + summary: "List the tools of every app you may use, or show one tool", options: [ "url", "login", @@ -445,10 +432,10 @@ const productCommands = { ], 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.` +annotations. Each app's tools start with its name, such as cms_.` }, call: { - usage: "fulldev call [json | -] [--file ]", + usage: "fulldev call [json | -] [--file ]", summary: "Call a tool with a JSON object", options: [ "file", @@ -459,10 +446,59 @@ annotations.` 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.` + }, + help: { + usage: "fulldev help [command...]", + summary: "Show help for a command" + } +}; +/** The commands that use the server, also per app. */ +const serverCommands = [ + "instructions", + "tools", + "call" +]; +const appCommands = { + instructions: { + usage: "fulldev instructions", + summary: "The overview and the app's own instructions; read first", + options: [ + "url", + "login", + "browser" + ], + details: "Prints them as plain text, as the server sends them." + }, + tools: { + usage: "fulldev tools [name]", + summary: "List the app's tools, or show one tool", + options: [ + "url", + "login", + "browser" + ], + details: `Without a name: the name, title and first line of the description of each +of the app's tools. With a name, with or without the app's prefix: its +description, input schema, output schema and annotations.` + }, + call: { + usage: "fulldev call [json | -] [--file ]", + summary: "Call one of the app's tools with a JSON object", + options: [ + "file", + "url", + "login", + "browser" + ], + details: `The tool's name may leave out the app's prefix: fulldev cms call +list_repositories calls cms_list_repositories. Its input is a JSON object: +the argument, a file with --file, or stdin with -. Without input it sends +{}. Prints the tool's result as JSON on stdout. When the tool fails, prints +its error on stderr and exits with 1.` } }; const formWait = { - usage: "fulldev form wait [--timeout ]", + usage: "fulldev form wait [--timeout ]", summary: "Wait until the person sends a form", options: [ "timeout", @@ -470,76 +506,85 @@ const formWait = { "login", "browser" ], - 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.` + details: `Calls cms_wait_for_form until the person sends the form, with a progress +line on stderr each round, then prints the result. After --timeout minutes +(default 30) it prints the form id and exits with 2, so you can run it again.` }; const optionHelp = { - url: "--url Use another server for the product, such as a deploy preview", + url: "--url Use another server, such as a deploy preview's", file: "-f, --file Read the tool's JSON input from a file", timeout: "--timeout How long to wait (default 30)", login: "--no-login Fail with exit code 3 instead of signing in", - browser: "--no-browser Print the sign-in link without opening a browser" + browser: "--no-browser Print the sign-in link, for a browser here or elsewhere" }; 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) { +function appTopics(app) { const name = (topic) => ({ ...topic, - usage: topic.usage.replace("", product.name) + usage: topic.usage.replace("", app.name) }); const topics = {}; - for (const [command, topic] of Object.entries(productCommands)) topics[`${product.name} ${command}`] = name(topic); - if (product.forms) { + for (const [command, topic] of Object.entries(appCommands)) topics[`${app.name} ${command}`] = name(topic); + if (app.forms) { const wait = name(formWait); - topics[`${product.name} form`] = wait; - topics[`${product.name} form wait`] = wait; + topics[`${app.name} form`] = wait; + topics[`${app.name} form wait`] = wait; } return topics; } +const serverLine = `Server: ${server.url} (${urlVariable} or --url overrides it)`; function rootHelp() { - const width = Math.max(...products.map((product) => product.name.length)) + 4; - return `Fulldev CLI: use Fulldev products from a terminal or an AI agent. + const width = Math.max(...apps.map((app) => app.name.length)) + 4; + return `Fulldev CLI: use Fulldev apps from a terminal or an AI agent, through the +Fulldev MCP server. One sign-in covers every app your organization gives you. Usage: - fulldev [options] + fulldev [options] + fulldev [options] + +Commands: ${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")} +Apps (their tools start with the app's name, such as cms_): +${apps.map((app) => ` ${pad(app.name, width)}${app.description}`).join("\n")} -Product commands: -${[...Object.values(productCommands), { +App commands, for one app's part: +${[...Object.values(appCommands), { ...formWait, - usage: formWait.usage.replace("", "cms") + 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 instructions first and follow them. Run fulldev help for a command's options, for example fulldev help cms call. +${serverLine} + Options: -h, --help Show help -v, --version Show the version ${footer}`; } -function productHelp(product) { - const topics = Object.values(productTopics(product)).filter((topic, index, all) => all.indexOf(topic) === index); - return `${product.title} +function appHelp(app) { + const topics = Object.values(appTopics(app)).filter((topic, index, all) => all.indexOf(topic) === index); + return `${app.title} -${product.description} +${app.description} -Server: ${product.url} (${urlVariable(product)} or --url overrides it) +Its tools start with ${app.name}_; after fulldev ${app.name} you may leave that out. Usage: ${topics.map((topic) => ` ${topic.usage}\n ${topic.summary}`).join("\n")} -Run fulldev ${product.name} instructions first and follow them. -Sign in with fulldev login ${product.name}. +Run fulldev ${app.name} instructions first and follow them. +Sign in with fulldev login, once for every app. + +${serverLine} ${footer}`; } @@ -556,11 +601,11 @@ 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 app = findApp(topic); + if (app) return appHelp(app); const [name] = topic.split(" "); - const owner = findProduct(name ?? ""); - const found = owner ? productTopics(owner)[topic] : void 0; + const owner = findApp(name ?? ""); + const found = owner ? appTopics(owner)[topic] : void 0; return found ? topicHelp(found) : void 0; } /** The longest leading words of `words` that name a help topic. */ @@ -626,54 +671,50 @@ function parseCommandLine(argv, env = process.env) { const extra = given.find((key) => !options.includes(key)); if (extra) throw new UsageError(`--${extra === "login" || extra === "browser" ? `no-${extra}` : extra} does not work with fulldev ${topic}.`, helpFor(topic)); }; + const target = resolveTarget(values.url, env); + const session = { + login: values.login ?? true, + browser: values.browser ?? true + }; if (first === "login" || first === "logout" || first === "status") { allow(first, rootCommands[first].options); - 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 (rest.length) throw new UsageError(`fulldev ${first} takes no app: one sign-in covers every app.`, helpFor(first)); if (first === "login") return { kind: "login", - targets, + target, browser: values.browser ?? true }; return { kind: first, - targets + target }; } - 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); + const app = findApp(first); + const command = app ? rest[0] : first; + const args = app ? rest.slice(1) : rest; + if (!app && !serverCommands.includes(first)) throw new UsageError(`Unknown command or app: ${first}`); + if (app && command === void 0) { + allow(app.name); return { kind: "help", - topic: product.name + topic: app.name }; } - const topic = `${product.name} ${command}`; - const known = productTopics(product)[topic]; - if (!known) throw new UsageError(`Unknown command: fulldev ${topic}`, helpFor(product.name)); + const topic = app ? `${app.name} ${command}` : first; + const known = app ? appTopics(app)[topic] : rootCommands[topic]; + if (!known) throw new UsageError(`Unknown command: fulldev ${topic}`, helpFor(app?.name ?? "")); allow(topic, known.options); const usage = (count) => { if (!count) throw new UsageError(`Usage: ${known.usage}`, helpFor(topic)); }; - 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 + session, + app }; case "tools": usage(args.length <= 1); @@ -681,6 +722,7 @@ function parseCommandLine(argv, env = process.env) { kind: "tools", target, session, + app, name: args[0] }; case "call": @@ -689,18 +731,19 @@ function parseCommandLine(argv, env = process.env) { kind: "call", target, session, - tool: args[0], + tool: app ? toolName(app, args[0]) : args[0], json: args[1], file: values.file }; default: { - usage(args[0] === "wait" && args.length === 3); + usage(app !== void 0 && args[0] === "wait" && args.length === 3); const timeoutMinutes = values.timeout === void 0 ? 30 : Number(values.timeout); if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) throw new UsageError("--timeout must be a number of minutes above 0.", helpFor(topic)); return { kind: "form-wait", target, session, + app, branchId: args[1], formId: args[2], timeoutMinutes @@ -741,7 +784,7 @@ function toolError(result) { } //#endregion //#region src/form-wait.ts -/** The one tool the CLI names itself. */ +/** The one tool the CLI names itself, without its app's prefix. */ const waitTool = "wait_for_form"; const maxFailures = 3; const retryMs = 5e3; @@ -766,7 +809,7 @@ function wait(ms, signal) { * 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 }) { +async function waitForForm(callTool, { branchId, formId, timeoutMs, app = "cms", progress = () => {}, now = Date.now, sleep = wait }) { const deadline = now() + timeoutMs; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -777,7 +820,7 @@ async function waitForForm(callTool, { branchId, formId, timeoutMs, product = "c for (let round = 1; now() < deadline && !aborted(); round++) { let result; try { - result = await callTool(waitTool, { + result = await callTool(`${app}_${waitTool}`, { branchId, formId }, signal); @@ -809,7 +852,7 @@ async function waitForForm(callTool, { branchId, formId, timeoutMs, product = "c 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.` + message: `Stopped waiting after ${minutes(timeoutMs)}. Run fulldev ${app} form wait ${branchId} ${formId} to keep waiting, or continue when the person says they are done.` } }; } @@ -844,24 +887,12 @@ async function readToolArguments({ json, file, readStdin = () => text(process.st //#endregion //#region src/oauth.ts /** -* 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. +* The Fulldev CLI's OAuth client id: the URL of its Client ID Metadata +* Document, served by full.dev from apps/website/public/oauth/cli.json. The +* CLI never registers a client, so an authorization server must support +* such documents. */ -const 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; -} +const clientMetadataUrl = "https://full.dev/oauth/cli.json"; /** The claims of a JWT, or {} for an opaque token. */ function claims(token) { try { @@ -889,17 +920,40 @@ 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) { +/** +* The scopes a sign-in asks for. The SDK would take the resource's +* `scopes_supported`, which leaves out `openid`, the authorization server's +* own scope; without it there is no ID token and so no email. +*/ +const signInScope = "openid profile email offline_access"; +/** The CLI as the client of an authorization server. */ +const cliClient = (issuer) => ({ + client_id: clientMetadataUrl, + issuer +}); +/** +* Saves tokens for a target, keeping only the email from the ID token. A +* sign-in also saves the client they were issued to, and its email only: +* it may be another person's. A refresh keeps the email it had. +*/ +async function saveTokens(store, target, tokens, issuer, client) { const { id_token: idToken, ...kept } = tokens; const { email } = claims(idToken); - await store.update(target.url, (current) => ({ - ...current, - issuer, - tokens: kept, - email: typeof email === "string" ? email : current.email, - savedAt: Date.now() - })); + await store.update(target.url, (current) => { + const next = { + ...current, + issuer, + ...client ? { client } : {}, + tokens: kept, + savedAt: Date.now() + }; + delete next.email; + const known = typeof email === "string" ? email : client ? void 0 : current.email; + return known ? { + ...next, + email: known + } : next; + }); } /** Finds the target's authorization server and checks its resource. */ async function discover(target, fetchFn) { @@ -920,13 +974,10 @@ async function discover(target, fetchFn) { }; } const callbackPath = "/callback"; -/** The redirect URIs a dynamically registered client was registered with. */ -function redirectUris(client) { - return client && "redirect_uris" in client ? client.redirect_uris : []; -} /** -* 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. +* The SDK's view of one browser sign-in: the metadata document as client, +* with PKCE S256 and a loopback redirect, on the server that discovery +* found. */ var BrowserLoginProvider = class { store; @@ -934,19 +985,21 @@ var BrowserLoginProvider = class { redirectUrl; loginState; onAuthorizationUrl; - verifier = ""; discovery; - constructor(store, target, redirectUrl, loginState, onAuthorizationUrl) { + verifier = ""; + constructor(store, target, redirectUrl, loginState, onAuthorizationUrl, discovery) { this.store = store; this.target = target; this.redirectUrl = redirectUrl; this.loginState = loginState; this.onAuthorizationUrl = onAuthorizationUrl; + this.discovery = discovery; } + /** Read by the SDK for the scope; the server reads the metadata document. */ get clientMetadata() { return { client_name: "Fulldev CLI", - client_uri: "https://github.com/fulldotdev/cli", + client_uri: "https://full.dev", redirect_uris: [this.redirectUrl], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], @@ -956,24 +1009,15 @@ var BrowserLoginProvider = class { state() { return this.loginState; } - 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, ctx) { - if (fixedClient(ctx?.issuer ?? client.issuer)) return; - await this.store.update(this.target.url, (current) => ({ - ...current, - client - })); + /** Always the metadata document, so the SDK never registers a client. */ + clientInformation(ctx) { + return cliClient(ctx?.issuer ?? ""); } /** A sign-in always asks again, so it never reuses stored tokens. */ tokens() {} async saveTokens(tokens, ctx) { - const issuer = ctx?.issuer ?? tokens.issuer; - await saveTokens(this.store, this.target, tokens, issuer ?? ""); + const issuer = ctx?.issuer ?? tokens.issuer ?? ""; + await saveTokens(this.store, this.target, tokens, issuer, cliClient(issuer)); } redirectToAuthorization(authorizationUrl) { this.onAuthorizationUrl(authorizationUrl); @@ -990,10 +1034,9 @@ var BrowserLoginProvider = class { discoveryState() { return this.discovery; } - async invalidateCredentials(scope) { + invalidateCredentials(scope) { if (scope === "verifier") this.verifier = ""; if (scope === "discovery" || scope === "all") this.discovery = void 0; - if (scope === "client" || scope === "all") await this.store.update(this.target.url, (current) => without(current, "client")); } }; const tile = ``; const page = (message) => `Fulldev CLI
${tile}

${message}

`; -/** Listens on 127.0.0.1 for the authorization server's redirect (RFC 8252). */ -async function listenForCallback(preferredPort) { +/** +* Listens on 127.0.0.1, on a port the system picks, for the authorization +* server's redirect (RFC 8252). +*/ +async function listenForCallback() { let receive = () => {}; const received = new Promise((resolve) => { receive = resolve; @@ -1017,38 +1063,66 @@ async function listenForCallback(preferredPort) { 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)); }); - const listen = (port) => new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(port, "127.0.0.1", () => { + server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); - try { - await listen(preferredPort ?? 0); - } catch (error) { - if (preferredPort === void 0) throw error; - await listen(0); - } return { port: server.address().port, - wait: async (timeoutMs) => { - let timer; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new CliError("SIGN_IN_TIMEOUT", `Sign-in timed out after ${Math.round(timeoutMs / 6e4)} minutes.`)), timeoutMs); - }); - try { - return await Promise.race([received, timeout]); - } finally { - clearTimeout(timer); - } - }, + received, close: () => { server.closeAllConnections(); server.close(); } }; } +/** The query of a pasted address, when it is the redirect after sign-in. */ +function redirectQuery(line) { + try { + const { searchParams } = new URL(line.trim()); + return searchParams.has("state") ? searchParams : void 0; + } catch { + return; + } +} +/** +* Reads the address of the page after sign-in, pasted into the terminal. +* With the browser on another computer, such as over SSH, the redirect +* cannot reach this one's loopback port, but the address bar still holds +* the code. Other lines are asked for again; at the end of the input, the +* loopback redirect can still arrive. +*/ +function readPastedRedirect(input, log) { + const lines = createInterface({ + input, + terminal: false + }); + return { + received: new Promise((resolve) => { + lines.on("line", (line) => { + const query = redirectQuery(line); + if (query) resolve(query); + else if (line.trim()) log("That is not the address of the page after sign-in. Paste the whole address from the browser's address bar."); + }); + }), + close: () => lines.close() + }; +} +/** The first of the promises to settle, or a timeout error. */ +async function firstWithin(timeoutMs, promises) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new CliError("SIGN_IN_TIMEOUT", `Sign-in timed out after ${Math.round(timeoutMs / 6e4)} minutes.`)), timeoutMs); + }); + try { + return await Promise.race([...promises, timeout]); + } finally { + clearTimeout(timer); + } +} /** Opens a URL in the system browser; the URL is printed as well. */ function openBrowser(url) { const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [ @@ -1069,24 +1143,38 @@ function openBrowser(url) { /** * 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. +* app's MCP URL as the resource. Without the browser, the address after +* sign-in can also be pasted, for a browser on another computer. */ -async function browserLogin(target, store, { browser, log, timeoutMs = 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); +async function browserLogin(target, store, { browser, log, timeoutMs = 3e5, fetchFn, input = process.stdin }) { + const discovery = await discover(target, fetchFn); + if (discovery.metadata.client_id_metadata_document_supported !== true) throw new CliError("CLIENT_METADATA_UNSUPPORTED", `The authorization server ${discovery.issuer} does not support Client ID Metadata Documents, which fulldev needs to sign in to ${target.title}.`); + const callback = await listenForCallback(); + const pasted = browser ? void 0 : readPastedRedirect(input, log); try { const state = randomBytes(16).toString("base64url"); const provider = new BrowserLoginProvider(store, target, `http://127.0.0.1:${callback.port}${callbackPath}`, state, (url) => { - log(`Sign in to ${target.title} in your browser. If it does not open, open:`); + if (browser) { + log(`Sign in to ${target.title} in your browser. If it does not open, open:`); + log(url.href); + openBrowser(url.href); + return; + } + log(`Open this link to sign in to ${target.title}:`); log(url.href); - if (browser) openBrowser(url.href); + log("If the browser is on another computer, the page after sign-in does not load: copy its address from the address bar and paste it here."); + }, { + authorizationServerUrl: String(discovery.authorizationServerUrl), + resourceMetadata: discovery.resourceMetadata, + authorizationServerMetadata: discovery.metadata }); if (await auth(provider, { serverUrl: target.url, + scope: signInScope, forceReauthorization: true, ...fetchFn ? { fetchFn } : {} }) === "AUTHORIZED") return; - const params = await callback.wait(timeoutMs); + const params = await firstWithin(timeoutMs, [callback.received, ...pasted ? [pasted.received] : []]); if (params.get("state") !== state) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: the response did not match this sign-in."); const error = params.get("error"); if (error) throw new CliError("SIGN_IN_FAILED", `Sign-in failed: ${[error, params.get("error_description")].filter(Boolean).join(": ")}`, void 0, { oauthError: error }); @@ -1094,12 +1182,14 @@ async function browserLogin(target, store, { browser, log, timeoutMs = 3e5, fetc if (!code) throw new CliError("SIGN_IN_FAILED", "Sign-in failed: no authorization code."); await auth(provider, { serverUrl: target.url, + scope: signInScope, authorizationCode: code, iss: params.get("iss") ?? void 0, ...fetchFn ? { fetchFn } : {} }); } finally { callback.close(); + pasted?.close(); } } const formHeaders = { @@ -1125,7 +1215,7 @@ async function refreshTokens(target, store, fetchFn) { const refreshToken = credentials.tokens?.refresh_token; if (!refreshToken) throw new SignInRequiredError(target); const discovery = await discover(target, fetchFn); - const client = clientFor(discovery.issuer, credentials); + const { client } = credentials; if (!client || credentials.issuer && credentials.issuer !== discovery.issuer) throw new SignInRequiredError(target); let tokens; try { @@ -1150,20 +1240,18 @@ async function refreshTokens(target, store, fetchFn) { * client. Never throws: the result says what failed. */ async function revokeTokens(target, credentials, fetchFn = fetch) { - const { tokens } = credentials; + const { tokens, client } = credentials; if (!tokens) return { revoked: true }; + if (!client) return { + revoked: false, + error: "The sign-in is from an earlier version of fulldev and cannot be revoked." + }; try { - const metadata = credentials.issuer ? await discoverAuthorizationServerMetadata(credentials.issuer, { fetchFn }) : (await discover(target, fetchFn)).metadata; - const endpoint = metadata?.revocation_endpoint; + const endpoint = (credentials.issuer ? await discoverAuthorizationServerMetadata(credentials.issuer, { fetchFn }) : (await discover(target, fetchFn)).metadata)?.revocation_endpoint; if (!endpoint) return { revoked: false, error: "The authorization server has no revocation endpoint." }; - const 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; @@ -1195,7 +1283,7 @@ async function revokeTokens(target, credentials, fetchFn = fetch) { } } /** -* Gives the MCP transport the stored access token of one product, refreshing +* Gives the MCP transport the stored access token of one app, refreshing * it when it is about to expire or the server answers 401. Refreshes happen * under the store's lock, and a token another process refreshed meanwhile is * used instead of refreshing again. It never starts a sign-in. @@ -1291,12 +1379,10 @@ async function listTools(client) { } while (cursor); return tools; } -async function productStatus(target, io) { +/** The sign-in, and whether the server still accepts it. */ +async function signInStatus(target, io) { const { credentials, storage } = await io.store.read(target.url); - const base = { - product: target.name, - server: target.url - }; + const base = { server: target.url }; if (!credentials.tokens) return { ...base, signedIn: false @@ -1326,11 +1412,11 @@ async function productStatus(target, io) { storage }; } -async function status(targets, io) { - const results = []; - for (const target of targets) results.push(await productStatus(target, io)); +async function status(target, io) { + const result = await signInStatus(target, io); const storage = await io.store.storage(); io.stdout(json({ + ...result, storage: storage === "keychain" ? { kind: "keychain", service: "fulldev" @@ -1338,36 +1424,41 @@ async function status(targets, io) { 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; + if (!result.signedIn || "valid" in result && result.valid === false) return exitCodes.signIn; + return "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 } : {} - }); +async function logout(target, io) { + const credentials = await io.store.get(target.url); + if (!credentials.tokens && !credentials.client) { + io.stdout(json({ + server: target.url, + signedIn: false + })); + return exitCodes.ok; } - io.stdout(json({ products: results })); + 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, () => ({})); + io.stdout(json({ + server: target.url, + signedIn: false, + revoked: revocation.revoked, + ...revocation.error ? { revocationError: revocation.error } : {} + })); return exitCodes.ok; } +/** +* The part of the server's instructions for one app: the overview before +* the first app, and the app's own, under its heading `## (<app>_)`. +*/ +function instructionsFor(text, app) { + const [overview = "", ...sections] = text.split(/\n(?=## )/); + const own = sections.find((section) => section.split("\n")[0].endsWith(`(${app.name}_)`)); + if (!own) throw new CliError("APP_NOT_AVAILABLE", `${app.title} is not among your apps in this organization. Run fulldev instructions for the apps you may use, or fulldev login to choose another organization.`); + return `${overview.trimEnd()}\n\n${own.trim()}`; +} async function run(command, io) { const log = (line) => io.stderr(`${line}\n`); switch (command.kind) { @@ -1378,40 +1469,32 @@ async function run(command, io) { 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.`); - } switch (command.kind) { - case "login": - 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}.`); + case "login": { + const { target } = command; + 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}`); } - return status(command.targets, io); - case "logout": return logout(command.targets, io); - case "status": return status(command.targets, io); + log(`Signed in to ${target.title}.`); + return status(target, io); + } + case "logout": return logout(command.target, io); + case "status": return status(command.target, io); case "instructions": { 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`); + else io.stdout(`${command.app ? instructionsFor(text, command.app) : text}\n`); return exitCodes.ok; } case "tools": { - const tools = await withClient(command.target, command.session, io, listTools); + const { app } = command; + const tools = (await withClient(command.target, command.session, io, listTools)).filter((tool) => !app || tool.name.startsWith(`${app.name}_`)); if (!command.name) { io.stdout(json(tools.map((tool) => ({ name: tool.name, @@ -1420,8 +1503,9 @@ async function run(command, io) { })))); return exitCodes.ok; } - 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.`); + const name = app ? toolName(app, command.name) : command.name; + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) throw new CliError("NOT_FOUND", `You have no tool named ${name}. Run fulldev ${app ? `${app.name} ` : ""}tools for the list.`); io.stdout(json({ name: tool.name, title: tool.title ?? tool.annotations?.title, @@ -1459,7 +1543,7 @@ async function run(command, io) { branchId: command.branchId, formId: command.formId, timeoutMs: command.timeoutMinutes * 6e4, - product: command.target.name, + app: command.app.name, progress: log })); if (outcome.status === "error") { diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index e59a27e..7ab3816 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -1,75 +1,80 @@ --- name: fulldev -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. Use sites when they want Fulldev to make a new website from a brief, only for Fulldev administrators for now. +description: Use the Fulldev CLI to work with Fulldev apps for the person. Use cms when they want to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. Use connect when they want to use their business tools, such as Shopify, through Fulldev Connect. Use scan to scan websites, only for Fulldev administrators. Use sites when they want Fulldev to make a new website from a brief, only for Fulldev administrators for now. Use pages to publish a report or plan as a clear web page, only for Fulldev administrators for now. Use contacts when they want to check, pause or resume the daily copy of their Google contacts with Fulldev Contacts, or answer its look-alike questions. license: MIT -compatibility: Needs Node.js 24 or later and network access to cms.full.dev, connect.full.dev, scan.full.dev, sites.full.dev and clerk.full.dev. +compatibility: Needs Node.js 24 or later and network access to app.full.dev. --- # Fulldev -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. +The `fulldev` CLI talks to the Fulldev MCP server, which has the tools of every Fulldev app the person may use. Each app's tools start with its name, such as `cms_`. 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. | -| `sites` | Making a new website with Fulldev Sites from a brief and design options. Only for Fulldev administrators for now. | +| App | 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. | +| `sites` | Making a new website with Fulldev Sites from a brief and design options. Only for Fulldev administrators for now. | +| `pages` | Publishing a report or plan as a web page with Fulldev Pages. Only for Fulldev administrators for now. | +| `contacts` | The person's daily copy of Google contacts from one of their Google accounts to another: its runs, look-alike questions, pausing and resuming. | ## Run Use `fulldev` when it is installed. Otherwise run every command as `npx -y fulldev <command>`. -Every product has the same commands: +Every app has the same commands, which work on that app's part: ```sh -fulldev <product> instructions # read first -fulldev <product> tools # list the tools -fulldev <product> tools <name> # one tool: description, input and output schema -fulldev <product> call <tool> '<json>' # call a tool +fulldev <app> instructions # read first +fulldev <app> tools # list the app's tools +fulldev <app> tools <name> # one tool: description, input and output schema +fulldev <app> call <tool> '<json>' # call a tool; the app's prefix may be left out ``` +`fulldev instructions` and `fulldev tools` show every app the person may use; `fulldev call <tool>` takes the full name, such as `cms_list_repositories`. + 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 <product>` to run | -| 64 | The command was called wrongly; the error names the help to read | +| Code | Meaning | +| ---- | ---------------------------------------------------------------- | +| 0 | Done | +| 1 | Error, from the tool or the CLI | +| 2 | `form wait` stopped waiting | +| 3 | Sign-in needed; run `fulldev login` | +| 64 | The command was called wrongly; the error names the help to read | -`fulldev --help`, `fulldev <product> --help` and `fulldev <product> <command> --help` explain every command. +`fulldev --help`, `fulldev <app> --help` and `fulldev <app> <command> --help` explain every command. ## Sign in -The person signs in once per product with their Fulldev account and picks an organization: +The person signs in once, for every app, with their Fulldev account on app.full.dev, with a code sent to their email or with Google, and picks an organization when they are in more than one: ```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 +fulldev login # opens the browser +fulldev status # signed in, still valid, email, organization, expiry +fulldev logout # 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 <product>` from the error and ask the person to finish it. The organization is chosen at sign-in; to switch organization, run `fulldev login <product>` again. +When the person's browser is on another computer, as when you run on a server over SSH, the page after sign-in does not load. Then the person runs `fulldev login --no-browser` in a terminal on this computer, opens the link, and pastes the address of that last page into the terminal. + +Without a terminal, as when you run commands, a command never starts a sign-in: it exits with 3 at once. Then run `fulldev login` 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 <product> 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. +Run `fulldev <app> 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. -Read `fulldev <product> tools <name>` before the first call to a tool. +Read `fulldev <app> tools <name>` before the first call to a tool. ## Call tools Give a tool's input as a JSON argument, from a file with `--file`, or from stdin with `-`. Use stdin or `--file` for long or quoted text, so the shell does not change it. ```sh -fulldev cms call list_projects +fulldev cms call list_repositories fulldev cms call read_file '{"branchId":"<id>","path":"src/content/home.md"}' fulldev cms call commit_files - <<'JSON' {"branchId":"<id>","expectedRevision":"<revision>","changes":[{"path":"src/content/home.md","edits":[{"oldText":"Open 9 to 5","newText":"Open 8 to 6"}]}]} diff --git a/src/apps.test.ts b/src/apps.test.ts new file mode 100644 index 0000000..dea538e --- /dev/null +++ b/src/apps.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test" + +import { parseCommandLine } from "./args.ts" +import { UsageError } from "./errors.ts" +import { apps, findApp, resolveTarget, server, toolName } from "./apps.ts" + +describe("apps", () => { + it("has cms, connect, scan, sites, pages and contacts on the one server", () => { + expect(apps.map(({ name }) => name)).toEqual([ + "cms", + "connect", + "scan", + "sites", + "pages", + "contacts", + ]) + expect(server.url).toBe("https://app.full.dev/mcp") + for (const app of apps) { + expect(app.title).toMatch(/^Fulldev /) + expect(app.description).not.toContain("\n") + } + }) + + it("gives a tool the app's prefix unless it has it", () => { + const cms = findApp("cms")! + expect(toolName(cms, "list_repositories")).toBe("cms_list_repositories") + expect(toolName(cms, "cms_list_repositories")).toBe("cms_list_repositories") + }) + + it("takes the server from --url, then FULLDEV_URL, then app.full.dev", () => { + const preview = "https://deploy-preview-1--fulldev-app.netlify.app/mcp" + const other = "https://other.example.com/mcp" + expect(resolveTarget(undefined, {})).toEqual({ + title: "Fulldev", + url: server.url, + urlFromFlag: false, + }) + expect(resolveTarget(undefined, { FULLDEV_URL: preview })).toMatchObject({ + url: preview, + urlFromFlag: false, + }) + expect(resolveTarget(other, { FULLDEV_URL: preview })).toMatchObject({ + url: other, + urlFromFlag: true, + }) + expect( + parseCommandLine(["cms", "tools"], { FULLDEV_URL: preview }), + ).toMatchObject({ target: { url: preview } }) + }) + + it("rejects a URL that is not http or https", () => { + expect(() => resolveTarget("file:///etc/passwd", {})).toThrow(UsageError) + expect(() => resolveTarget(undefined, { FULLDEV_URL: "nope" })).toThrow( + UsageError, + ) + }) +}) diff --git a/src/apps.ts b/src/apps.ts new file mode 100644 index 0000000..b2d5090 --- /dev/null +++ b/src/apps.ts @@ -0,0 +1,102 @@ +import { UsageError } from "./errors.ts" + +/** The Fulldev MCP server: the tools of every app the person may use. */ +export const server = { + title: "Fulldev", + url: "https://app.full.dev/mcp", +} + +/** The environment variable that overrides the server's URL. */ +export const urlVariable = "FULLDEV_URL" + +/** + * A Fulldev app on the server. Its tools start with its name, such as + * `cms_list_repositories`. Add an app with one entry. + */ +export interface App { + name: string + title: string + description: string + /** Has the cms_wait_for_form tool, so `fulldev <app> form wait` exists. */ + forms?: boolean +} + +export const apps: Array<App> = [ + { + name: "cms", + title: "Fulldev CMS", + description: + "Edit your website: text, pages, images and settings, through a pull request.", + forms: true, + }, + { + name: "connect", + title: "Fulldev Connect", + description: + "Use your organization's business tools, such as Shopify, with the access Fulldev grants you.", + }, + { + name: "scan", + title: "Fulldev Scan", + description: "Scan whole websites for problems. For administrators only.", + }, + { + name: "sites", + title: "Fulldev Sites", + description: + "Have Fulldev make a finished website from a brief and tested design options. For administrators only for now.", + }, + { + name: "pages", + title: "Fulldev Pages", + description: + "Publish reports and plans as clear web pages, composed from ready-made blocks. For administrators only for now.", + }, + { + name: "contacts", + title: "Fulldev Contacts", + description: + "Look after the daily copy of your Google contacts from one Google account to another: its runs, look-alike questions, pausing and resuming.", + }, +] + +/** The server this run uses. */ +export interface Target { + title: string + url: string + /** True when --url chose the server, so commands must repeat it. */ + urlFromFlag: boolean +} + +export function findApp(name: string): App | undefined { + return apps.find((app) => app.name === name) +} + +/** + * A tool's name on the server: an app's tools start with the app's name, + * which may be left out after `fulldev <app>`. + */ +export function toolName(app: App, name: string) { + return name.startsWith(`${app.name}_`) ? name : `${app.name}_${name}` +} + +/** The server: --url, then FULLDEV_URL, then app.full.dev. */ +export function resolveTarget( + flagUrl: string | undefined, + env: NodeJS.ProcessEnv, +): Target { + const url = flagUrl ?? env[urlVariable] ?? server.url + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new UsageError(`Not a valid server URL: ${url}`) + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") + throw new UsageError(`The server URL must use https: ${url}`) + return { + title: server.title, + url: parsed.href, + urlFromFlag: flagUrl !== undefined, + } +} diff --git a/src/args.test.ts b/src/args.test.ts index f62f9be..cb29626 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -2,30 +2,52 @@ import { describe, expect, it } from "vite-plus/test" import { helpText, parseCommandLine } from "./args.ts" import { UsageError } from "./errors.ts" -import { products } from "./products.ts" +import { apps } from "./apps.ts" describe("parseCommandLine", () => { - it("reads product commands with the product's default server", () => { - expect(parseCommandLine(["cms", "tools"], {})).toMatchObject({ + it("reads commands for every app and for one, on the one server", () => { + const target = { url: "https://app.full.dev/mcp" } + expect(parseCommandLine(["tools"], {})).toMatchObject({ kind: "tools", - target: { name: "cms", url: "https://cms.full.dev/mcp" }, + app: undefined, + target, session: { login: true, browser: true }, }) + expect(parseCommandLine(["cms", "tools"], {})).toMatchObject({ + kind: "tools", + app: { name: "cms" }, + target, + }) expect(parseCommandLine(["connect", "tools", "x"], {})).toMatchObject({ kind: "tools", name: "x", - target: { name: "connect", url: "https://connect.full.dev/mcp" }, + app: { name: "connect" }, }) expect(parseCommandLine(["scan", "instructions"], {})).toMatchObject({ kind: "instructions", - target: { url: "https://scan.full.dev/mcp" }, + app: { name: "scan" }, + }) + expect(parseCommandLine(["instructions"], {})).toMatchObject({ + kind: "instructions", + app: undefined, }) }) + it("names a tool in full, also after its app without the prefix", () => { + expect(parseCommandLine(["call", "cms_read_file"], {})).toMatchObject({ + kind: "call", + tool: "cms_read_file", + }) + for (const name of ["read_file", "cms_read_file"]) + expect(parseCommandLine(["cms", "call", name], {})).toMatchObject({ + tool: "cms_read_file", + }) + }) + it("reads call input from the argument, stdin and --file", () => { expect( parseCommandLine(["cms", "call", "read_file", "-"], {}), - ).toMatchObject({ kind: "call", tool: "read_file", json: "-" }) + ).toMatchObject({ kind: "call", tool: "cms_read_file", json: "-" }) expect( parseCommandLine(["cms", "call", "read_file", "-f", "in.json"], {}), ).toMatchObject({ file: "in.json" }) @@ -41,6 +63,7 @@ describe("parseCommandLine", () => { parseCommandLine(["cms", "form", "wait", "b", "f", "--timeout", "5"], {}), ).toMatchObject({ kind: "form-wait", + app: { name: "cms" }, branchId: "b", formId: "f", timeoutMinutes: 5, @@ -50,24 +73,24 @@ describe("parseCommandLine", () => { ).toMatchObject({ timeoutMinutes: 30 }) }) - 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, + it("signs in, out and shows the status once, for every app", () => { + for (const kind of ["login", "logout", "status"] as const) + expect(parseCommandLine([kind], {})).toMatchObject({ + kind, + target: { title: "Fulldev", url: "https://app.full.dev/mcp" }, + }) + expect(parseCommandLine(["login"], {})).toMatchObject({ browser: true }) + expect(parseCommandLine(["login", "--no-browser"], {})).toMatchObject({ + browser: false, }) expect( - parseCommandLine(["login", "connect", "--no-browser"], {}), - ).toMatchObject({ browser: false }) + parseCommandLine(["login", "--url", "https://p.example/mcp"], {}), + ).toMatchObject({ + target: { url: "https://p.example/mcp", urlFromFlag: true }, + }) }) - it("turns off sign-in and the browser for product commands", () => { + it("turns off sign-in and the browser for app commands", () => { expect( parseCommandLine(["cms", "tools", "--no-login", "--no-browser"], {}), ).toMatchObject({ session: { login: false, browser: false } }) @@ -107,8 +130,10 @@ describe("parseCommandLine", () => { it.each([ [["frobnicate"]], - [["tools"]], - [["call", "x"]], + [["call"]], + [["tools", "a", "b"]], + [["instructions", "a"]], + [["form", "wait", "a", "b"]], [["cms", "frobnicate"]], [["cms", "call"]], [["cms", "call", "a", "b", "c"]], @@ -124,11 +149,10 @@ describe("parseCommandLine", () => { [["cms", "form", "wait", "a", "b", "--timeout", "soon"]], [["cms", "tools", "--device"]], [["cms", "tools", "--unknown"]], - [["login", "nope"]], + [["login", "cms"]], + [["status", "cms"]], [["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"]], @@ -150,11 +174,13 @@ describe("parseCommandLine", () => { }) describe("helpText", () => { - it("lists every product and the exit codes at the root", () => { + it("lists every app, the server and the exit codes at the root", () => { const text = helpText("")! - for (const product of products) expect(text).toContain(product.name) + for (const app of apps) expect(text).toContain(app.name) expect(text).toContain("64 usage error") expect(text).toContain("fulldev cms form wait") + expect(text).toContain("fulldev tools [name]") + expect(text).toContain("https://app.full.dev/mcp (FULLDEV_URL") }) it("shows form wait only for cms", () => { @@ -178,11 +204,14 @@ describe("helpText", () => { "logout", "status", "help", - ...products.flatMap((product) => [ - product.name, - `${product.name} tools`, - `${product.name} call`, - `${product.name} instructions`, + "instructions", + "tools", + "call", + ...apps.flatMap((app) => [ + app.name, + `${app.name} tools`, + `${app.name} call`, + `${app.name} instructions`, ]), "cms form wait", ] diff --git a/src/args.ts b/src/args.ts index 375e495..dac3976 100644 --- a/src/args.ts +++ b/src/args.ts @@ -2,25 +2,36 @@ import { parseArgs } from "node:util" import { UsageError } from "./errors.ts" import { - findProduct, - products, + apps, + findApp, resolveTarget, + server, + toolName, urlVariable, -} from "./products.ts" -import type { Product, Target } from "./products.ts" +} from "./apps.ts" +import type { App, Target } from "./apps.ts" export type Command = | { kind: "help"; topic: string } | { kind: "version" } - | { kind: "login"; targets: Array<Target>; browser: boolean } - | { kind: "logout"; targets: Array<Target> } - | { kind: "status"; targets: Array<Target> } - | { kind: "instructions"; target: Target; session: Session } - | { kind: "tools"; target: Target; session: Session; name?: string } + | { kind: "login"; target: Target; browser: boolean } + | { kind: "logout"; target: Target } + | { kind: "status"; target: Target } + /** With an app: the overview and that app's own instructions. */ + | { kind: "instructions"; target: Target; session: Session; app?: App } + /** With an app: only its tools; `name` may leave out its prefix. */ + | { + kind: "tools" + target: Target + session: Session + app?: App + name?: string + } | { kind: "call" target: Target session: Session + /** The tool's full name on the server. */ tool: string json?: string file?: string @@ -29,12 +40,13 @@ export type Command = kind: "form-wait" target: Target session: Session + app: App branchId: string formId: string timeoutMinutes: number } -/** How a product command may sign in when it needs to. */ +/** How a command may sign in when it needs to. */ export interface Session { login: boolean browser: boolean @@ -51,32 +63,59 @@ interface Topic { const rootCommands: Record<string, Topic> = { login: { - usage: "fulldev login [product...] [--no-browser] [--url <mcp url>]", - summary: "Sign in (all products by default)", + usage: "fulldev login [--no-browser] [--url <mcp url>]", + summary: "Sign in, once for every app", 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 <product> -again to switch that product to another organization.`, + details: `Signs in to the Fulldev MCP server in your browser, where you choose your +organization when you are in more than one. One sign-in covers every app +the organization gives you. Run fulldev login again to switch organization. + +With --no-browser, open the link in a browser on any computer. When that is +another computer, as over SSH, the page after sign-in does not load: copy its +address from the address bar and paste it into this terminal.`, }, logout: { - usage: "fulldev logout [product...] [--url <mcp url>]", - summary: "Sign out and revoke the tokens (all products by default)", + usage: "fulldev logout [--url <mcp url>]", + summary: "Sign out and revoke the tokens", 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.`, + details: `Revokes the refresh token at the authorization server and deletes the +tokens from this computer. The access token cannot be revoked and expires +within 15 minutes. When revoking fails, the local sign-out still happens and +the result says so.`, }, status: { - usage: "fulldev status [product...] [--url <mcp url>]", - summary: "Show the sign-in of each product", + usage: "fulldev status [--url <mcp url>]", + summary: "Show the sign-in", 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.`, + details: `Prints 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 a sign-in is needed, and with 1 when the +server could not be reached to check it.`, + }, + instructions: { + usage: "fulldev instructions", + summary: + "The instructions for agents, of every app you may use; read first", + options: ["url", "login", "browser"], + details: `Prints the instructions the server sends, as plain text: an overview, then +each app's own, under its name. fulldev <app> instructions prints the +overview and that app's part.`, + }, + tools: { + usage: "fulldev tools [name]", + summary: "List the tools of every app you may use, or show one tool", + options: ["url", "login", "browser"], + details: `Without a name: the name, title and first line of the description of each +tool. With a name: its description, input schema, output schema and +annotations. Each app's tools start with its name, such as cms_.`, + }, + call: { + usage: "fulldev call <tool> [json | -] [--file <path>]", + 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.`, }, help: { usage: "fulldev help [command...]", @@ -84,48 +123,52 @@ product's signedIn and valid to see which one needs fulldev login.`, }, } -const productCommands: Record<string, Topic> = { +/** The commands that use the server, also per app. */ +const serverCommands = ["instructions", "tools", "call"] as const + +const appCommands: Record<string, Topic> = { instructions: { - usage: "fulldev <product> instructions", - summary: "The product's instructions for agents; read them first", + usage: "fulldev <app> instructions", + summary: "The overview and the app's own instructions; read first", options: ["url", "login", "browser"], - details: "Prints the instructions the server sends, as plain text.", + details: "Prints them as plain text, as the server sends them.", }, tools: { - usage: "fulldev <product> tools [name]", - summary: "List the tools, or show one tool", + usage: "fulldev <app> tools [name]", + summary: "List the app's tools, or show one tool", options: ["url", "login", "browser"], details: `Without a name: the name, title and first line of the description of each -tool. With a name: its description, input schema, output schema and -annotations.`, +of the app's tools. With a name, with or without the app's prefix: its +description, input schema, output schema and annotations.`, }, call: { - usage: "fulldev <product> call <tool> [json | -] [--file <path>]", - summary: "Call a tool with a JSON object", + usage: "fulldev <app> call <tool> [json | -] [--file <path>]", + summary: "Call one of the app's tools 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.`, + details: `The tool's name may leave out the app's prefix: fulldev cms call +list_repositories calls cms_list_repositories. Its input is a JSON object: +the argument, a file with --file, or stdin with -. Without input it sends +{}. Prints the tool's result as JSON on stdout. When the tool fails, prints +its error on stderr and exits with 1.`, }, } const formWait: Topic = { - usage: - "fulldev <product> form wait <branchId> <formId> [--timeout <minutes>]", + usage: "fulldev <app> form wait <branchId> <formId> [--timeout <minutes>]", summary: "Wait until the person sends a form", options: ["timeout", "url", "login", "browser"], - 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.`, + details: `Calls cms_wait_for_form until the person sends the form, with a progress +line on stderr each round, then prints the result. After --timeout minutes +(default 30) it prints the form id and exits with 2, so you can run it again.`, } const optionHelp: Record<Option, string> = { - url: "--url <mcp url> Use another server for the product, such as a deploy preview", + url: "--url <mcp url> Use another server, such as a deploy preview's", file: "-f, --file <path> Read the tool's JSON input from a file", timeout: "--timeout <minutes> 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", + "--no-browser Print the sign-in link, for a browser here or elsewhere", } const footer = `Data is JSON on stdout; progress lines go to stderr. Every error is one JSON @@ -135,47 +178,55 @@ Exit codes: 0 ok, 1 error, 2 form wait timed out, 3 sign-in needed, 64 usage err const pad = (text: string, width: number) => text.padEnd(width) -function productTopics(product: Product): Record<string, Topic> { +function appTopics(app: App): Record<string, Topic> { const name = (topic: Topic): Topic => ({ ...topic, - usage: topic.usage.replace("<product>", product.name), + usage: topic.usage.replace("<app>", app.name), }) const topics: Record<string, Topic> = {} - for (const [command, topic] of Object.entries(productCommands)) - topics[`${product.name} ${command}`] = name(topic) - if (product.forms) { + for (const [command, topic] of Object.entries(appCommands)) + topics[`${app.name} ${command}`] = name(topic) + if (app.forms) { const wait = name(formWait) - topics[`${product.name} form`] = wait - topics[`${product.name} form wait`] = wait + topics[`${app.name} form`] = wait + topics[`${app.name} form wait`] = wait } return topics } +const serverLine = `Server: ${server.url} (${urlVariable} or --url overrides it)` + function rootHelp() { - const width = Math.max(...products.map((product) => product.name.length)) + 4 - return `Fulldev CLI: use Fulldev products from a terminal or an AI agent. + const width = Math.max(...apps.map((app) => app.name.length)) + 4 + return `Fulldev CLI: use Fulldev apps from a terminal or an AI agent, through the +Fulldev MCP server. One sign-in covers every app your organization gives you. Usage: - fulldev <product> <command> [options] + fulldev <command> [options] + fulldev <app> <command> [options] + +Commands: ${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")} +Apps (their tools start with the app's name, such as cms_): +${apps.map((app) => ` ${pad(app.name, width)}${app.description}`).join("\n")} -Product commands: +App commands, for one app's part: ${[ - ...Object.values(productCommands), - { ...formWait, usage: formWait.usage.replace("<product>", "cms") }, + ...Object.values(appCommands), + { ...formWait, usage: formWait.usage.replace("<app>", "cms") }, ] .map((topic) => ` ${topic.usage.split(" [--")[0]}\n ${topic.summary}`) .join("\n")} -Run fulldev <product> instructions first and follow them. +Run fulldev instructions first and follow them. Run fulldev help <command...> for a command's options, for example fulldev help cms call. +${serverLine} + Options: -h, --help Show help -v, --version Show the version @@ -183,21 +234,23 @@ Options: ${footer}` } -function productHelp(product: Product) { - const topics = Object.values(productTopics(product)).filter( +function appHelp(app: App) { + const topics = Object.values(appTopics(app)).filter( (topic, index, all) => all.indexOf(topic) === index, ) - return `${product.title} + return `${app.title} -${product.description} +${app.description} -Server: ${product.url} (${urlVariable(product)} or --url overrides it) +Its tools start with ${app.name}_; after fulldev ${app.name} you may leave that out. Usage: ${topics.map((topic) => ` ${topic.usage}\n ${topic.summary}`).join("\n")} -Run fulldev ${product.name} instructions first and follow them. -Sign in with fulldev login ${product.name}. +Run fulldev ${app.name} instructions first and follow them. +Sign in with fulldev login, once for every app. + +${serverLine} ${footer}` } @@ -216,11 +269,11 @@ export function helpText(topic: string): string | undefined { if (topic === "") return rootHelp() const root = rootCommands[topic] if (root) return topicHelp(root) - const product = findProduct(topic) - if (product) return productHelp(product) + const app = findApp(topic) + if (app) return appHelp(app) const [name] = topic.split(" ") - const owner = findProduct(name ?? "") - const found = owner ? productTopics(owner)[topic] : undefined + const owner = findApp(name ?? "") + const found = owner ? appTopics(owner)[topic] : undefined return found ? topicHelp(found) : undefined } @@ -288,75 +341,64 @@ export function parseCommandLine( ) } + const target = resolveTarget(values.url, env) + const session: Session = { + login: values.login ?? true, + browser: values.browser ?? true, + } + 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 !== undefined && named.length !== 1) + if (rest.length) throw new UsageError( - `--url needs exactly one product, for example fulldev ${first} cms --url <mcp url>.`, + `fulldev ${first} takes no app: one sign-in covers every app.`, 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 } + return { kind: "login", target, browser: values.browser ?? true } + return { kind: first, target } } - const product = findProduct(first) - if (!product) throw new UsageError(`Unknown command or product: ${first}`) - const [command, ...args] = rest - if (command === undefined) { - allow(product.name) - return { kind: "help", topic: product.name } + const app = findApp(first) + const command = app ? rest[0] : first + const args = app ? rest.slice(1) : rest + if (!app && !(serverCommands as ReadonlyArray<string>).includes(first)) + throw new UsageError(`Unknown command or app: ${first}`) + if (app && command === undefined) { + allow(app.name) + return { kind: "help", topic: app.name } } - const topic = `${product.name} ${command}` - const topics = productTopics(product) - const known = topics[topic] + const topic = app ? `${app.name} ${command}` : first + const known = app ? appTopics(app)[topic] : rootCommands[topic] if (!known) throw new UsageError( `Unknown command: fulldev ${topic}`, - helpFor(product.name), + helpFor(app?.name ?? ""), ) allow(topic, known.options) const usage = (count: boolean) => { if (!count) throw new UsageError(`Usage: ${known.usage}`, helpFor(topic)) } - const target = resolveTarget(product, values.url, env) - const session: Session = { - login: values.login ?? true, - browser: values.browser ?? true, - } switch (command) { case "instructions": usage(args.length === 0) - return { kind: "instructions", target, session } + return { kind: "instructions", target, session, app } case "tools": usage(args.length <= 1) - return { kind: "tools", target, session, name: args[0] } + return { kind: "tools", target, session, app, name: args[0] } case "call": usage(args.length === 1 || args.length === 2) return { kind: "call", target, session, - tool: args[0]!, + tool: app ? toolName(app, args[0]!) : args[0]!, json: args[1], file: values.file, } default: { - usage(args[0] === "wait" && args.length === 3) + usage(app !== undefined && args[0] === "wait" && args.length === 3) const timeoutMinutes = values.timeout === undefined ? 30 : Number(values.timeout) if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) @@ -368,6 +410,7 @@ export function parseCommandLine( kind: "form-wait", target, session, + app: app!, branchId: args[1]!, formId: args[2]!, timeoutMinutes, diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 9abf36e..c3345b0 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -97,7 +97,7 @@ describe("CredentialStore", () => { expect(keychainService).toBe("fulldev") }) - it("keeps one keychain entry per product server and no file", async () => { + it("keeps one keychain entry per app server and no file", async () => { const { secrets, keychain } = memoryKeychain() const store = new CredentialStore(await directory(), async () => keychain) await store.update(cms, () => ({ tokens, email: "a@example.com" })) @@ -164,30 +164,6 @@ describe("CredentialStore", () => { expect((await store.read(cms)).storage).toBe("keychain") expect(await exists(store.filePath)).toBe(false) }) - - it("removes the 0.1.0 credentials file once and reports who was signed in", async () => { - const dir = await directory() - const { keychain } = memoryKeychain() - const store = new CredentialStore(dir, async () => keychain) - await writeFile( - store.legacyPath, - JSON.stringify({ - servers: { - [cms]: { client: { client_id: "dcr" }, tokens, savedAt: 1 }, - "https://preview.example.com/mcp": { client: { client_id: "p" } }, - }, - }), - ) - expect(await store.migrate()).toEqual([ - { - url: cms, - credentials: { client: { client_id: "dcr" }, tokens, savedAt: 1 }, - }, - ]) - expect(await exists(store.legacyPath)).toBe(false) - expect(await store.get(cms)).toEqual({}) - expect(await store.migrate()).toEqual([]) - }) }) describe("withLock", () => { diff --git a/src/credentials.ts b/src/credentials.ts index e765616..441e377 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -19,11 +19,15 @@ import type { import { CliError } from "./errors.ts" -/** What the CLI keeps for one product server, keyed by its MCP URL. */ +/** What the CLI keeps for one app server, keyed by its MCP URL. */ export interface Credentials { /** The authorization server that issued the tokens. */ issuer?: string - /** A dynamically registered client, only for an issuer without a Fulldev client id. */ + /** + * The client the tokens were issued to: the CLI's metadata document URL. + * A sign-in of fulldev 0.3 and earlier has none, or a client registered + * with Clerk. + */ client?: StoredOAuthClientInformation /** The tokens, without the ID token, which is only read for the email. */ tokens?: StoredOAuthTokens @@ -53,7 +57,7 @@ export interface Keychain { export const keychainService = "fulldev" /** The OS keychain through @napi-rs/keyring; rejects when it cannot load. */ -export async function openKeychain(): Promise<Keychain> { +async function openKeychain(): Promise<Keychain> { const { AsyncEntry } = await import("@napi-rs/keyring") const entry = (account: string) => new AsyncEntry(keychainService, account) return { @@ -137,15 +141,13 @@ const isEmpty = (credentials: Credentials) => Object.keys(credentials).length === 0 /** - * Keeps credentials per product server in the OS keychain (service "fulldev", + * Keeps credentials per app 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. */ export class CredentialStore { readonly filePath: string readonly lockPath: string - /** The 0.1.0 file, removed once by migrate(). */ - readonly legacyPath: string /** Why the keychain is not used, once it failed. */ keychainError: string | undefined private keychain: Promise<Keychain | undefined> | undefined @@ -156,7 +158,6 @@ export class CredentialStore { ) { this.filePath = join(directory, "auth.json") this.lockPath = join(directory, "auth.lock") - this.legacyPath = join(directory, "credentials.json") } /** Where new credentials go. */ @@ -297,32 +298,4 @@ export class CredentialStore { await chmod(temporary, 0o600) 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(): Promise<Array<{ url: string; credentials: Credentials }>> { - if (!(await stat(this.legacyPath).catch(() => undefined))) return [] - return this.locked(async () => { - let data: unknown - try { - data = JSON.parse(await readFile(this.legacyPath, "utf8")) - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === "ENOENT") return [] - if (!(error instanceof SyntaxError)) throw error - } - const servers = - data && typeof data === "object" && "servers" in data - ? (data.servers as Record<string, Credentials>) - : {} - await rm(this.legacyPath, { force: true }) - return Object.entries(servers) - .filter(([, credentials]) => credentials.tokens) - .map(([url, credentials]) => ({ url, credentials })) - }) - } } diff --git a/src/errors.ts b/src/errors.ts index 97007cd..df6e693 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,6 @@ import { OAuthError, ProtocolError } from "@modelcontextprotocol/client" -import type { Target } from "./products.ts" +import type { Target } from "./apps.ts" export const exitCodes = { ok: 0, @@ -34,12 +34,12 @@ export class UsageError extends CliError { /** A command needs a sign-in that it may not start itself. */ export class SignInRequiredError extends CliError { constructor(readonly target: Target) { - const command = `fulldev login ${target.name}${target.urlFromFlag ? ` --url ${target.url}` : ""}` + const command = `fulldev login${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 }, + { server: target.url, command }, ) this.name = "SignInRequiredError" } @@ -50,7 +50,7 @@ export function isSignInRequired(error: unknown): error is SignInRequiredError { } /** The first error in the cause chain that is an instance of `type`. */ -export function findCause<T extends Error>( +function findCause<T extends Error>( error: unknown, type: abstract new (...args: Array<never>) => T, ): T | undefined { diff --git a/src/form-wait.test.ts b/src/form-wait.test.ts index 674d99f..ad2dcfa 100644 --- a/src/form-wait.test.ts +++ b/src/form-wait.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test" import { waitForForm } from "./form-wait.ts" import { SignInRequiredError } from "./errors.ts" -import { findProduct } from "./products.ts" +import { server } from "./apps.ts" const branchId = "11111111-1111-4111-8111-111111111111" const formId = "22222222-2222-4222-8222-222222222222" @@ -39,7 +39,7 @@ function setup(results: Array<CallToolResult | Error>) { } describe("waitForForm", () => { - it("calls wait_for_form until waiting is false", async () => { + it("calls cms_wait_for_form until waiting is false", async () => { const answers = { waiting: false, formId, answers: { title: "New" } } const { callTool, progress, options } = setup([ structured({ waiting: true, formId }), @@ -52,7 +52,7 @@ describe("waitForForm", () => { }) expect(callTool).toHaveBeenCalledTimes(3) expect(callTool).toHaveBeenCalledWith( - "wait_for_form", + "cms_wait_for_form", { branchId, formId }, expect.any(AbortSignal), ) @@ -100,7 +100,7 @@ describe("waitForForm", () => { branchId, formId, timeoutMs: 50, - product: "cms", + app: "cms", }) expect(aborted).toBe(true) expect(Date.now() - started).toBeLessThan(1000) @@ -153,11 +153,8 @@ describe("waitForForm", () => { }) it.each([ - new SignInRequiredError({ - ...findProduct("cms")!, - urlFromFlag: false, - }), - new ProtocolError(-32602, "Tool wait_for_form not found"), + new SignInRequiredError({ ...server, urlFromFlag: false }), + new ProtocolError(-32602, "Tool cms_wait_for_form not found"), ])("does not retry %s", async (error) => { const { callTool, options } = setup([error]) await expect(waitForForm(callTool, options)).rejects.toBe(error) diff --git a/src/form-wait.ts b/src/form-wait.ts index 4d36eb2..6194c08 100644 --- a/src/form-wait.ts +++ b/src/form-wait.ts @@ -4,8 +4,8 @@ import type { CallToolResult } from "@modelcontextprotocol/client" import { isSignInRequired } from "./errors.ts" import { toolError, toolOutput } from "./result.ts" -/** The one tool the CLI names itself. */ -export const waitTool = "wait_for_form" +/** The one tool the CLI names itself, without its app's prefix. */ +const waitTool = "wait_for_form" export type CallTool = ( name: string, @@ -17,8 +17,8 @@ export interface FormWaitOptions { branchId: string formId: string timeoutMs: number - /** The product name, for the command that resumes the wait. */ - product?: string + /** The app name, for the command that resumes the wait. */ + app?: string progress?: (line: string) => void now?: () => number sleep?: (ms: number, signal: AbortSignal) => Promise<void> @@ -61,7 +61,7 @@ export async function waitForForm( branchId, formId, timeoutMs, - product = "cms", + app = "cms", progress = () => {}, now = Date.now, sleep = wait, @@ -77,7 +77,11 @@ export async function waitForForm( for (let round = 1; now() < deadline && !aborted(); round++) { let result: CallToolResult try { - result = await callTool(waitTool, { branchId, formId }, signal) + result = await callTool( + `${app}_${waitTool}`, + { branchId, formId }, + signal, + ) failures = 0 } catch (error) { if (aborted()) break @@ -111,7 +115,7 @@ export async function waitForForm( 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.`, + message: `Stopped waiting after ${minutes(timeoutMs)}. Run fulldev ${app} form wait ${branchId} ${formId} to keep waiting, or continue when the person says they are done.`, }, } } diff --git a/src/main.test.ts b/src/main.test.ts index 7fad5ed..5a28c2e 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from "vite-plus/test" import { CredentialStore } from "./credentials.ts" import type { Keychain } from "./credentials.ts" -import { main } from "./main.ts" +import { findApp } from "./apps.ts" +import { instructionsFor, main } from "./main.ts" import type { Io } from "./main.ts" async function run( @@ -48,6 +49,7 @@ async function run( describe("main", () => { it.each([ + [["tools"]], [["cms", "tools"]], [["connect", "instructions"]], [["scan", "call", "list_sites", "{}"]], @@ -58,32 +60,30 @@ describe("main", () => { const { code, stdout, stderr } = await run(argv) expect(code).toBe(3) expect(stdout).toBe("") - const product = argv[0] expect(JSON.parse(stderr)).toEqual({ error: { code: "SIGN_IN_REQUIRED", - message: expect.stringContaining(`fulldev login ${product}`), - product, - server: `https://${product}.full.dev/mcp`, - command: `fulldev login ${product}`, + message: "Not signed in to Fulldev. Run: fulldev login", + server: "https://app.full.dev/mcp", + command: "fulldev login", }, }) }, ) it("names the --url in the login command it asks for", async () => { - const preview = "https://deploy-preview-3--cms.netlify.app/mcp" + const preview = "https://deploy-preview-3--fulldev-app.netlify.app/mcp" const { code, stderr } = await run(["cms", "tools", "--url", preview]) expect(code).toBe(3) expect(JSON.parse(stderr).error.command).toBe( - `fulldev login cms --url ${preview}`, + `fulldev login --url ${preview}`, ) const fromEnv = await run(["cms", "tools"], { - env: { FULLDEV_CMS_URL: preview }, + env: { FULLDEV_URL: preview }, }) expect(JSON.parse(fromEnv.stderr).error).toMatchObject({ server: preview, - command: "fulldev login cms", + command: "fulldev login", }) }) @@ -145,27 +145,44 @@ describe("main", () => { expect(stdout).toMatch(/^\d+\.\d+\.\d+\n$/) }) - it("logs out of products that are not signed in", async () => { + it("logs out when not signed in", async () => { const { code, stdout } = await run(["logout"]) expect(code).toBe(0) expect(JSON.parse(stdout)).toEqual({ - products: [ - { product: "cms", signedIn: false }, - { product: "connect", signedIn: false }, - { product: "scan", signedIn: false }, - { product: "sites", signedIn: false }, - ], + server: "https://app.full.dev/mcp", + signedIn: false, }) }) - it("reports status per product and exits 3 when one needs a sign-in", async () => { - const { code, stdout } = await run(["status", "cms"]) + it("reports the status and exits 3 when a sign-in is needed", async () => { + const { code, stdout } = await run(["status"]) expect(code).toBe(3) expect(JSON.parse(stdout)).toEqual({ + server: "https://app.full.dev/mcp", + signedIn: false, storage: { kind: "keychain", service: "fulldev" }, - products: [ - { product: "cms", server: "https://cms.full.dev/mcp", signedIn: false }, - ], }) }) }) + +describe("instructionsFor", () => { + const text = `This is Fulldev: the overview. + +## Fulldev CMS (cms_) +Use the CMS. + +## Fulldev Scan (scan_) +Use Scan.` + + it("gives the overview and the app's own part", () => { + expect(instructionsFor(text, findApp("scan")!)).toBe( + "This is Fulldev: the overview.\n\n## Fulldev Scan (scan_)\nUse Scan.", + ) + }) + + it("says so when the app is not among the person's", () => { + expect(() => instructionsFor(text, findApp("pages")!)).toThrow( + /Fulldev Pages is not among your apps/, + ) + }) +}) diff --git a/src/main.ts b/src/main.ts index f2d91a4..0010cc8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,8 +26,8 @@ import { expiresAt, revokeTokens, } from "./oauth.ts" -import { products } from "./products.ts" -import type { Target } from "./products.ts" +import { toolName } from "./apps.ts" +import type { App, Target } from "./apps.ts" import { toolError, toolOutput } from "./result.ts" /** Where the CLI writes, and whether a person is at the terminal. */ @@ -117,9 +117,10 @@ async function listTools(client: Client) { return tools } -async function productStatus(target: Target, io: Io) { +/** The sign-in, and whether the server still accepts it. */ +async function signInStatus(target: Target, io: Io) { const { credentials, storage } = await io.store.read(target.url) - const base = { product: target.name, server: target.url } + const base = { server: target.url } if (!credentials.tokens) return { ...base, signedIn: false } // valid: the server accepts the sign-in; unknown (with error) when it could not be checked. let valid: boolean | undefined = true @@ -149,12 +150,12 @@ async function productStatus(target: Target, io: Io) { } } -async function status(targets: Array<Target>, io: Io) { - const results = [] - for (const target of targets) results.push(await productStatus(target, io)) +async function status(target: Target, io: Io) { + const result = await signInStatus(target, io) const storage = await io.store.storage() io.stdout( json({ + ...result, storage: storage === "keychain" ? { kind: "keychain", service: "fulldev" } @@ -163,48 +164,53 @@ async function status(targets: Array<Target>, io: Io) { path: io.store.filePath, reason: `The OS keychain is unavailable: ${io.store.keychainError}`, }, - products: results, }), ) - // Sign-in is needed when no listed product can be used; a product the - // person does not use does not make status fail. - if ( - !results.some( - (result) => - result.signedIn && !("valid" in result && result.valid === false), - ) - ) + if (!result.signedIn || ("valid" in result && result.valid === false)) return exitCodes.signIn - return results.some((result) => "error" in result) - ? exitCodes.error - : exitCodes.ok + return "error" in result ? exitCodes.error : exitCodes.ok } -async function logout(targets: Array<Target>, io: 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, +async function logout(target: Target, io: Io) { + const credentials = await io.store.get(target.url) + if (!credentials.tokens && !credentials.client) { + io.stdout(json({ server: target.url, signedIn: false })) + return exitCodes.ok + } + 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, () => ({})) + io.stdout( + json({ + server: target.url, signedIn: false, revoked: revocation.revoked, ...(revocation.error ? { revocationError: revocation.error } : {}), - }) - } - io.stdout(json({ products: results })) + }), + ) return exitCodes.ok } +/** + * The part of the server's instructions for one app: the overview before + * the first app, and the app's own, under its heading `## <title> (<app>_)`. + */ +export function instructionsFor(text: string, app: App) { + const [overview = "", ...sections] = text.split(/\n(?=## )/) + const own = sections.find((section) => + section.split("\n")[0]!.endsWith(`(${app.name}_)`), + ) + if (!own) + throw new CliError( + "APP_NOT_AVAILABLE", + `${app.title} is not among your apps in this organization. Run fulldev instructions for the apps you may use, or fulldev login to choose another organization.`, + ) + return `${overview.trimEnd()}\n\n${own.trim()}` +} + async function run(command: Command, io: Io): Promise<number> { const log = (line: string) => io.stderr(`${line}\n`) switch (command.kind) { @@ -216,41 +222,30 @@ async function run(command: Command, io: Io): Promise<number> { return exitCodes.ok } - for (const { url, credentials } of await io.store.migrate()) { - const product = products.find((candidate) => candidate.url === url) - const name = product?.name - // Best effort: the old tokens stop working at Clerk too. - 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.`, - ) - } - switch (command.kind) { - case "login": - for (const target of command.targets) { - // A new sign-in, such as one to switch organization, replaces the - // old one, so the old tokens are revoked once it succeeds. - 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}.`) + case "login": { + const { target } = command + // A new sign-in, such as one to switch organization, replaces the + // old one, so the old tokens are revoked once it succeeds. + 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}`, + ) } - return status(command.targets, io) + log(`Signed in to ${target.title}.`) + return status(target, io) + } case "logout": - return logout(command.targets, io) + return logout(command.target, io) case "status": - return status(command.targets, io) + return status(command.target, io) case "instructions": { const text = await withClient( command.target, @@ -259,16 +254,17 @@ async function run(command: Command, io: Io): Promise<number> { async (client) => client.getInstructions(), ) if (!text) log("The server sent no instructions.") - else io.stdout(`${text}\n`) + else + io.stdout( + `${command.app ? instructionsFor(text, command.app) : text}\n`, + ) return exitCodes.ok } case "tools": { - const tools = await withClient( - command.target, - command.session, - io, - listTools, - ) + const { app } = command + const tools = ( + await withClient(command.target, command.session, io, listTools) + ).filter((tool) => !app || tool.name.startsWith(`${app.name}_`)) if (!command.name) { io.stdout( json( @@ -281,11 +277,12 @@ async function run(command: Command, io: Io): Promise<number> { ) return exitCodes.ok } - const tool = tools.find((candidate) => candidate.name === command.name) + const name = app ? toolName(app, command.name) : command.name + const tool = tools.find((candidate) => candidate.name === 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.`, + `You have no tool named ${name}. Run fulldev ${app ? `${app.name} ` : ""}tools for the list.`, ) io.stdout( json({ @@ -337,7 +334,7 @@ async function run(command: Command, io: Io): Promise<number> { branchId: command.branchId, formId: command.formId, timeoutMs: command.timeoutMinutes * 60_000, - product: command.target.name, + app: command.app.name, progress: log, }, ), diff --git a/src/oauth.test.ts b/src/oauth.test.ts index 91f262f..1045d03 100644 --- a/src/oauth.test.ts +++ b/src/oauth.test.ts @@ -1,6 +1,7 @@ import { mkdtemp } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { PassThrough } from "node:stream" import type { FetchLike } from "@modelcontextprotocol/client" import { describe, expect, it, vi } from "vite-plus/test" @@ -11,17 +12,26 @@ import { SignInRequiredError } from "./errors.ts" import { StoredTokenAuth, browserLogin, - clientIds, + clientMetadataUrl, refreshTokens, revokeTokens, } from "./oauth.ts" -import { findProduct } from "./products.ts" -import type { Target } from "./products.ts" +import { server } from "./apps.ts" +import type { Target } from "./apps.ts" -const issuer = "https://clerk.full.dev" -const clientId = clientIds[issuer]! -const cms: Target = { ...findProduct("cms")!, urlFromFlag: false } -const connect: Target = { ...findProduct("connect")!, urlFromFlag: false } +const issuer = "https://app.full.dev/api/auth" +const client = { client_id: clientMetadataUrl, issuer } +const scopes = ["openid", "profile", "email", "offline_access"] +// As the MCP server advertises them: `openid` is the authorization +// server's own scope (apps/app/server/auth/access.ts). +const resourceScopes = ["profile", "email", "offline_access"] +const fulldev: Target = { ...server, urlFromFlag: false } +// Another server, such as a test site's, with a sign-in of its own. +const other: Target = { + ...server, + url: "https://other.full.dev/mcp", + urlFromFlag: false, +} const jwt = (claims: Record<string, unknown>) => `e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig` @@ -39,59 +49,56 @@ interface Request { } /** - * A fake Clerk and product servers: resource metadata per product, the - * authorization server metadata, and handlers for the POST endpoints. + * A fake Better Auth server and MCP servers: resource metadata per + * server, the authorization server metadata (RFC 8414, at the well-known + * path before the issuer's path), and handlers for the POST endpoints. */ function authServer( handlers: Record<string, (form: URLSearchParams) => Response> = {}, - { asIssuer = issuer, registration = false } = {}, + { asIssuer = issuer, metadataDocuments = true, registration = false } = {}, ) { const requests: Array<Request> = [] + const { origin, pathname } = new URL(asIssuer) const fetchFn = vi.fn<FetchLike>(async (input, init) => { const url = String(input) const form = new URLSearchParams( init?.body instanceof URLSearchParams ? init.body : "", ) requests.push({ url, method: init?.method ?? "GET", form }) - const product = + const app = /^https:\/\/(\w+)\.full\.dev\/\.well-known\/oauth-protected-resource\/mcp$/.exec( url, ) - if (product) + if (app) return json({ - resource: `https://${product[1]}.full.dev/mcp`, + resource: `https://${app[1]}.full.dev/mcp`, authorization_servers: [asIssuer], - scopes_supported: [ - "openid", - "email", - "offline_access", - "user:org:read", - ], + scopes_supported: resourceScopes, + bearer_methods_supported: ["header"], }) - if (url === `${asIssuer}/.well-known/oauth-authorization-server`) + if (url === `${origin}/.well-known/oauth-authorization-server${pathname}`) return json({ issuer: asIssuer, - authorization_endpoint: `${asIssuer}/oauth/authorize`, - token_endpoint: `${asIssuer}/oauth/token`, - revocation_endpoint: `${asIssuer}/oauth/token/revoke`, - device_authorization_endpoint: `${asIssuer}/oauth/device_authorization`, + authorization_endpoint: `${asIssuer}/oauth2/authorize`, + token_endpoint: `${asIssuer}/oauth2/token`, + revocation_endpoint: `${asIssuer}/oauth2/revoke`, ...(registration - ? { registration_endpoint: `${asIssuer}/oauth/register` } + ? { registration_endpoint: `${asIssuer}/oauth2/register` } + : {}), + ...(metadataDocuments + ? { client_id_metadata_document_supported: true } : {}), response_types_supported: ["code"], - grant_types_supported: [ - "authorization_code", - "refresh_token", - "urn:ietf:params:oauth:grant-type:device_code", - ], - token_endpoint_auth_methods_supported: ["none", "client_secret_basic"], - scopes_supported: [ - "openid", - "email", - "offline_access", - "user:org:read", + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: [ + "none", + "client_secret_basic", + "client_secret_post", + "private_key_jwt", ], + scopes_supported: scopes, code_challenge_methods_supported: ["S256"], + authorization_response_iss_parameter_supported: true, }) const path = url.startsWith(asIssuer) ? url.slice(asIssuer.length) : url const handler = handlers[path] @@ -118,23 +125,24 @@ function memoryStore() { } describe("browserLogin", () => { - it("uses the fixed client with PKCE, a loopback port and the product as resource", async () => { + it("identifies itself with its metadata document, with PKCE, a loopback port and the app as resource", async () => { const store = await memoryStore() const idToken = jwt({ email: "person@example.com" }) const { fetchFn, requests } = authServer({ - "/oauth/token": () => + "/oauth2/token": () => json({ access_token: jwt({ org_id: "org_1", exp: 4_000_000_000 }), token_type: "Bearer", refresh_token: "refresh-1", id_token: idToken, - expires_in: 86400, + expires_in: 3600, }), }) let authorizationUrl: URL | undefined - await browserLogin(connect, store, { + await browserLogin(other, store, { browser: false, fetchFn, + input: new PassThrough(), log: (line) => { if (!line.startsWith("https://")) return authorizationUrl = new URL(line) @@ -152,162 +160,332 @@ describe("browserLogin", () => { }) const query = authorizationUrl!.searchParams expect(authorizationUrl!.origin + authorizationUrl!.pathname).toBe( - `${issuer}/oauth/authorize`, + `${issuer}/oauth2/authorize`, ) - expect(query.get("client_id")).toBe(clientId) - expect(query.get("resource")).toBe("https://connect.full.dev/mcp") + expect(query.get("client_id")).toBe(clientMetadataUrl) + expect(query.get("resource")).toBe("https://other.full.dev/mcp") expect(query.get("code_challenge_method")).toBe("S256") - expect(query.get("scope")).toBe("openid email offline_access user:org:read") + // openid too, though the resource does not list it, for the ID token. + expect(query.get("scope")).toBe("openid profile email offline_access") expect(query.get("redirect_uri")).toMatch( /^http:\/\/127\.0\.0\.1:\d+\/callback$/, ) expect( - requests.some((request) => request.url.endsWith("/oauth/register")), + requests.some((request) => request.url.endsWith("/oauth2/register")), ).toBe(false) const token = requests.find((request) => - request.url.endsWith("/oauth/token"), + request.url.endsWith("/oauth2/token"), )! expect(token.form.get("grant_type")).toBe("authorization_code") - expect(token.form.get("client_id")).toBe(clientId) + expect(token.form.get("client_id")).toBe(clientMetadataUrl) expect(token.form.get("code")).toBe("code-1") expect(token.form.get("code_verifier")).toMatch(/^[\w.~-]{43,128}$/) - expect(token.form.get("resource")).toBe("https://connect.full.dev/mcp") + expect(token.form.get("resource")).toBe("https://other.full.dev/mcp") + expect(token.form.get("redirect_uri")).toBe(query.get("redirect_uri")) - const saved = await store.get(connect.url) + const saved = await store.get(other.url) expect(saved).toMatchObject({ issuer, + client, email: "person@example.com", tokens: { refresh_token: "refresh-1" }, }) expect(saved.tokens).not.toHaveProperty("id_token") - expect(await store.get(cms.url)).toEqual({}) + expect(await store.get(fulldev.url)).toEqual({}) }) - it("registers a client only for an issuer without a fixed client id", async () => { + it("never keeps the previous person's email on a new sign-in", async () => { const store = await memoryStore() - const dev = "https://example.clerk.accounts.dev" - const { fetchFn } = authServer( - { - "/oauth/register": () => - json({ client_id: "registered-1", redirect_uris: [] }, 201), + await store.update(fulldev.url, () => ({ + issuer, + client, + email: "previous@example.com", + tokens: { access_token: "a", token_type: "Bearer", refresh_token: "r" }, + })) + const signIn = async (idToken?: string) => { + const { fetchFn } = authServer({ + "/oauth2/token": () => + json({ + access_token: "new", + token_type: "Bearer", + refresh_token: "refresh-new", + ...(idToken ? { id_token: idToken } : {}), + }), + }) + await browserLogin(fulldev, store, { + browser: false, + fetchFn, + input: new PassThrough(), + log: (line) => { + if (!line.startsWith("https://")) return + const authorization = new URL(line) + const redirect = new URL( + authorization.searchParams.get("redirect_uri")!, + ) + redirect.searchParams.set("code", "code-1") + redirect.searchParams.set( + "state", + authorization.searchParams.get("state")!, + ) + redirect.searchParams.set("iss", issuer) + void fetch(redirect) + }, + }) + } + await signIn(jwt({ email: "next@example.com" })) + expect((await store.get(fulldev.url)).email).toBe("next@example.com") + // Without an ID token the email is unknown, not the one before. + await signIn() + expect(await store.get(fulldev.url)).not.toHaveProperty("email") + + // A refresh, which has no ID token, keeps the sign-in's email. + await store.update(fulldev.url, (current) => ({ + ...current, + email: "next@example.com", + })) + const { fetchFn } = authServer({ + "/oauth2/token": () => + json({ access_token: "refreshed", token_type: "Bearer" }), + }) + await store.locked(() => refreshTokens(fulldev, store, fetchFn)) + expect(await store.get(fulldev.url)).toMatchObject({ + email: "next@example.com", + tokens: { access_token: "refreshed" }, + }) + }) + + it("takes the address after sign-in pasted into the terminal, for a browser on another computer", async () => { + const store = await memoryStore() + const { fetchFn, requests } = authServer({ + "/oauth2/token": () => + json({ + access_token: jwt({ org_id: "org_1", exp: 4_000_000_000 }), + token_type: "Bearer", + refresh_token: "refresh-1", + expires_in: 3600, + }), + }) + const input = new PassThrough() + const logged: Array<string> = [] + await browserLogin(fulldev, store, { + browser: false, + fetchFn, + input, + log: (line) => { + logged.push(line) + if (!line.startsWith("https://")) return + const authorization = new URL(line) + // The browser on the other computer cannot load this page, so the + // person copies its address. + const redirect = new URL( + authorization.searchParams.get("redirect_uri")!, + ) + redirect.searchParams.set("code", "code-1") + redirect.searchParams.set( + "state", + authorization.searchParams.get("state")!, + ) + redirect.searchParams.set("iss", issuer) + input.write("code-1\n") + input.write(` ${redirect.href} \n`) }, - { asIssuer: dev, registration: true }, - ) - const login = browserLogin(cms, store, { + }) + expect(logged.join("\n")).toMatch(/paste it here/) + expect(logged.join("\n")).toMatch(/That is not the address/) + const token = requests.find((request) => + request.url.endsWith("/oauth2/token"), + )! + expect(token.form.get("code")).toBe("code-1") + expect(await store.get(fulldev.url)).toMatchObject({ + issuer, + client, + tokens: { refresh_token: "refresh-1" }, + }) + }) + + it("refuses a pasted address of another sign-in", async () => { + const store = await memoryStore() + const { fetchFn, requests } = authServer() + const input = new PassThrough() + const login = browserLogin(fulldev, store, { browser: false, fetchFn, - timeoutMs: 50, + input, log: (line) => { if (line.startsWith("https://")) - expect(new URL(line).searchParams.get("client_id")).toBe( - "registered-1", + input.write( + "http://127.0.0.1:1234/callback?code=code-1&state=another\n", ) }, }) - await expect(login).rejects.toThrow(/timed out/) - expect((await store.get(cms.url)).client?.client_id).toBe("registered-1") + await expect(login).rejects.toMatchObject({ code: "SIGN_IN_FAILED" }) + expect( + requests.some((request) => request.url.endsWith("/oauth2/token")), + ).toBe(false) + expect(await store.get(fulldev.url)).toEqual({}) + }) + + it("fails clearly with a server that does not support metadata documents, and registers no client", async () => { + const store = await memoryStore() + const preview = "https://deploy-preview-12--fulldev.netlify.app/api/auth" + const { fetchFn, requests } = authServer( + { + "/oauth2/register": () => + json({ client_id: "registered-1", redirect_uris: [] }, 201), + }, + { asIssuer: preview, metadataDocuments: false, registration: true }, + ) + const log = vi.fn() + await expect( + browserLogin(fulldev, store, { + browser: false, + fetchFn, + input: new PassThrough(), + log, + }), + ).rejects.toMatchObject({ + code: "CLIENT_METADATA_UNSUPPORTED", + message: expect.stringContaining(preview), + }) + expect(log).not.toHaveBeenCalled() + expect( + requests.some((request) => request.url.endsWith("/oauth2/register")), + ).toBe(false) + expect(await store.get(fulldev.url)).toEqual({}) }) }) describe("revokeTokens", () => { it("revokes the refresh token as a public client, and not the access token", async () => { const { fetchFn, requests } = authServer({ - "/oauth/token/revoke": () => new Response(null, { status: 200 }), + "/oauth2/revoke": () => new Response(null, { status: 200 }), }) const result = await revokeTokens( - cms, + fulldev, { issuer, + client, tokens: { access_token: "a", token_type: "Bearer", refresh_token: "r" }, }, fetchFn, ) expect(result).toEqual({ revoked: true }) const revocations = requests.filter((request) => - request.url.endsWith("/oauth/token/revoke"), + request.url.endsWith("/oauth2/revoke"), ) expect( revocations.map((request) => Object.fromEntries(request.form)), ).toEqual([ - { token: "r", token_type_hint: "refresh_token", client_id: clientId }, + { + token: "r", + token_type_hint: "refresh_token", + client_id: clientMetadataUrl, + }, ]) }) it("reports a failure without throwing", async () => { const { fetchFn } = authServer({ - "/oauth/token/revoke": () => + "/oauth2/revoke": () => json( { error: "invalid_client", error_description: "Unknown client." }, 401, ), }) - const result = await revokeTokens( - cms, - { - issuer, - tokens: { access_token: "a", token_type: "Bearer", refresh_token: "r" }, - }, - fetchFn, - ) + const credentials = { + issuer, + client, + tokens: { access_token: "a", token_type: "Bearer", refresh_token: "r" }, + } + const result = await revokeTokens(fulldev, credentials, fetchFn) expect(result.revoked).toBe(false) expect(result.error).toContain( "refresh_token: invalid_client: Unknown client.", ) - const offline = await revokeTokens( - cms, + const offline = await revokeTokens(fulldev, credentials, async () => { + throw new TypeError("fetch failed") + }) + expect(offline).toEqual({ revoked: false, error: "fetch failed" }) + }) + + it("cannot revoke a sign-in without a client, from an earlier fulldev", async () => { + const fetchFn = vi.fn<FetchLike>() + const result = await revokeTokens( + fulldev, { - issuer, + issuer: "https://clerk.full.dev", tokens: { access_token: "a", token_type: "Bearer", refresh_token: "r" }, }, - async () => { - throw new TypeError("fetch failed") - }, + fetchFn, ) - expect(offline).toEqual({ revoked: false, error: "fetch failed" }) + expect(result).toEqual({ + revoked: false, + error: expect.stringContaining("earlier version"), + }) + expect(fetchFn).not.toHaveBeenCalled() }) }) describe("refreshing", () => { it("drops tokens the server no longer accepts and asks for a sign-in", async () => { const store = await memoryStore() - await store.update(cms.url, () => ({ + await store.update(fulldev.url, () => ({ issuer, + client, tokens: { access_token: "a", token_type: "Bearer", refresh_token: "old" }, })) const { fetchFn, requests } = authServer({ - "/oauth/token": () => json({ error: "invalid_grant" }, 400), + "/oauth2/token": () => json({ error: "invalid_grant" }, 400), }) await expect( - store.locked(() => refreshTokens(cms, store, fetchFn)), + store.locked(() => refreshTokens(fulldev, store, fetchFn)), ).rejects.toBeInstanceOf(SignInRequiredError) - expect((await store.get(cms.url)).tokens).toBeUndefined() + expect((await store.get(fulldev.url)).tokens).toBeUndefined() const refresh = requests.find((request) => - request.url.endsWith("/oauth/token"), + request.url.endsWith("/oauth2/token"), )! expect(Object.fromEntries(refresh.form)).toMatchObject({ grant_type: "refresh_token", refresh_token: "old", - client_id: clientId, - resource: cms.url, + client_id: clientMetadataUrl, + resource: fulldev.url, }) }) + it("asks for a new sign-in when the app moved to another authorization server", async () => { + const store = await memoryStore() + const clerk = "https://clerk.full.dev" + await store.update(fulldev.url, () => ({ + issuer: clerk, + client: { client_id: "registered-1", issuer: clerk }, + tokens: { access_token: "a", token_type: "Bearer", refresh_token: "r" }, + })) + const { fetchFn, requests } = authServer() + await expect( + store.locked(() => refreshTokens(fulldev, store, fetchFn)), + ).rejects.toBeInstanceOf(SignInRequiredError) + expect( + requests.some((request) => request.url.endsWith("/oauth2/token")), + ).toBe(false) + }) + it("uses a token another process refreshed instead of refreshing again", async () => { const store = await memoryStore() - await store.update(cms.url, () => ({ + await store.update(fulldev.url, () => ({ issuer, + client, tokens: { access_token: "first", token_type: "Bearer", refresh_token: "r1", }, })) - const provider = new StoredTokenAuth(cms, store) + const provider = new StoredTokenAuth(fulldev, store) expect(await provider.token()).toBe("first") // Another process refreshed while this request was on its way. - await store.update(cms.url, (current) => ({ + await store.update(fulldev.url, (current) => ({ ...current, tokens: { access_token: "second", @@ -323,8 +501,9 @@ describe("refreshing", () => { it("refreshes a token that is about to expire before sending it", async () => { const store = await memoryStore() - await store.update(cms.url, () => ({ + await store.update(fulldev.url, () => ({ issuer, + client, tokens: { access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 10 }), token_type: "Bearer", @@ -332,26 +511,26 @@ describe("refreshing", () => { }, })) const { fetchFn } = authServer({ - "/oauth/token": () => + "/oauth2/token": () => json({ access_token: "fresh", token_type: "Bearer", - expires_in: 86400, + expires_in: 3600, }), }) vi.stubGlobal("fetch", fetchFn) try { - expect(await new StoredTokenAuth(cms, store).token()).toBe("fresh") + expect(await new StoredTokenAuth(fulldev, store).token()).toBe("fresh") } finally { vi.unstubAllGlobals() } - expect((await store.get(cms.url)).tokens?.refresh_token).toBe("r1") + expect((await store.get(fulldev.url)).tokens?.refresh_token).toBe("r1") }) it("never starts a sign-in without tokens", async () => { const store = await memoryStore() await expect( - new StoredTokenAuth(cms, store).token(), + new StoredTokenAuth(fulldev, store).token(), ).rejects.toBeInstanceOf(SignInRequiredError) }) }) diff --git a/src/oauth.ts b/src/oauth.ts index 81b6b2f..5c2b079 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process" import { randomBytes } from "node:crypto" import { createServer } from "node:http" import type { AddressInfo } from "node:net" +import { createInterface } from "node:readline" import { OAuthError, @@ -28,31 +29,15 @@ import type { import type { CredentialStore, Credentials } from "./credentials.ts" import { CliError, SignInRequiredError } from "./errors.ts" -import type { Target } from "./products.ts" +import type { Target } from "./apps.ts" /** - * 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. + * The Fulldev CLI's OAuth client id: the URL of its Client ID Metadata + * Document, served by full.dev from apps/website/public/oauth/cli.json. The + * CLI never registers a client, so an authorization server must support + * such documents. */ -export const clientIds: Record<string, string> = { - "https://clerk.full.dev": "3Gjxf97mGc2QVnTv", -} - -const trimSlash = (url: string) => url.replace(/\/+$/, "") - -/** The pre-registered client for an issuer, if there is one. */ -export function fixedClient( - issuer: string | undefined, -): StoredOAuthClientInformation | undefined { - const clientId = issuer ? clientIds[trimSlash(issuer)] : undefined - return clientId ? { client_id: clientId, issuer } : undefined -} - -/** The client to use with an issuer: the fixed one, else the registered one. */ -function clientFor(issuer: string | undefined, credentials: Credentials) { - return fixedClient(issuer) ?? credentials.client -} +export const clientMetadataUrl = "https://full.dev/oauth/cli.json" /** The claims of a JWT, or {} for an opaque token. */ export function claims(token: string | undefined): Record<string, unknown> { @@ -92,22 +77,46 @@ function needsRefresh(credentials: Credentials, now = Date.now()) { return expires !== undefined && expires - refreshEarlyMs <= now } -/** Saves tokens for a target, keeping only the email from the ID token. */ +/** + * The scopes a sign-in asks for. The SDK would take the resource's + * `scopes_supported`, which leaves out `openid`, the authorization server's + * own scope; without it there is no ID token and so no email. + */ +const signInScope = "openid profile email offline_access" + +/** The CLI as the client of an authorization server. */ +const cliClient = (issuer: string): StoredOAuthClientInformation => ({ + client_id: clientMetadataUrl, + issuer, +}) + +/** + * Saves tokens for a target, keeping only the email from the ID token. A + * sign-in also saves the client they were issued to, and its email only: + * it may be another person's. A refresh keeps the email it had. + */ async function saveTokens( store: CredentialStore, target: Target, tokens: OAuthTokens, issuer: string, + client?: StoredOAuthClientInformation, ) { 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(), - })) + await store.update(target.url, (current) => { + const next: Credentials = { + ...current, + issuer, + ...(client ? { client } : {}), + tokens: kept, + savedAt: Date.now(), + } + delete next.email + const known = + typeof email === "string" ? email : client ? undefined : current.email + return known ? { ...next, email: known } : next + }) } /** Metadata fields the SDK's type leaves out (RFC 8414, RFC 8628). */ @@ -119,13 +128,13 @@ interface Discovery { authorizationServerUrl: string | URL metadata: ServerMetadata issuer: string - /** The product's MCP URL as the resource server names it. */ + /** The app's MCP URL as the resource server names it. */ resource: string resourceMetadata: OAuthProtectedResourceMetadata } /** Finds the target's authorization server and checks its resource. */ -export async function discover( +async function discover( target: Target, fetchFn?: FetchLike, ): Promise<Discovery> { @@ -156,28 +165,15 @@ export async function discover( } } -/** The scopes the resource asks for, plus offline_access for a refresh token. */ -export function scopeFor({ resourceMetadata, metadata }: Discovery) { - const scopes = new Set(resourceMetadata.scopes_supported ?? []) - if (metadata.scopes_supported?.includes("offline_access")) - scopes.add("offline_access") - return [...scopes].join(" ") -} - const callbackPath = "/callback" -/** The redirect URIs a dynamically registered client was registered with. */ -function redirectUris(client: StoredOAuthClientInformation | undefined) { - return client && "redirect_uris" in client ? client.redirect_uris : [] -} - /** - * 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. + * The SDK's view of one browser sign-in: the metadata document as client, + * with PKCE S256 and a loopback redirect, on the server that discovery + * found. */ class BrowserLoginProvider implements OAuthClientProvider { private verifier = "" - private discovery: OAuthDiscoveryState | undefined constructor( private readonly store: CredentialStore, @@ -185,12 +181,14 @@ class BrowserLoginProvider implements OAuthClientProvider { readonly redirectUrl: string, private readonly loginState: string, private readonly onAuthorizationUrl: (url: URL) => void, + private discovery: OAuthDiscoveryState | undefined, ) {} + /** Read by the SDK for the scope; the server reads the metadata document. */ get clientMetadata(): OAuthClientMetadata { return { client_name: "Fulldev CLI", - client_uri: "https://github.com/fulldotdev/cli", + client_uri: "https://full.dev", redirect_uris: [this.redirectUrl], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], @@ -202,23 +200,9 @@ class BrowserLoginProvider implements OAuthClientProvider { return this.loginState } - async clientInformation(ctx?: OAuthClientInformationContext) { - const fixed = fixedClient(ctx?.issuer) - if (fixed) return fixed - const { client } = await this.store.get(this.target.url) - // A client registered for another loopback port cannot receive this code. - return redirectUris(client).includes(this.redirectUrl) ? client : undefined - } - - async saveClientInformation( - client: StoredOAuthClientInformation, - ctx?: OAuthClientInformationContext, - ) { - if (fixedClient(ctx?.issuer ?? client.issuer)) return - await this.store.update(this.target.url, (current) => ({ - ...current, - client, - })) + /** Always the metadata document, so the SDK never registers a client. */ + clientInformation(ctx?: OAuthClientInformationContext) { + return cliClient(ctx?.issuer ?? "") } /** A sign-in always asks again, so it never reuses stored tokens. */ @@ -227,8 +211,8 @@ class BrowserLoginProvider implements OAuthClientProvider { } async saveTokens(tokens: OAuthTokens, ctx?: OAuthClientInformationContext) { - const issuer = ctx?.issuer ?? (tokens as { issuer?: string }).issuer - await saveTokens(this.store, this.target, tokens, issuer ?? "") + const issuer = ctx?.issuer ?? (tokens as { issuer?: string }).issuer ?? "" + await saveTokens(this.store, this.target, tokens, issuer, cliClient(issuer)) } redirectToAuthorization(authorizationUrl: URL) { @@ -251,21 +235,17 @@ class BrowserLoginProvider implements OAuthClientProvider { return this.discovery } - async invalidateCredentials( + invalidateCredentials( scope: "all" | "client" | "tokens" | "verifier" | "discovery", ) { if (scope === "verifier") this.verifier = "" if (scope === "discovery" || scope === "all") this.discovery = undefined - if (scope === "client" || scope === "all") - await this.store.update(this.target.url, (current) => - without(current, "client"), - ) } } interface Callback { port: number - wait: (timeoutMs: number) => Promise<URLSearchParams> + received: Promise<URLSearchParams> close: () => void } @@ -281,8 +261,11 @@ const tile = `<svg width="40" height="40" viewBox="0 0 32 32" aria-hidden="true" const page = (message: string) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Fulldev CLI
${tile}

${message}

` -/** Listens on 127.0.0.1 for the authorization server's redirect (RFC 8252). */ -async function listenForCallback(preferredPort?: number): Promise { +/** + * Listens on 127.0.0.1, on a port the system picks, for the authorization + * server's redirect (RFC 8252). + */ +async function listenForCallback(): Promise { let receive: (params: URLSearchParams) => void = () => {} const received = new Promise((resolve) => { receive = resolve @@ -305,42 +288,16 @@ async function listenForCallback(preferredPort?: number): Promise { () => receive(url.searchParams), ) }) - const listen = (port: number) => - new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(port, "127.0.0.1", () => { - server.off("error", reject) - resolve() - }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve() }) - try { - await listen(preferredPort ?? 0) - } catch (error) { - if (preferredPort === undefined) throw error - await listen(0) - } + }) return { port: (server.address() as AddressInfo).port, - wait: async (timeoutMs) => { - let timer: NodeJS.Timeout | undefined - const timeout = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new CliError( - "SIGN_IN_TIMEOUT", - `Sign-in timed out after ${Math.round(timeoutMs / 60_000)} minutes.`, - ), - ), - timeoutMs, - ) - }) - try { - return await Promise.race([received, timeout]) - } finally { - clearTimeout(timer) - } - }, + received, close: () => { server.closeAllConnections() server.close() @@ -348,6 +305,63 @@ async function listenForCallback(preferredPort?: number): Promise { } } +/** The query of a pasted address, when it is the redirect after sign-in. */ +function redirectQuery(line: string) { + try { + const { searchParams } = new URL(line.trim()) + return searchParams.has("state") ? searchParams : undefined + } catch { + return undefined + } +} + +/** + * Reads the address of the page after sign-in, pasted into the terminal. + * With the browser on another computer, such as over SSH, the redirect + * cannot reach this one's loopback port, but the address bar still holds + * the code. Other lines are asked for again; at the end of the input, the + * loopback redirect can still arrive. + */ +function readPastedRedirect( + input: NodeJS.ReadableStream, + log: (line: string) => void, +) { + const lines = createInterface({ input, terminal: false }) + const received = new Promise((resolve) => { + lines.on("line", (line) => { + const query = redirectQuery(line) + if (query) resolve(query) + else if (line.trim()) + log( + "That is not the address of the page after sign-in. Paste the whole address from the browser's address bar.", + ) + }) + }) + return { received, close: () => lines.close() } +} + +/** The first of the promises to settle, or a timeout error. */ +async function firstWithin(timeoutMs: number, promises: Array>) { + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new CliError( + "SIGN_IN_TIMEOUT", + `Sign-in timed out after ${Math.round(timeoutMs / 60_000)} minutes.`, + ), + ), + timeoutMs, + ) + }) + try { + return await Promise.race([...promises, timeout]) + } finally { + clearTimeout(timer) + } +} + /** Opens a URL in the system browser; the URL is printed as well. */ function openBrowser(url: string) { const [command, args] = @@ -366,27 +380,40 @@ function openBrowser(url: string) { } export interface BrowserLoginOptions { + /** Opens the system browser; without it the person opens the link and may paste the address after sign-in. */ browser: boolean log: (line: string) => void timeoutMs?: number fetchFn?: FetchLike + /** Where a pasted address is read without the browser; stdin by default. */ + input?: NodeJS.ReadableStream } /** * 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. + * app's MCP URL as the resource. Without the browser, the address after + * sign-in can also be pasted, for a browser on another computer. */ export async function browserLogin( target: Target, store: CredentialStore, - { browser, log, timeoutMs = 5 * 60_000, fetchFn }: BrowserLoginOptions, + { + browser, + log, + timeoutMs = 5 * 60_000, + fetchFn, + input = process.stdin, + }: BrowserLoginOptions, ) { - // A registered client (unknown issuer) keeps the port it was registered with. - const registered = redirectUris((await store.get(target.url)).client)[0] - const callback = await listenForCallback( - registered ? Number(new URL(registered).port) || undefined : undefined, - ) + const discovery = await discover(target, fetchFn) + if (discovery.metadata.client_id_metadata_document_supported !== true) + throw new CliError( + "CLIENT_METADATA_UNSUPPORTED", + `The authorization server ${discovery.issuer} does not support Client ID Metadata Documents, which fulldev needs to sign in to ${target.title}.`, + ) + const callback = await listenForCallback() + const pasted = browser ? undefined : readPastedRedirect(input, log) try { const state = randomBytes(16).toString("base64url") const provider = new BrowserLoginProvider( @@ -395,20 +422,37 @@ export async function browserLogin( `http://127.0.0.1:${callback.port}${callbackPath}`, state, (url) => { + if (browser) { + log( + `Sign in to ${target.title} in your browser. If it does not open, open:`, + ) + log(url.href) + openBrowser(url.href) + return + } + log(`Open this link to sign in to ${target.title}:`) + log(url.href) log( - `Sign in to ${target.title} in your browser. If it does not open, open:`, + "If the browser is on another computer, the page after sign-in does not load: copy its address from the address bar and paste it here.", ) - log(url.href) - if (browser) openBrowser(url.href) + }, + { + authorizationServerUrl: String(discovery.authorizationServerUrl), + resourceMetadata: discovery.resourceMetadata, + authorizationServerMetadata: discovery.metadata, }, ) const started = await auth(provider, { serverUrl: target.url, + scope: signInScope, forceReauthorization: true, ...(fetchFn ? { fetchFn } : {}), }) if (started === "AUTHORIZED") return - const params = await callback.wait(timeoutMs) + const params = await firstWithin(timeoutMs, [ + callback.received, + ...(pasted ? [pasted.received] : []), + ]) if (params.get("state") !== state) throw new CliError( "SIGN_IN_FAILED", @@ -430,12 +474,14 @@ export async function browserLogin( ) await auth(provider, { serverUrl: target.url, + scope: signInScope, authorizationCode: code, iss: params.get("iss") ?? undefined, ...(fetchFn ? { fetchFn } : {}), }) } finally { callback.close() + pasted?.close() } } @@ -471,7 +517,7 @@ export async function refreshTokens( const refreshToken = credentials.tokens?.refresh_token if (!refreshToken) throw new SignInRequiredError(target) const discovery = await discover(target, fetchFn) - const client = clientFor(discovery.issuer, credentials) + const { client } = credentials if ( !client || (credentials.issuer && credentials.issuer !== discovery.issuer) @@ -515,8 +561,16 @@ export async function revokeTokens( credentials: Credentials, fetchFn: FetchLike = fetch, ): Promise { - const { tokens } = credentials + const { tokens, client } = credentials if (!tokens) return { revoked: true } + // Tokens are revoked with the client they were issued to. A sign-in from + // before fulldev identified itself with its metadata document has none. + if (!client) + return { + revoked: false, + error: + "The sign-in is from an earlier version of fulldev and cannot be revoked.", + } try { const metadata: ServerMetadata | undefined = credentials.issuer ? await discoverAuthorizationServerMetadata(credentials.issuer, { @@ -529,14 +583,9 @@ export async function revokeTokens( revoked: false, error: "The authorization server has no revocation endpoint.", } - // Tokens are revoked with the client they were issued to, which for a - // sign-in from fulldev 0.1.0 is its dynamically registered client. - const client = credentials.client ?? fixedClient(metadata.issuer) - if (!client) - return { revoked: false, error: "No OAuth client to revoke with." } const failures: Array = [] - // Only the refresh token: Clerk's access tokens are JWTs that cannot be - // revoked and expire within a day. + // Only the refresh token: the access tokens are JWTs that cannot be + // revoked and expire within 15 minutes. for (const [token, hint] of [ [tokens.refresh_token, "refresh_token"], ] as const) { @@ -571,7 +620,7 @@ export async function revokeTokens( } /** - * Gives the MCP transport the stored access token of one product, refreshing + * Gives the MCP transport the stored access token of one app, refreshing * it when it is about to expire or the server answers 401. Refreshes happen * under the store's lock, and a token another process refreshed meanwhile is * used instead of refreshing again. It never starts a sign-in. diff --git a/src/products.test.ts b/src/products.test.ts deleted file mode 100644 index df56ba3..0000000 --- a/src/products.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from "vite-plus/test" - -import { parseCommandLine } from "./args.ts" -import { UsageError } from "./errors.ts" -import { - findProduct, - products, - resolveTarget, - urlVariable, -} from "./products.ts" - -const cms = findProduct("cms")! - -describe("products", () => { - it("has cms, connect, scan and sites with their MCP servers", () => { - expect(products.map(({ name, url }) => [name, url])).toEqual([ - ["cms", "https://cms.full.dev/mcp"], - ["connect", "https://connect.full.dev/mcp"], - ["scan", "https://scan.full.dev/mcp"], - ["sites", "https://sites.full.dev/mcp"], - ]) - for (const product of products) { - expect(product.title).toMatch(/^Fulldev /) - expect(product.description).not.toContain("\n") - } - }) - - it("names the URL variable after the product", () => { - expect(urlVariable(cms)).toBe("FULLDEV_CMS_URL") - expect(urlVariable({ ...cms, name: "my-product" })).toBe( - "FULLDEV_MY_PRODUCT_URL", - ) - }) - - it("takes the server from --url, then the variable, then the default", () => { - const preview = "https://deploy-preview-1--cms.netlify.app/mcp" - const other = "https://other.example.com/mcp" - expect(resolveTarget(cms, undefined, {})).toMatchObject({ - url: cms.url, - urlFromFlag: false, - }) - expect( - resolveTarget(cms, undefined, { FULLDEV_CMS_URL: preview }), - ).toMatchObject({ url: preview, urlFromFlag: false }) - expect( - resolveTarget(cms, other, { FULLDEV_CMS_URL: preview }), - ).toMatchObject({ url: other, urlFromFlag: true }) - }) - - it("applies a product's variable only to that product", () => { - const env = { FULLDEV_CMS_URL: "https://preview.example.com/mcp" } - const status = parseCommandLine(["status"], env) - expect( - "targets" in status && status.targets.map((target) => target.url), - ).toEqual([ - "https://preview.example.com/mcp", - "https://connect.full.dev/mcp", - "https://scan.full.dev/mcp", - "https://sites.full.dev/mcp", - ]) - expect(parseCommandLine(["connect", "tools"], env)).toMatchObject({ - target: { url: "https://connect.full.dev/mcp" }, - }) - }) - - it("applies --url to the named product", () => { - expect( - parseCommandLine( - ["login", "connect", "--url", "https://p.example/mcp"], - {}, - ), - ).toMatchObject({ - targets: [ - { name: "connect", url: "https://p.example/mcp", urlFromFlag: true }, - ], - }) - }) - - it("rejects a URL that is not http or https", () => { - expect(() => resolveTarget(cms, "file:///etc/passwd", {})).toThrow( - UsageError, - ) - expect(() => - resolveTarget(cms, undefined, { FULLDEV_CMS_URL: "nope" }), - ).toThrow(UsageError) - }) -}) diff --git a/src/products.ts b/src/products.ts deleted file mode 100644 index 373155a..0000000 --- a/src/products.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { UsageError } from "./errors.ts" - -/** A Fulldev product with a remote MCP server. Add a product with one entry. */ -export interface Product { - name: string - title: string - url: string - description: string - /** Has the wait_for_form tool, so `fulldev form wait` exists. */ - forms?: boolean -} - -export const products: Array = [ - { - 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.", - }, - { - name: "sites", - title: "Fulldev Sites", - url: "https://sites.full.dev/mcp", - description: - "Have Fulldev make a finished website from a brief and tested design options. For administrators only for now.", - }, -] - -/** A product with the server URL this run uses. */ -export interface Target extends Product { - /** True when --url chose the server, so commands must repeat it. */ - urlFromFlag: boolean -} - -export function findProduct(name: string): Product | undefined { - return products.find((product) => product.name === name) -} - -/** The environment variable that overrides a product's URL, such as FULLDEV_CMS_URL. */ -export function urlVariable(product: Product) { - return `FULLDEV_${product.name.toUpperCase().replaceAll(/[^A-Z0-9]/g, "_")}_URL` -} - -/** The product's server: --url, then FULLDEV__URL, then the default. */ -export function resolveTarget( - product: Product, - flagUrl: string | undefined, - env: NodeJS.ProcessEnv, -): Target { - const url = flagUrl ?? env[urlVariable(product)] ?? product.url - let parsed: URL - 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 !== undefined } -}