diff --git a/README.md b/README.md index cc436a6..db5bfb2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ Commands you run in a terminal check for a newer version at most once a day, whi Workspace commands need an Excalidraw+ API key. The [authentication docs](https://plus.excalidraw.com/docs/api/authentication) explain how to create one. ```bash -export EXCALIDRAW_API_KEY="your_api_key" +# Paste your API key once; later commands use it +excalidraw login # Check which workspace the key belongs to excalidraw whoami @@ -87,7 +88,9 @@ Rendering uses a browser that's already installed: Chrome, Edge or Chromium, or | Command | What it does | | --- | --- | -| `whoami` | Show the workspace, key type and API origin in use | +| `login` | Check an API key and save it for later commands | +| `logout` | Remove the saved API key | +| `whoami` | Show the workspace, key type, credential source and API origin in use | | `scenes` | List, create, rename, move, pin and trash scenes | | `scenes content` | Read, replace or patch a scene's drawing | | `scenes render` | Render a workspace scene to PNG | @@ -119,7 +122,8 @@ echo '{"appState":{"viewBackgroundColor":"#f8f9fa"}}' | excalidraw scenes conten ## Scripting and agents -- Commands print JSON. `-o table` prints list commands, `whoami` and `update` as a table. +- Commands print JSON, except `login` and `logout`, which print a confirmation. `-o table` prints list commands, `whoami` and `update` as a table. +- Set `EXCALIDRAW_API_KEY` rather than running `login`, which asks for the key in a terminal. `excalidraw login --api-key ` saves a key without asking. - Lists return 5 items by default (50 for logs), up to 100 with `--limit`. `--all` fetches every page. - `--raw` prints the API's response text as received, and shows API errors in full. - `-` as a file path reads from stdin for inputs (`render -`, `--file -`) and writes to stdout for `--out`. @@ -133,7 +137,7 @@ These flags can also be set with environment variables. | Flag | Environment variable | Default | | --- | --- | --- | -| `--api-key` | `EXCALIDRAW_API_KEY` | | +| `--api-key` | `EXCALIDRAW_API_KEY` | key saved by `login` | | `--api-url` | `EXCALIDRAW_API_URL` | `https://api.excalidraw.com` | | `-o, --output` | `EXCALIDRAW_OUTPUT` | `json` | | `--retries` | `EXCALIDRAW_RETRIES` | `3` | @@ -143,8 +147,14 @@ These flags can also be set with environment variables. For a self-hosted or staging instance, set `--api-url` to the origin, without `/api/v1`. +### API keys + There are two kinds of API key. Workspace keys act for the whole workspace. They can't see private scenes, so `scenes create` needs a real collection ID. Personal keys start with `uk-` and act as one user, with that user's role. They can pass `private` as the collection ID to use the owner's private collection. Run `excalidraw whoami` to see which kind you're using. +`excalidraw login` asks how to sign in (an API key for now; OAuth is coming), checks the key against the API and saves it. A key the API rejects isn't saved. Commands use the saved key when neither `--api-key` nor `EXCALIDRAW_API_KEY` is set; `whoami` reports which one is in use as `credentialSource` (`flag`, `env` or `login`). + +Keys are saved per API origin, so a staging key is only sent to staging: `excalidraw login --api-url https://staging.example.com` saves one next to your production key. They live in `~/.config/excalidraw-cli/credentials.json` (`$XDG_CONFIG_HOME` if set, `%APPDATA%` on Windows), readable only by you. `excalidraw logout` removes the key for the origin; to revoke the key itself, delete it in Excalidraw+. + The full API reference lives at [plus.excalidraw.com/docs/api](https://plus.excalidraw.com/docs/api). ## Contributing diff --git a/package.json b/package.json index 84076f9..c732d83 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "playwright-core": "1.63.0" }, "devDependencies": { + "@clack/prompts": "1.8.1", "@excalidraw/utils": "0.1.5", "@types/node": "^22.19.3", "@types/pngjs": "^6.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10d2360..c021f8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: 1.63.0 version: 1.63.0 devDependencies: + '@clack/prompts': + specifier: 1.8.1 + version: 1.8.1 '@excalidraw/utils': specifier: 0.1.5 version: 0.1.5 @@ -45,6 +48,14 @@ packages: '@braintree/sanitize-url@6.0.2': resolution: {integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==} + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -233,6 +244,15 @@ packages: engines: {node: '>=18'} hasBin: true + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -272,6 +292,9 @@ packages: roughjs@4.6.4: resolution: {integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sliced@1.0.1: resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} deprecated: Unsupported @@ -291,6 +314,18 @@ snapshots: '@braintree/sanitize-url@6.0.2': {} + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -428,6 +463,16 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + hachure-fill@0.5.2: {} pako@2.0.3: {} @@ -465,6 +510,8 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + sisteransi@1.0.5: {} + sliced@1.0.1: {} typescript@5.9.3: {} diff --git a/src/cli.ts b/src/cli.ts index 2228d3a..b0efd72 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { Command, Option } from "commander"; import { registerCollectionsCommands } from "./commands/collections.js"; +import { registerLoginCommands } from "./commands/login.js"; import { registerLogsCommands } from "./commands/logs.js"; import { registerScenesCommands } from "./commands/scenes.js"; import { registerWhoamiCommand } from "./commands/whoami.js"; @@ -24,7 +25,11 @@ export function createProgram() { "EXCALIDRAW_API_URL", ), ) - .addOption(new Option("--api-key ", "API key").env("EXCALIDRAW_API_KEY")) + .addOption( + new Option("--api-key ", 'API key; overrides the key "excalidraw login" saved').env( + "EXCALIDRAW_API_KEY", + ), + ) .addOption( new Option( "-o, --output ", @@ -54,6 +59,7 @@ export function createProgram() { "after", ` EXAMPLES + $ excalidraw login $ excalidraw whoami $ excalidraw scenes list --limit 25 $ excalidraw scenes list --all --output table @@ -64,10 +70,10 @@ EXAMPLES $ excalidraw logs list --operation update --limit 10 SETUP - Set EXCALIDRAW_API_KEY for non-interactive use, or pass --api-key per command. + Run "excalidraw login" once to save an API key. In scripts and CI, set EXCALIDRAW_API_KEY or pass --api-key instead; both take precedence over the saved key. Local "render" commands do not require an API key. - Use --api-url for development, staging, or self-hosted API origins. Pass the origin only; /api/v1 is added automatically. - Run "excalidraw whoami" to confirm which workspace and key type the CLI is using. + Use --api-url for development, staging, or self-hosted API origins. Pass the origin only; /api/v1 is added automatically. Saved keys are per origin. + Run "excalidraw whoami" to confirm which workspace, key type and credential source the CLI is using. OUTPUT Commands print formatted JSON by default. Use --output table for readable lists, or --raw for the raw API response text. @@ -98,6 +104,7 @@ LEARN MORE styleArgumentTerm: helpTheme.argument, }); + registerLoginCommands(program); registerWhoamiCommand(program); registerScenesCommands(program); registerRenderCommand(program); diff --git a/src/client.ts b/src/client.ts index 3267907..688ec18 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_RETRIES, resolveConfig } from "./config.js"; import type { Command } from "commander"; +import type { GlobalOptions } from "./config.js"; const ErrorResponseSchema = z .object({ @@ -208,7 +209,10 @@ function encodePathId(id: string) { } export function getClient(command: Command) { - const config = resolveConfig(command.optsWithGlobals()); + return createClient(resolveConfig(command)); +} + +export function createClient(config: GlobalOptions & { apiKey: string }) { return new PublicApiClient(config.apiUrl, config.apiKey, { retries: config.retries, timeout: config.requestTimeout, diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..acd70ae --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,172 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import { ApiError, createClient } from "../client.js"; +import { parseGlobalOptions } from "../config.js"; +import { credentialsPath, removeCredential, saveCredential } from "../credentials.js"; +import { stripControlCharacters } from "../io.js"; +import { fetchWorkspaceSummary } from "./whoami.js"; + +import type { GlobalOptions } from "../config.js"; + +const API_KEY_DOCS_URL = "https://plus.excalidraw.com/docs/api/authentication"; + +export function registerLoginCommands(program: Command) { + program + .command("login") + .summary("Sign in and save an API key for later commands") + .description( + "Pick a sign-in method, check the API key against the API, and save it for the API origin. Later commands use the saved key unless --api-key or EXCALIDRAW_API_KEY is set.", + ) + .addHelpText( + "after", + ` +EXAMPLES + $ excalidraw login + $ excalidraw login --api-key uk-... + $ excalidraw login --api-url https://staging.example.com + +NOTES + Need a key? See ${API_KEY_DOCS_URL} + --api-key skips the prompts, e.g. in scripts. + Keys are saved in ${credentialsPath()}, one per --api-url. +`, + ) + .action(async (_options, command: Command) => { + const options = parseGlobalOptions(command); + // Only a key typed on the command line counts as an answer: EXCALIDRAW_API_KEY is often set + // for other reasons, and saving it silently would surprise. + const fromFlag = command.getOptionValueSourceWithGlobals("apiKey") === "cli" ? options.apiKey : undefined; + + if (fromFlag) { + const { workspace, warning } = await logIn(options, fromFlag); + console.error(chalk.green(loggedInMessage(workspace))); + if (warning) { + console.error(chalk.yellow(warning)); + } + } else { + await logInInteractively(options); + } + + warnAboutEnvironmentKey("EXCALIDRAW_API_KEY is set, so commands use it instead of the saved key. Unset it to use the saved key."); + }); + + program + .command("logout") + .summary("Remove the saved API key") + .description("Remove the API key that login saved for the API origin.") + .addHelpText( + "after", + ` +EXAMPLES + $ excalidraw logout + $ excalidraw logout --api-url https://staging.example.com + +NOTES + logout prints a confirmation, not JSON, and succeeds when no key was saved. The key itself + stays valid: to revoke it, delete it in Excalidraw+. +`, + ) + .action(async (_options, command: Command) => { + const options = parseGlobalOptions(command); + + if (await removeCredential(options.apiUrl)) { + console.error(chalk.green(`Logged out of ${options.apiUrl}`)); + } else { + console.error(`No key was saved for ${options.apiUrl}`); + } + + warnAboutEnvironmentKey("EXCALIDRAW_API_KEY is still set, so commands keep using it."); + }); +} + +async function logInInteractively(options: GlobalOptions) { + if (!process.stdin.isTTY) { + throw new Error( + 'excalidraw login asks for the key in a terminal. In scripts, pass it with "excalidraw login --api-key ", or set EXCALIDRAW_API_KEY instead of logging in.', + ); + } + + // Loaded on demand, so other commands don't pay for it. + const prompts = await import("@clack/prompts"); + // Prompts go to stderr, which every command keeps for messages to people. + const output = process.stderr; + const cancelled = () => new Error("Login cancelled. Nothing was saved."); + + prompts.intro(`Log in to ${options.apiUrl}`, { output }); + + const method = await prompts.select({ + message: "Pick which method to sign in with:", + options: [ + { value: "api-key", label: "API key" }, + { value: "oauth", label: "OAuth", hint: "coming soon!", disabled: true }, + ], + output, + }); + if (prompts.isCancel(method)) { + throw cancelled(); + } + + prompts.log.message(`Need an API key? See ${API_KEY_DOCS_URL}`, { output }); + const apiKey = await prompts.password({ + message: "Paste your API key:", + validate: (value) => (value?.trim() ? undefined : "Paste an API key, or press Ctrl+C to cancel."), + output, + }); + if (prompts.isCancel(apiKey)) { + throw cancelled(); + } + + const spinner = prompts.spinner({ output }); + spinner.start("Checking the key"); + + try { + const { workspace, warning } = await logIn(options, apiKey.trim()); + spinner.stop("Key accepted"); + if (warning) { + prompts.log.warn(warning, { output }); + } + prompts.outro(loggedInMessage(workspace), { output }); + } catch (error) { + spinner.error("Login failed"); + throw error; + } +} + +/** + * Checks the key against the API, then saves it for the API origin. A rejected key isn't saved. + * Returns the key's workspace, or null with a warning when the API won't show it. + */ +async function logIn(options: GlobalOptions, apiKey: string) { + const client = createClient({ ...options, apiKey }); + let workspace: Awaited> | null = null; + let warning: string | undefined; + + try { + workspace = await fetchWorkspaceSummary(client); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + throw new Error(`The API rejected the key (HTTP 401: ${error.message}), so it was not saved.`, { cause: error }); + } + if (!(error instanceof ApiError && error.status === 403)) { + throw error; + } + // The key is valid, but it lacks the permission to read the workspace, or the workspace was + // canceled or removed. The API's message says which. + warning = `Saved the key, but the API refused to show its workspace (HTTP 403: ${stripControlCharacters(error.message)}).`; + } + + await saveCredential(options.apiUrl, { type: "api-key", apiKey }); + + return { workspace, warning }; +} + +function loggedInMessage(workspace: { name: string } | null) { + return workspace ? `Logged in to ${stripControlCharacters(workspace.name)}` : "Logged in"; +} + +/** The variable takes precedence over the saved key, which could make login and logout look like they did nothing. */ +function warnAboutEnvironmentKey(message: string) { + if (process.env.EXCALIDRAW_API_KEY?.trim()) { + console.error(chalk.yellow(message)); + } +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 032fc7f..ac7a9c3 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,24 +1,37 @@ import { Command } from "commander"; import { z } from "zod/v4"; -import { getClient } from "../client.js"; +import { createClient } from "../client.js"; +import { resolveConfig } from "../config.js"; import { emitOutput } from "../output.js"; import { keyValueTable } from "../tables.js"; +import type { PublicApiClient } from "../client.js"; + const WorkspaceSummarySchema = z .object({ id: z.string(), name: z.string(), subscriptionStatus: z.string(), - users: z.array(z.unknown()).optional(), }) .loose(); +/** Fetches the summary of the key's workspace that whoami and login print. */ +export async function fetchWorkspaceSummary(client: PublicApiClient) { + const workspace = WorkspaceSummarySchema.parse(await client.request("GET", "/workspaces", { raw: false })); + + return { + id: workspace.id, + name: workspace.name, + subscriptionStatus: workspace.subscriptionStatus, + }; +} + export function registerWhoamiCommand(program: Command) { program .command("whoami") - .summary("Show which workspace and key type the CLI is using") + .summary("Show which workspace and key the CLI is using") .description( - "Validate the configured API key by fetching its workspace, and report the key type and API origin.", + "Validate the configured API key by fetching its workspace, and report the key type, where the key came from, and the API origin.", ) .addHelpText( "after", @@ -31,27 +44,19 @@ EXAMPLES NOTES Run this first when a command fails with HTTP 401 or 403. It shows which workspace the key belongs to and whether it is a personal (uk-) or workspace key. + credentialSource is "flag" for --api-key, "env" for EXCALIDRAW_API_KEY, or "login" for the + key that "excalidraw login" saved. The flag and the variable take precedence over a saved key. A workspace key limited to certain permissions can get HTTP 403 here even though it is valid. `, ) .action(async (_options, command: Command) => { - const client = getClient(command); - const workspace = WorkspaceSummarySchema.parse( - await client.request("GET", "/workspaces", { raw: false }), - ); + const config = resolveConfig(command); + const client = createClient(config); + const workspace = await fetchWorkspaceSummary(client); emitOutput( command, - { - apiUrl: client.apiUrl, - keyType: client.keyType, - workspace: { - id: workspace.id, - name: workspace.name, - subscriptionStatus: workspace.subscriptionStatus, - userCount: workspace.users?.length ?? null, - }, - }, + { apiUrl: client.apiUrl, keyType: client.keyType, credentialSource: config.credentialSource, workspace }, keyValueTable, ); }); diff --git a/src/config.ts b/src/config.ts index e706f0f..c60423b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,8 @@ import { z } from "zod/v4"; +import { readCredential } from "./credentials.js"; import { emptyToUndefined } from "./schemas.js"; +import type { Command } from "commander"; import type { $ZodIssue } from "zod/v4/core"; const DEFAULT_API_URL = "https://api.excalidraw.com"; @@ -22,10 +24,8 @@ const OutputOptionsSchema = z.object({ const ConfigSchema = OutputOptionsSchema.extend({ apiUrl: z.preprocess(emptyToUndefined, z.url().default(DEFAULT_API_URL)), - apiKey: z.preprocess( - (value) => (typeof value === "string" ? value.trim() : ""), - z.string().min(1), - ), + // Blank means unset, so the key saved by `excalidraw login` applies. + apiKey: z.preprocess(emptyToUndefined, z.string().trim().optional()), retries: z.preprocess( emptyToUndefined, z.coerce.number().int().min(0).max(MAX_RETRIES).default(DEFAULT_RETRIES), @@ -41,7 +41,12 @@ const ConfigSchema = OutputOptionsSchema.extend({ ), }); -export type RuntimeConfig = z.infer; +export type GlobalOptions = z.infer; + +/** Where the credential came from: --api-key, EXCALIDRAW_API_KEY, or what `excalidraw login` saved. */ +export type CredentialSource = "flag" | "env" | "login"; + +export type RuntimeConfig = GlobalOptions & { apiKey: string; credentialSource: CredentialSource }; export type OutputOptions = z.infer; @@ -52,8 +57,9 @@ export class ConfigError extends Error { } } -export function resolveConfig(options: unknown): RuntimeConfig { - const result = ConfigSchema.safeParse(options); +/** Parses the global flags. `apiKey` is set only when --api-key or EXCALIDRAW_API_KEY is. */ +export function parseGlobalOptions(command: Command): GlobalOptions { + const result = ConfigSchema.safeParse(command.optsWithGlobals()); if (!result.success) { throw new ConfigError(result.error.issues.map(formatConfigIssue)); @@ -67,6 +73,27 @@ export function resolveConfig(options: unknown): RuntimeConfig { }; } +/** Resolves the global flags and the API key: --api-key, then EXCALIDRAW_API_KEY, then the key saved for the API origin. */ +export function resolveConfig(command: Command): RuntimeConfig { + const options = parseGlobalOptions(command); + + if (options.apiKey) { + // commander merges the flag and its environment variable into one option, and records which one it used. + const credentialSource = command.getOptionValueSourceWithGlobals("apiKey") === "env" ? "env" : "flag"; + return { ...options, apiKey: options.apiKey, credentialSource }; + } + + const saved = readCredential(options.apiUrl); + + if (!saved) { + throw new ConfigError([ + `Missing API key for ${options.apiUrl}. Run "excalidraw login", pass --api-key , or set EXCALIDRAW_API_KEY.`, + ]); + } + + return { ...options, apiKey: saved.apiKey, credentialSource: "login" }; +} + /** * Strips a trailing slash or a pasted "/api/v1" suffix from the origin. This only * affects the origin string the CLI displays (e.g. in whoami): requests are built @@ -89,10 +116,6 @@ export function getOutputOptions(options: unknown): OutputOptions { function formatConfigIssue(issue: $ZodIssue) { const field = issue.path[0]; - if (field === "apiKey") { - return "Missing API key. Pass --api-key or set EXCALIDRAW_API_KEY."; - } - if (field === "apiUrl") { return "Invalid API URL. Pass --api-url or set EXCALIDRAW_API_URL."; } diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..b9c3113 --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,178 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { z } from "zod/v4"; + +// OAuth tokens will be another member of this union. +const CredentialSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("api-key"), apiKey: z.string().min(1) }), +]); + +export type Credential = z.infer; + +/** + * Bumped only for changes that older versions of the CLI can't read or rewrite safely: they refuse a + * newer file rather than misread or overwrite it. Additive changes keep the version, because a rewrite + * keeps fields it doesn't know and an unknown credential type asks for an upgrade. + */ +const FILE_VERSION = 1; + +const CredentialsFileSchema = z + .object({ + version: z.literal(FILE_VERSION), + // Keyed by API origin. + origins: z.record(z.string(), z.unknown()), + }) + .loose(); + +type CredentialsFile = z.infer; + +/** + * Where `excalidraw login` saves credentials. macOS uses ~/.config too, like gh: it's where terminal + * users look for a CLI's settings. + */ +export function credentialsPath() { + const base = process.env.XDG_CONFIG_HOME + ? process.env.XDG_CONFIG_HOME + : process.platform === "win32" + ? (process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")) + : join(homedir(), ".config"); + return join(base, "excalidraw-cli", "credentials.json"); +} + +/** + * Credentials are saved per API origin, the host requests go to, so a key is only ever sent to the + * origin it was saved for, even when --api-url points somewhere else. + */ +export function readCredential(apiUrl: string): Credential | undefined { + const origin = new URL(apiUrl).origin; + const { origins } = readCredentialsFile(); + + if (!Object.hasOwn(origins, origin)) { + return undefined; + } + + const result = CredentialSchema.safeParse(origins[origin]); + + if (!result.success) { + // Most likely a credential type that a newer version of the CLI saved. + throw new Error( + `Can't use the credentials saved for ${origin} in ${credentialsPath()}. If a newer version of the CLI saved them, upgrade it; otherwise run "excalidraw login" to replace them.`, + ); + } + + return result.data; +} + +export async function saveCredential(apiUrl: string, credential: Credential) { + // Logging in again is how to recover from a damaged file, so it starts a new one. + const file = readCredentialsFile({ replaceInvalid: true }); + file.origins[new URL(apiUrl).origin] = credential; + await writeCredentialsFile(file); +} + +/** Returns whether there was a credential to remove. */ +export async function removeCredential(apiUrl: string) { + const file = readCredentialsFile(); + const origin = new URL(apiUrl).origin; + + if (!Object.hasOwn(file.origins, origin)) { + return false; + } + + delete file.origins[origin]; + await writeCredentialsFile(file); + return true; +} + +function readCredentialsFile({ replaceInvalid = false } = {}): CredentialsFile { + const path = credentialsPath(); + let text: string; + + try { + text = readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { version: FILE_VERSION, origins: {} }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + + // Checked before the layout, which a newer version may have changed. + const version = parsed !== null && typeof parsed === "object" ? (parsed as { version?: unknown }).version : undefined; + if (typeof version === "number" && version > FILE_VERSION) { + throw new Error(`${path} was saved by a newer version of the excalidraw CLI. Upgrade the CLI to use it.`); + } + + const result = CredentialsFileSchema.safeParse(parsed); + + if (!result.success) { + if (replaceInvalid) { + return { version: FILE_VERSION, origins: {} }; + } + throw new Error(`Can't read the saved credentials in ${path}. Run "excalidraw login" again.`); + } + + return result.data; +} + +async function writeCredentialsFile(file: CredentialsFile) { + const path = credentialsPath(); + + try { + if (Object.keys(file.origins).length === 0) { + await retryWhileLocked(() => rm(path, { force: true })); + return; + } + + // The file holds API keys, so only the user may read it. Windows ignores the modes; the + // profile directory is private there already. + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + // Written next to the file and renamed over it, so an interrupted write can't truncate it. + const temporary = `${path}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + try { + await retryWhileLocked(() => rename(temporary, path)); + } finally { + // Already gone after the rename. If the rename failed, it's a copy of the keys to clean up. + await rm(temporary, { force: true }); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? (error instanceof Error ? error.message : String(error)); + const hint = isLocked(error) ? " Another program, such as antivirus or a backup tool, may have it open; try again." : ""; + throw new Error(`Could not update ${path} (${code}).${hint}`, { cause: error }); + } +} + +const LOCK_ATTEMPTS = 5; + +/** + * Windows can't replace or delete a file that another program has open, such as antivirus scanning + * it right after a write. Those locks are usually brief, so wait for them: 1 s in total. + */ +async function retryWhileLocked(action: () => Promise) { + for (let attempt = 1; ; attempt++) { + try { + return await action(); + } catch (error) { + if (!isLocked(error) || attempt === LOCK_ATTEMPTS) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); + } + } +} + +function isLocked(error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + return process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY"); +} diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 93f068a..19c589c 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -79,6 +79,25 @@ liveTest("whoami", async (t) => { t.diagnostic(`keyType: ${identity.keyType}, workspace: ${identity.workspace.name}`); }); +liveTest("login checks and saves the key, whoami uses it, and logout removes it", async () => { + assert.ok(tempDir && liveApiEnv.EXCALIDRAW_API_KEY); + const env = cliEnv({ ...liveApiEnv, EXCALIDRAW_API_KEY: "", XDG_CONFIG_HOME: join(tempDir, "config") }); + // Failures report stderr, not the command line, which holds the key. + const run = async (args: string[]) => { + try { + return await execFileAsync(process.execPath, [cliPath, ...args], { env }); + } catch (error) { + assertExecError(error); + throw new Error(`excalidraw ${args[0]} failed:\n${error.stderr}`); + } + }; + + await assert.rejects(() => run(["login", "--api-key", "not-a-valid-key"]), /The API rejected the key \(HTTP 401/); + assert.match((await run(["login", "--api-key", liveApiEnv.EXCALIDRAW_API_KEY])).stderr, /^Logged in/); + assert.equal(JSON.parse((await run(["whoami"])).stdout).credentialSource, "login"); + assert.match((await run(["logout"])).stderr, /^Logged out of /); +}); + liveTest("collections list", async (t) => { const result = await runStepJson>(t, "List collections", ["collections", "list", "--limit", "20", "--offset", "0"]); assert.ok(Array.isArray(result.data), "collections list should return data array"); diff --git a/test/features.test.ts b/test/features.test.ts index 07956f6..4dde144 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -1,14 +1,14 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, open, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, open, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { createServer, type OutgoingHttpHeaders } from "node:http"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import test from "node:test"; -import { assertExecError, cliEnv, runWithStdout } from "./helpers.ts"; +import { assertExecError, cliEnv, escapeRegExp, runWithStdout } from "./helpers.ts"; const execFileAsync = promisify(execFile); const cliPath = fileURLToPath(new URL("../dist/main.js", import.meta.url)); @@ -59,6 +59,9 @@ const HUGE_LIST = 150_000; /** An error page like a gateway's, far longer than what stderr should show by default. */ const LONG_ERROR_PAGE = `${"Bad request from the gateway. ".repeat(100)}`; +/** A valid workspace key whose permissions don't include reading the workspace. */ +const LIMITED_KEY = "limited-key"; + /** Minimal stand-in for the public API: offset lists, cursor logs, workspace, content writes, and 429s. */ function createMockApi() { const calls: ApiCall[] = []; @@ -77,10 +80,14 @@ function createMockApi() { res.end(JSON.stringify(payload)); }; - if (req.headers.authorization !== "Bearer test-key" && req.headers.authorization !== "Bearer uk-test-key") { + if (!["Bearer test-key", "Bearer uk-test-key", `Bearer ${LIMITED_KEY}`].includes(req.headers.authorization ?? "")) { return send(401, { statusCode: 401, error: "Unauthorized", message: "Unauthorized" }); } + if (url.pathname === "/api/v1/workspaces" && req.headers.authorization === `Bearer ${LIMITED_KEY}`) { + return send(403, { statusCode: 403, error: "Forbidden", message: "Missing permission" }); + } + if (url.pathname === "/api/v1/scenes" && req.method === "GET") { const limit = Number(url.searchParams.get("limit") ?? 5); const offset = Number(url.searchParams.get("offset") ?? 0); @@ -468,7 +475,7 @@ test("whoami reports key type and workspace summary", async () => { const personal = JSON.parse((await runCli(["whoami"], { origin, env: { EXCALIDRAW_API_KEY: "uk-test-key" } })).stdout); assert.equal(personal.keyType, "personal"); assert.equal(personal.apiUrl, origin); - assert.deepEqual(personal.workspace, { id: "ws_1", name: "Acme", subscriptionStatus: "active", userCount: 2 }); + assert.deepEqual(personal.workspace, { id: "ws_1", name: "Acme", subscriptionStatus: "active" }); const workspace = JSON.parse((await runCli(["whoami"], { origin })).stdout); assert.equal(workspace.keyType, "workspace"); @@ -479,6 +486,163 @@ test("whoami reports key type and workspace summary", async () => { }); }); +test("login saves the key per API origin, and commands use it unless a flag or variable overrides it", async (t) => { + const config = await mkdtemp(join(tmpdir(), "excalidraw-cli-config-")); + t.after(() => rm(config, { recursive: true, force: true })); + const file = join(config, "excalidraw-cli", "credentials.json"); + // A blank variable counts as unset, so the saved key applies. + const env = { XDG_CONFIG_HOME: config, EXCALIDRAW_API_KEY: "" }; + + await withApi(async ({ origin }) => { + await withApi(async ({ origin: other }) => { + const login = await runCli(["login", "--api-key", "uk-test-key"], { origin, env }); + assert.equal(login.stdout, ""); + assert.equal(login.stderr, "Logged in to Acme\n"); + await runCli(["login", "--api-key", "test-key"], { origin: other, env }); + + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { + version: 1, + origins: { + [origin]: { type: "api-key", apiKey: "uk-test-key" }, + [other]: { type: "api-key", apiKey: "test-key" }, + }, + }); + if (process.platform !== "win32") { + assert.equal((await stat(file)).mode & 0o777, 0o600); + assert.equal((await stat(dirname(file))).mode & 0o777, 0o700); + } + + const whoami = async (origin: string, args: string[] = [], extraEnv = {}) => + JSON.parse((await runCli(["whoami", ...args], { origin, env: { ...env, ...extraEnv } })).stdout); + assert.deepEqual(await whoami(origin), { ...(await whoami(origin, ["--api-key", "uk-test-key"])), credentialSource: "login" }); + assert.equal((await whoami(other)).keyType, "workspace"); + // The mock rejects other keys, so a workspace key type proves which key was sent. + assert.deepEqual(await whoami(origin, [], { EXCALIDRAW_API_KEY: "test-key" }), { + apiUrl: origin, + keyType: "workspace", + credentialSource: "env", + workspace: { id: "ws_1", name: "Acme", subscriptionStatus: "active" }, + }); + assert.equal((await whoami(origin, ["--api-key", "test-key"])).credentialSource, "flag"); + + assert.deepEqual(await runCli(["logout"], { origin: other, env }), { stdout: "", stderr: `Logged out of ${other}\n` }); + assert.deepEqual(await runCli(["logout"], { origin: other, env }), { stdout: "", stderr: `No key was saved for ${other}\n` }); + await assert.rejects( + () => runCli(["scenes", "list"], { origin: other, env }), + (error: unknown) => { + assertExecError(error); + assert.match(error.stderr, new RegExp(`Missing API key for ${escapeRegExp(other)}\\. Run "excalidraw login"`)); + return true; + }, + ); + assert.equal((await whoami(origin)).credentialSource, "login", "logging out of one origin keeps the others"); + + await runCli(["logout"], { origin, env }); + assert.equal(existsSync(file), false, "the file is removed with its last key"); + }); + }); +}); + +test("login doesn't save a key the API rejects, and doesn't save EXCALIDRAW_API_KEY by itself", async (t) => { + const config = await mkdtemp(join(tmpdir(), "excalidraw-cli-config-")); + t.after(() => rm(config, { recursive: true, force: true })); + const file = join(config, "excalidraw-cli", "credentials.json"); + + await withApi(async ({ origin }) => { + await assert.rejects( + () => runCli(["login", "--api-key", "wrong-key"], { origin, env: { XDG_CONFIG_HOME: config } }), + (error: unknown) => { + assertExecError(error); + assert.equal(error.code, 1); + assert.match(error.stderr, /The API rejected the key \(HTTP 401: Unauthorized\), so it was not saved\./); + return true; + }, + ); + + // Without a terminal, login can't ask, and the variable isn't an answer. + await assert.rejects( + () => runCli(["login"], { origin, env: { XDG_CONFIG_HOME: config } }), + (error: unknown) => { + assertExecError(error); + assert.match(error.stderr, /asks for the key in a terminal\. In scripts, pass it with "excalidraw login --api-key "/); + return true; + }, + ); + assert.equal(existsSync(file), false); + + // The saved key works, but the variable still wins, so login and logout say so. + const login = await runCli(["login", "--api-key", "uk-test-key"], { origin, env: { XDG_CONFIG_HOME: config } }); + assert.match(login.stderr, /EXCALIDRAW_API_KEY is set, so commands use it instead of the saved key/); + const logout = await runCli(["logout"], { origin, env: { XDG_CONFIG_HOME: config } }); + assert.match(logout.stderr, /EXCALIDRAW_API_KEY is still set/); + }); +}); + +test("login saves a valid key that can't read the workspace, and says so", async (t) => { + const config = await mkdtemp(join(tmpdir(), "excalidraw-cli-config-")); + t.after(() => rm(config, { recursive: true, force: true })); + const env = { XDG_CONFIG_HOME: config, EXCALIDRAW_API_KEY: "" }; + + await withApi(async ({ origin, calls }) => { + const { stderr } = await runCli(["login", "--api-key", LIMITED_KEY], { origin, env }); + assert.equal(stderr, "Logged in\nSaved the key, but the API refused to show its workspace (HTTP 403: Missing permission).\n"); + + await runCli(["scenes", "list"], { origin, env }); + assert.equal(calls.at(-1)?.path, "/api/v1/scenes"); + }); +}); + +test("login replaces a damaged credentials file, and leaves one from a newer CLI alone", async (t) => { + const config = await mkdtemp(join(tmpdir(), "excalidraw-cli-config-")); + t.after(() => rm(config, { recursive: true, force: true })); + const file = join(config, "excalidraw-cli", "credentials.json"); + await mkdir(dirname(file)); + const env = { XDG_CONFIG_HOME: config, EXCALIDRAW_API_KEY: "" }; + + await withApi(async ({ origin }) => { + const rejects = (args: string[], message: RegExp) => + assert.rejects( + () => runCli(args, { origin, env }), + (error: unknown) => { + assertExecError(error); + assert.match(error.stderr, message); + return true; + }, + ); + const invalid = new RegExp(`Can't read the saved credentials in ${escapeRegExp(file)}\\. Run "excalidraw login" again\\.`); + + await writeFile(file, "{ truncated"); + await rejects(["scenes", "list"], invalid); + // An explicit key doesn't need the file. + await runCli(["scenes", "list"], { origin, env: { ...env, EXCALIDRAW_API_KEY: "test-key" } }); + + await writeFile(file, JSON.stringify({ [origin]: { type: "api-key", apiKey: "test-key" } })); + await rejects(["scenes", "list"], invalid); + await runCli(["login", "--api-key", "test-key"], { origin, env }); + await runCli(["scenes", "list"], { origin, env }); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { version: 1, origins: { [origin]: { type: "api-key", apiKey: "test-key" } } }); + + // A newer CLI may have changed the layout, so the file is left alone. + const newer = JSON.stringify({ version: 2, accounts: [] }); + await writeFile(file, newer); + await rejects(["scenes", "list"], /was saved by a newer version of the excalidraw CLI\. Upgrade the CLI to use it\./); + await rejects(["login", "--api-key", "test-key"], /was saved by a newer version/); + await rejects(["logout"], /was saved by a newer version/); + assert.equal(await readFile(file, "utf8"), newer); + + // A credential type a newer CLI added: login replaces it, and other fields survive the rewrite. + await writeFile(file, JSON.stringify({ version: 1, origins: { [origin]: { type: "oauth", token: "x" } }, defaults: { a: 1 } })); + await rejects(["scenes", "list"], new RegExp(`Can't use the credentials saved for ${escapeRegExp(origin)} in .*If a newer version of the CLI saved them, upgrade it`)); + await runCli(["login", "--api-key", "test-key"], { origin, env }); + await runCli(["scenes", "list"], { origin, env }); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { + version: 1, + origins: { [origin]: { type: "api-key", apiKey: "test-key" } }, + defaults: { a: 1 }, + }); + }); +}); + test("--restricted-domains sends an array on create-link and null for none on update", async () => { await withApi(async ({ origin, calls }) => { const created = JSON.parse( diff --git a/test/helpers.ts b/test/helpers.ts index bc96d6a..9b10b18 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; type ExecError = Error & { @@ -12,6 +14,8 @@ type ExecError = Error & { // The CLI's own settings, except the browser path the render tests may need, and the color switches commander and chalk read. const CLI_SETTING = /^EXCALIDRAW_(?!BROWSER_PATH$)/; const COLOR_SWITCHES = new Set(["NO_COLOR", "FORCE_COLOR", "CLICOLOR_FORCE"]); +/** A config directory that doesn't exist, so the key the developer saved with `excalidraw login` isn't used. Tests that log in pass their own. */ +const NO_CONFIG = join(tmpdir(), `excalidraw-cli-test-no-config-${process.pid}`); /** * The developer's environment without the variables that change what the CLI does or prints, plus `env`. @@ -19,7 +23,11 @@ const COLOR_SWITCHES = new Set(["NO_COLOR", "FORCE_COLOR", "CLICOLOR_FORCE"]); */ export function cliEnv(env: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const inherited = Object.entries(process.env).filter(([name]) => !CLI_SETTING.test(name) && !COLOR_SWITCHES.has(name)); - return { ...Object.fromEntries(inherited), NODE_NO_WARNINGS: "1", ...env }; + return { ...Object.fromEntries(inherited), NODE_NO_WARNINGS: "1", XDG_CONFIG_HOME: NO_CONFIG, ...env }; +} + +export function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function assertExecError(error: unknown): asserts error is ExecError { diff --git a/test/integration.test.ts b/test/integration.test.ts index b1029dd..9c0deaf 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import test from "node:test"; -import { assertExecError, cliEnv } from "./helpers.ts"; +import { assertExecError, cliEnv, escapeRegExp } from "./helpers.ts"; const execFileAsync = promisify(execFile); const cliPath = fileURLToPath(new URL("../dist/main.js", import.meta.url)); @@ -14,6 +14,8 @@ test("help output exposes the command tree", async () => { { args: ["--help"], expected: [ + "login", + "logout", "whoami", "render", "scenes", @@ -29,6 +31,7 @@ test("help output exposes the command tree", async () => { "--api-url ", ], }, + { args: ["login", "--help"], expected: ["plus.excalidraw.com/docs/api/authentication", "credentials.json", "--api-key uk-..."] }, { args: ["scenes", "--help"], expected: ["list", "get ", "create", "update", "delete", "content", "render"] }, { args: ["render", "--help"], expected: ["--out ", "--frame-id ", "--browser-path ", "No account or API key"] }, { args: ["scenes", "render", "--help"], expected: ["--out ", "--scale ", "--max-width "] }, @@ -128,8 +131,8 @@ test("missing API key prints a setup hint", async () => { assertExecError(error); assert.equal(error.code, 1); assert.match(error.stderr, /Configuration error:/); - assert.match(error.stderr, /Missing API key/); - assert.match(error.stderr, /--api-key /); + assert.match(error.stderr, /Missing API key for https:\/\/api\.excalidraw\.com\./); + assert.match(error.stderr, /Run "excalidraw login", pass --api-key , or set EXCALIDRAW_API_KEY\./); assert.doesNotMatch(error.stderr, /expected string, received undefined/); return true; }, @@ -148,7 +151,3 @@ async function runCli(args: string[], options: { env?: NodeJS.ProcessEnv } = {}) throw error; } } - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -}