From 28939322a9a63c6440efe6aef41a3058ec6137a6 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Thu, 15 Oct 2026 17:42:31 +0300 Subject: [PATCH] feat(api): find and call any public Base44 API from the spec base44 api catalog | search | grep | fetch, driven at runtime by the server's OpenAPI spec (GET /api/openapi.json), cached per server under ~/.base44/cache// for an hour (--refresh forces a download). - Listings print {endpoint, summary}, as the MCP bridge does; --read-only filters to operations a read-only token may call. - catalog lists areas (the path segment after {app_id}, else after /api), or one area's operations, capped at 200. - search (MiniSearch) ranks summaries holding every query word first. - grep matches each operation's contract with schemas inlined. - fetch sends the path as is through base44Client, with no timeout and no local validation; --help after a path prints the operation's contract; --read-only refuses unsafe operations before sending; the body comes from --body JSON, @file or @-, or --form for multipart (file uploads); --output saves the response. A rate-limited call shows Retry-After. - The path is normalized once (dot segments resolved as the request URL resolves them), and that path is both checked by --read-only and sent. - JSON is passed through byte for byte when piped (no int64 rounding) and pretty-printed only in a terminal; bytes that aren't UTF-8 with no content type are described as binary. - Component $refs to parameters, request bodies and responses resolve. - A malformed route in the spec is skipped, not fatal. - A failed call reports no request or response body, --body, --form or query string to telemetry. The step-by-step history is on amitb/api-command-history. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 + docs/commands.md | 13 + packages/cli/README.md | 4 + packages/cli/package.json | 1 + packages/cli/src/cli/commands/api/catalog.ts | 44 + packages/cli/src/cli/commands/api/fetch.ts | 237 ++++++ packages/cli/src/cli/commands/api/grep.ts | 76 ++ packages/cli/src/cli/commands/api/index.ts | 14 + packages/cli/src/cli/commands/api/search.ts | 47 ++ packages/cli/src/cli/commands/api/shared.ts | 144 ++++ packages/cli/src/cli/program.ts | 4 + packages/cli/src/cli/utils/json.ts | 12 +- packages/cli/src/core/api-spec/catalog.ts | 81 ++ packages/cli/src/core/api-spec/contract.ts | 240 ++++++ packages/cli/src/core/api-spec/grep.ts | 22 + packages/cli/src/core/api-spec/index.ts | 8 + packages/cli/src/core/api-spec/operations.ts | 170 ++++ packages/cli/src/core/api-spec/request.ts | 102 +++ packages/cli/src/core/api-spec/schema.ts | 67 ++ packages/cli/src/core/api-spec/search.ts | 132 +++ packages/cli/src/core/api-spec/spec.ts | 88 ++ packages/cli/src/core/api-spec/text.ts | 12 + packages/cli/src/core/config.ts | 2 +- packages/cli/src/core/utils/fs.ts | 2 +- packages/cli/tests/cli/api.spec.ts | 783 ++++++++++++++++++ packages/cli/tests/cli/api_redaction.spec.ts | 124 +++ packages/cli/tests/core/api-spec.spec.ts | 534 ++++++++++++ .../cli/tests/fixtures/api-spec/openapi.json | 1 + 28 files changed, 2962 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/cli/commands/api/catalog.ts create mode 100644 packages/cli/src/cli/commands/api/fetch.ts create mode 100644 packages/cli/src/cli/commands/api/grep.ts create mode 100644 packages/cli/src/cli/commands/api/index.ts create mode 100644 packages/cli/src/cli/commands/api/search.ts create mode 100644 packages/cli/src/cli/commands/api/shared.ts create mode 100644 packages/cli/src/core/api-spec/catalog.ts create mode 100644 packages/cli/src/core/api-spec/contract.ts create mode 100644 packages/cli/src/core/api-spec/grep.ts create mode 100644 packages/cli/src/core/api-spec/index.ts create mode 100644 packages/cli/src/core/api-spec/operations.ts create mode 100644 packages/cli/src/core/api-spec/request.ts create mode 100644 packages/cli/src/core/api-spec/schema.ts create mode 100644 packages/cli/src/core/api-spec/search.ts create mode 100644 packages/cli/src/core/api-spec/spec.ts create mode 100644 packages/cli/src/core/api-spec/text.ts create mode 100644 packages/cli/tests/cli/api.spec.ts create mode 100644 packages/cli/tests/cli/api_redaction.spec.ts create mode 100644 packages/cli/tests/core/api-spec.spec.ts create mode 100644 packages/cli/tests/fixtures/api-spec/openapi.json diff --git a/bun.lock b/bun.lock index 2aba89195..cb1a75120 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "jsonwebtoken": "^9.0.3", "ky": "^1.14.2", "lodash": "^4.17.23", + "minisearch": "^7.1.2", "ms": "^2.1.3", "multer": "^2.0.0", "nanoid": "^5.1.6", @@ -830,6 +831,8 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minisearch": ["minisearch@7.1.2", "", {}, "sha512-R1Pd9eF+MD5JYDDSPAp/q1ougKglm14uEkPMvQ/05RGmx6G9wvmLTrTI/Q5iPNJLYqNdsDQ7qTGIcNWR+FrHmA=="], + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], diff --git a/docs/commands.md b/docs/commands.md index 5247aacbd..c7edf353a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -32,6 +32,19 @@ To add support, set `supportsBranch: true` on the command and consume accepting the query parameter alone is not proof of branch isolation. Omitting the flag preserves existing main-app behavior. +## API commands (spec-driven) + +`src/cli/commands/api/` (`catalog`, `search`, `grep`, `fetch`) is driven by the server's OpenAPI spec at runtime, with no per-endpoint code. The logic lives in `src/core/api-spec/`. + +- The spec (`GET /api/openapi.json`) is cached per server under `~/.base44/cache//` for one hour; `--refresh` forces a download. +- Read-only comes from each operation's `security` list: `PersonalAccessTokenAuth: ["read_only"]` (the backend's `@read_safe`). `--read-only` filters or refuses by it; the server stays the real guard. +- `fetch` sends the path as is through `base44Client`, so every auth mode works. Don't add local validation; the server validates. +- `fetch` reads the spec only for `--help` and `--read-only`, so a route newer than the cache still works. Both send nothing, so they run before the login check. +- `fetch` disables Commander's help option, so `--help` after a method and path can print that operation's contract. +- `fetch` has no timeout: a call such as a deploy can take minutes. +- `fetch` reads stdin only for `--body @-`. An agent harness can hold stdin open forever, so reading it unasked would hang the call. +- A failed `fetch` reports to telemetry without the request or response body, `--body`, `--form`, or the query string: any endpoint can carry secrets. + ## Command File Template ```typescript diff --git a/packages/cli/README.md b/packages/cli/README.md index afe37e3a5..c7ac8ce2e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -43,6 +43,10 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | Command | Description | | ------- | ----------- | +| `api catalog [area]` | List the API's areas, or every operation in one area | +| `api search ` | Search every public Base44 API operation by what it does | +| `api grep ` | Search the API spec with a regular expression, for when search misses | +| `api fetch ` | Call any public Base44 API operation; `--help` after a path prints its contract | | [`create`](https://docs.base44.com/developers/references/cli/commands/create) | Create a new Base44 project from a template | | [`deploy`](https://docs.base44.com/developers/references/cli/commands/deploy) | Deploy all project resources and site to Base44 | | [`eject`](https://docs.base44.com/developers/references/cli/commands/eject) | Download the code for an existing Base44 project | diff --git a/packages/cli/package.json b/packages/cli/package.json index ac0a82ccc..555cf3485 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -75,6 +75,7 @@ "jsonwebtoken": "^9.0.3", "ky": "^1.14.2", "lodash": "^4.17.23", + "minisearch": "^7.1.2", "ms": "^2.1.3", "multer": "^2.0.0", "nanoid": "^5.1.6", diff --git a/packages/cli/src/cli/commands/api/catalog.ts b/packages/cli/src/cli/commands/api/catalog.ts new file mode 100644 index 000000000..51f110fc7 --- /dev/null +++ b/packages/cli/src/cli/commands/api/catalog.ts @@ -0,0 +1,44 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { + areaListing, + catalogIndex, + listOperations, +} from "@/core/api-spec/index.js"; +import { + formatJson, + loadSpecTask, + operationsInArea, + READ_ONLY_FILTER_HELP, + REFRESH_OPTION_HELP, +} from "./shared.js"; + +async function catalogAction( + ctx: CLIContext, + area: string | undefined, + options: { readOnly?: boolean; refresh?: boolean }, +): Promise { + const { spec } = await loadSpecTask(ctx, options.refresh); + const operations = listOperations(spec); + const readOnly = (ops: typeof operations) => + options.readOnly ? ops.filter((op) => op.readOnly) : ops; + if (area === undefined) { + return { stdout: formatJson(catalogIndex(readOnly(operations))) }; + } + // Look the area up before filtering, so an area with no reads is empty, not unknown. + const inArea = readOnly(operationsInArea(operations, area)); + return { stdout: formatJson(areaListing(area, inArea)) }; +} + +export function getApiCatalogCommand(): Command { + return new Base44Command("catalog", { + requireAuth: false, + requireAppContext: false, + }) + .description("List the API's areas, or every operation in one area") + .argument("[area]", 'An area from the list, e.g. "workflows"') + .option("--read-only", READ_ONLY_FILTER_HELP) + .option("--refresh", REFRESH_OPTION_HELP) + .action(catalogAction); +} diff --git a/packages/cli/src/cli/commands/api/fetch.ts b/packages/cli/src/cli/commands/api/fetch.ts new file mode 100644 index 000000000..8feffb6af --- /dev/null +++ b/packages/cli/src/cli/commands/api/fetch.ts @@ -0,0 +1,237 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, ensureAuth } from "@/cli/utils/index.js"; +import { + type ApiResponse, + getOperationContract, + listOperations, + matchOperation, + normalizePath, + sendApiRequest, +} from "@/core/api-spec/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { writeFile } from "@/core/utils/fs.js"; +import { + formatJson, + loadSpecTask, + REFRESH_OPTION_HELP, + readFormBody, + readRequestBody, + reportedFetchCommand, + withoutQuery, +} from "./shared.js"; + +interface FetchOptions { + body?: string; + form: string[]; + output?: string; + readOnly?: boolean; + refresh?: boolean; + help?: boolean; +} + +async function findOperation( + ctx: CLIContext, + method: string, + path: string, + { refresh, flag }: { refresh?: boolean; flag: "--help" | "--read-only" }, +) { + const { spec } = await loadSpecTask(ctx, refresh); + const op = matchOperation(listOperations(spec), method, path); + if (!op) { + throw new InvalidInputError( + `No operation in the API spec matches ${method} ${withoutQuery(path)}.`, + { + hints: [ + { + message: "The cached spec may predate this route; fetch it again", + command: `base44 api fetch ${method} ${path} ${flag} --refresh`, + }, + { + message: "Find the operation and its path template", + command: 'base44 api search ""', + }, + ], + }, + ); + } + return { spec, op }; +} + +const MULTIPART_HINT = + "Send this body with --form name=value, and --form name=@path for files."; + +const CONTRACT_HELP = + "\nThe contract is JSON; cut it down with e.g. | jq '.parameters, .requestBodySchema'"; + +const TEXT_CONTENT_TYPES = [ + "json", + "text/", + "xml", + "javascript", + "x-www-form-urlencoded", +]; + +/** + * The body as text, or undefined when it is binary: a declared non-text type, + * or bytes that aren't valid UTF-8 (a body without a content type, say). + */ +function decodeText(body: Uint8Array, contentType: string): string | undefined { + const type = contentType.toLowerCase(); + if (type && !TEXT_CONTENT_TYPES.some((hint) => type.includes(hint))) { + return undefined; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(body); + } catch { + return undefined; + } +} + +/** + * The body as the server sent it. JSON is pretty-printed only in a terminal: + * re-serializing would round integers above 2^53. Under --json a non-JSON + * text body is wrapped; an empty body prints its status; a binary one is + * described, and saved only with --output. + */ +async function formatResponse( + { status, contentType, body }: ApiResponse, + output: string | undefined, + jsonMode: boolean, +): Promise { + if (output !== undefined) { + await writeFile(output, body); + return formatJson({ status, contentType, bytes: body.length, output }); + } + if (body.length === 0) { + return formatJson({ status }); + } + const text = decodeText(body, contentType); + if (text === undefined) { + return formatJson({ + status, + contentType, + bytes: body.length, + note: "Binary body not printed; add --output to save it.", + }); + } + const asIs = text.endsWith("\n") ? text : `${text}\n`; + try { + const parsed: unknown = JSON.parse(text); + return process.stdout.isTTY ? formatJson(parsed) : asIs; + } catch { + return jsonMode ? formatJson({ status, contentType, body: text }) : asIs; + } +} + +async function fetchAction( + ctx: CLIContext, + methodArg: string | undefined, + path: string | undefined, + options: FetchOptions, + command: Command, +): Promise { + // First: the CLI recorded the raw arguments and --body for error reporting, + // and any endpoint's body or query can carry secrets. + ctx.errorReporter.setContext({ + command: reportedFetchCommand(methodArg ?? "", path ?? "", { ...options }), + }); + if (!methodArg || !path) { + if (options.help) { + // helpInformation() leaves out addHelpText, so add the line here too. + const usage = `${command.helpInformation()}${CONTRACT_HELP}\n`; + return { stdout: ctx.jsonMode ? formatJson({ usage }) : usage }; + } + throw new InvalidInputError( + "Provide a method and a path, e.g. GET /api/apps.", + ); + } + const method = methodArg.toUpperCase(); + const target = normalizePath(path); + + if (options.help) { + const { spec, op } = await findOperation(ctx, method, target, { + refresh: options.refresh, + flag: "--help", + }); + const contract = getOperationContract(spec, op); + return { + stdout: formatJson( + contract.requestBodyContentType === "multipart/form-data" + ? { ...contract, hint: MULTIPART_HINT } + : contract, + ), + }; + } + + // --help and the --read-only refusal send nothing, so they need no login. + if (options.readOnly) { + const { op } = await findOperation(ctx, method, target, { + refresh: options.refresh, + flag: "--read-only", + }); + if (!op.readOnly) { + throw new InvalidInputError( + `Refused under --read-only: ${op.method} ${op.path} is not a read-only operation. Nothing was sent.`, + ); + } + } + + if (options.body !== undefined && options.form.length > 0) { + throw new InvalidInputError("Use --body or --form, not both."); + } + await ensureAuth(ctx); + const body = + options.form.length > 0 + ? await readFormBody(options.form) + : await readRequestBody(options.body, ctx.log); + const response = await ctx.runTask( + `Calling ${method} ${target}`, + () => sendApiRequest(method, target, body), + { + successMessage: `Called ${method} ${target}`, + errorMessage: "Request failed", + }, + ); + return { + stdout: await formatResponse(response, options.output, ctx.jsonMode), + }; +} + +export function getApiFetchCommand(): Command { + return new Base44Command("fetch", { + requireAuth: false, + requireAppContext: false, + }) + .description( + "Call a Base44 API operation; add --help after a path for its contract", + ) + .helpOption(false) + .argument("[method]", "HTTP method, e.g. GET or POST") + .argument( + "[path]", + 'Concrete path with any query string, e.g. "/api/apps//workflows/runs?status=failed"', + ) + .option( + "--body ", + "Request body: JSON, @file.json, or @- to read it from stdin", + ) + .option( + "--form ", + "Multipart field: name=value, or name=@file to attach a file (repeatable)", + (field: string, fields: string[]) => [...fields, field], + [], + ) + .option("--output ", "Save the response body to a file") + .option( + "--read-only", + "Refuse, before sending, any operation the spec does not mark read-only", + ) + .option("--refresh", REFRESH_OPTION_HELP) + .option( + "-h, --help", + "With a method and path, print that operation's contract; otherwise this help", + ) + .addHelpText("after", CONTRACT_HELP) + .action(fetchAction); +} diff --git a/packages/cli/src/cli/commands/api/grep.ts b/packages/cli/src/cli/commands/api/grep.ts new file mode 100644 index 000000000..94970fa96 --- /dev/null +++ b/packages/cli/src/cli/commands/api/grep.ts @@ -0,0 +1,76 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { grepOperations, listOperations } from "@/core/api-spec/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { + formatJson, + loadSpecTask, + operationsInArea, + parseLimit, + READ_ONLY_FILTER_HELP, + REFRESH_OPTION_HELP, +} from "./shared.js"; + +interface GrepOptions { + area?: string; + ignoreCase?: boolean; + limit?: string; + readOnly?: boolean; + refresh?: boolean; +} + +function assertValidRegex(pattern: string): void { + try { + new RegExp(pattern); + } catch (error) { + throw new InvalidInputError( + `Invalid regular expression: ${error instanceof Error ? error.message : pattern}`, + ); + } +} + +async function grepAction( + ctx: CLIContext, + pattern: string, + options: GrepOptions, +): Promise { + const limit = parseLimit(options.limit); + assertValidRegex(pattern); + const { spec } = await loadSpecTask(ctx, options.refresh); + let operations = listOperations(spec); + if (options.area !== undefined) { + operations = operationsInArea(operations, options.area); + } + if (options.readOnly) { + operations = operations.filter((op) => op.readOnly); + } + const hits = grepOperations(spec, operations, pattern, { + ignoreCase: options.ignoreCase, + limit, + }); + return { stdout: formatJson(hits) }; +} + +export function getApiGrepCommand(): Command { + return new Base44Command("grep", { + requireAuth: false, + requireAppContext: false, + }) + .description( + "Search the API spec with a regular expression, for when search misses", + ) + .argument( + "", + 'Regular expression over each operation\'s contract as JSON text (quotes and newlines appear escaped), e.g. "runs.*status"', + ) + .option( + "--area ", + "Search only one area's operations (see base44 api catalog)", + ) + .option("-i, --ignore-case", "Case-insensitive match") + .option("--limit ", "Most operations to return (default 20)") + .option("--read-only", READ_ONLY_FILTER_HELP) + .option("--refresh", REFRESH_OPTION_HELP) + .action(grepAction); +} diff --git a/packages/cli/src/cli/commands/api/index.ts b/packages/cli/src/cli/commands/api/index.ts new file mode 100644 index 000000000..934caa572 --- /dev/null +++ b/packages/cli/src/cli/commands/api/index.ts @@ -0,0 +1,14 @@ +import { Command } from "commander"; +import { getApiCatalogCommand } from "./catalog.js"; +import { getApiFetchCommand } from "./fetch.js"; +import { getApiGrepCommand } from "./grep.js"; +import { getApiSearchCommand } from "./search.js"; + +export function getApiCommand(): Command { + return new Command("api") + .description("Search and call any public Base44 API operation") + .addCommand(getApiCatalogCommand()) + .addCommand(getApiSearchCommand()) + .addCommand(getApiGrepCommand()) + .addCommand(getApiFetchCommand()); +} diff --git a/packages/cli/src/cli/commands/api/search.ts b/packages/cli/src/cli/commands/api/search.ts new file mode 100644 index 000000000..feef70b03 --- /dev/null +++ b/packages/cli/src/cli/commands/api/search.ts @@ -0,0 +1,47 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { listOperations, searchOperations } from "@/core/api-spec/index.js"; +import { + formatJson, + loadSpecTask, + parseLimit, + READ_ONLY_FILTER_HELP, + REFRESH_OPTION_HELP, +} from "./shared.js"; + +interface SearchOptions { + limit?: string; + readOnly?: boolean; + refresh?: boolean; +} + +async function searchAction( + ctx: CLIContext, + words: string[], + options: SearchOptions, +): Promise { + const limit = parseLimit(options.limit); + const { spec } = await loadSpecTask(ctx, options.refresh); + const results = searchOperations(listOperations(spec), words.join(" "), { + limit, + readOnly: options.readOnly, + }); + return { stdout: formatJson(results) }; +} + +export function getApiSearchCommand(): Command { + return new Base44Command("search", { + requireAuth: false, + requireAppContext: false, + }) + .description("Search every Base44 API operation by what it does") + .argument( + "", + 'What you are looking for, e.g. "failed workflow runs"', + ) + .option("--limit ", "Most results to return (default 5)") + .option("--read-only", READ_ONLY_FILTER_HELP) + .option("--refresh", REFRESH_OPTION_HELP) + .action(searchAction); +} diff --git a/packages/cli/src/cli/commands/api/shared.ts b/packages/cli/src/cli/commands/api/shared.ts new file mode 100644 index 000000000..852d621c3 --- /dev/null +++ b/packages/cli/src/cli/commands/api/shared.ts @@ -0,0 +1,144 @@ +import { fstatSync } from "node:fs"; +import { basename } from "node:path"; +import type { CLIContext } from "@/cli/types.js"; +import { type Logger, readStdin, toJsonStdout } from "@/cli/utils/index.js"; +import { + type ApiOperation, + groupByArea, + type LoadedSpec, + loadSpec, +} from "@/core/api-spec/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readFile, readTextFile } from "@/core/utils/fs.js"; + +/** JSON for stdout: pretty-printed in a terminal, compact when piped. */ +export function formatJson(value: unknown): string { + return toJsonStdout(value, { compact: !process.stdout.isTTY }); +} + +export function parseLimit(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const n = Number.parseInt(value, 10); + if (!Number.isInteger(n) || n < 1) { + throw new InvalidInputError("--limit must be a positive integer."); + } + return n; +} + +export const READ_ONLY_FILTER_HELP = "Only read-only operations"; + +export const REFRESH_OPTION_HELP = + "Fetch the API spec again instead of using the cached copy (kept 1 hour)"; + +export function loadSpecTask( + { runTask }: CLIContext, + refresh: boolean | undefined, +): Promise { + return runTask("Loading the API spec", () => loadSpec({ refresh }), { + successMessage: "Loaded the API spec", + }); +} + +/** An area's operations; an unknown area fails with the list of real ones. */ +export function operationsInArea( + operations: ApiOperation[], + area: string, +): ApiOperation[] { + const areas = groupByArea(operations); + const inArea = areas.get(area); + if (!inArea) { + throw new InvalidInputError(`Unknown API area: ${area}`, { + details: [`Areas: ${[...areas.keys()].join(", ")}`], + hints: [{ message: "List the areas", command: "base44 api catalog" }], + }); + } + return inArea; +} + +/** Stdin is a shell pipe or a redirected file; checking reads nothing from it. */ +function isPipedStdin(): boolean { + try { + const stats = fstatSync(0); + return stats.isFIFO() || stats.isFile(); + } catch { + return false; + } +} + +/** + * The body from `--body ''`, `--body @file.json` or `--body @-`. Stdin + * is read only for `@-`: a harness can hold stdin open forever, so reading it + * unasked could hang the call. + */ +export async function readRequestBody( + flag: string | undefined, + log: Logger, +): Promise { + if (flag === "@-") { + return readStdin("--body @-", { trim: false }); + } + if (flag?.startsWith("@")) { + return readTextFile(flag.slice(1)); + } + if (flag === undefined && isPipedStdin()) { + log.warn("stdin is ignored; pass --body @- to send it as the body."); + } + return flag; +} + +const FORM_FILE = /^@(.+?)(?:;type=([^;]+))?$/; + +/** + * A multipart body from curl-style `--form` fields: `name=value`, or + * `name=@path` (optionally `;type=text/csv`) to attach a file. + */ +export async function readFormBody(fields: string[]): Promise { + const form = new FormData(); + for (const field of fields) { + const separator = field.indexOf("="); + if (separator < 1) { + throw new InvalidInputError( + `--form takes name=value or name=@file, got "${field}".`, + ); + } + const name = field.slice(0, separator); + const value = field.slice(separator + 1); + const file = FORM_FILE.exec(value); + if (file?.[1]) { + const type = file[2] ?? "application/octet-stream"; + const blob = new Blob([await readFile(file[1])], { type }); + form.append(name, blob, basename(file[1])); + } else { + form.append(name, value); + } + } + return form; +} + +/** + * The command as error reporting records it: the path without its query + * string, and `--body` and `--form` masked, since any of them can carry secrets. + */ +export function withoutQuery(path: string): string { + return path.split("?", 1)[0] ?? ""; +} + +export function reportedFetchCommand( + method: string, + path: string, + options: Record, +) { + return { + name: "api fetch", + args: [method, withoutQuery(path)], + options: { + ...options, + ...(options.body === undefined ? {} : { body: "[not reported]" }), + ...(Array.isArray(options.form) && options.form.length > 0 + ? { form: "[not reported]" } + : {}), + }, + }; +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 06d896cd7..468a6fd94 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -2,6 +2,7 @@ import { Command, Option } from "commander"; import { getActorsCommand } from "@/cli/commands/actors/index.js"; import { getAgentSkillsCommand } from "@/cli/commands/agent-skills/index.js"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; +import { getApiCommand } from "@/cli/commands/api/index.js"; import { getAuthCommand } from "@/cli/commands/auth/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; @@ -115,6 +116,9 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getSandboxCommand()); program.addCommand(getBranchesCommand()); + // Register API commands, driven by the server's OpenAPI spec + program.addCommand(getApiCommand()); + // Register auth config commands program.addCommand(getAuthCommand()); diff --git a/packages/cli/src/cli/utils/json.ts b/packages/cli/src/cli/utils/json.ts index 554d88d5b..928deeb1f 100644 --- a/packages/cli/src/cli/utils/json.ts +++ b/packages/cli/src/cli/utils/json.ts @@ -1,4 +1,10 @@ -/** Serialize a value as pretty JSON for stdout (the `--json` contract). */ -export function toJsonStdout(result: unknown): string { - return `${JSON.stringify(result, null, 2)}\n`; +/** + * Serialize a value as JSON for stdout (the `--json` contract): pretty by + * default, compact with `compact` (e.g. when piped). + */ +export function toJsonStdout( + result: unknown, + options: { compact?: boolean } = {}, +): string { + return `${JSON.stringify(result, null, options.compact ? undefined : 2)}\n`; } diff --git a/packages/cli/src/core/api-spec/catalog.ts b/packages/cli/src/core/api-spec/catalog.ts new file mode 100644 index 000000000..2386c4a17 --- /dev/null +++ b/packages/cli/src/core/api-spec/catalog.ts @@ -0,0 +1,81 @@ +import type { ApiOperation } from "./operations.js"; +import { type OperationSummary, toOperationSummary } from "./search.js"; + +const INLINE_AREA_MAX = 2; +const AREA_SAMPLE_MAX = 3; +const AREA_OPERATIONS_MAX = 200; + +// Code-point order; localeCompare would sort "{" before letters. +const byCodePoint = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +/** The area a path belongs to: the segment after `{app_id}`, else the one after `/api`. */ +export function areaOf(path: string): string { + const parts = path.split("/").filter(Boolean); + const appIdIndex = parts.indexOf("{app_id}"); + if (appIdIndex !== -1 && appIdIndex + 1 < parts.length) { + return parts[appIdIndex + 1] ?? "other"; + } + if (parts[0] === "api" && parts.length > 1) { + return parts[1] ?? "other"; + } + return parts[0] ?? "other"; +} + +/** Operations bucketed by area; areas and their operations both sorted. */ +export function groupByArea( + operations: ApiOperation[], +): Map { + const sorted = [...operations].sort((a, b) => + byCodePoint(`${a.method} ${a.path}`, `${b.method} ${b.path}`), + ); + const areas = new Map(); + for (const op of sorted) { + const area = areaOf(op.path); + areas.set(area, [...(areas.get(area) ?? []), op]); + } + return new Map([...areas].sort(([a], [b]) => byCodePoint(a, b))); +} + +// A sample of reads says what an area holds; a sorted list starts with DELETEs. +function readsFirst(ops: ApiOperation[]): ApiOperation[] { + const isRead = (op: ApiOperation) => (op.method === "GET" ? 0 : 1); + return [...ops].sort((a, b) => isRead(a) - isRead(b)); +} + +type AreaEntry = + | { area: string; operations: OperationSummary[] } + | { area: string; operationCount: number; sample: string[] }; + +/** Every area; small ones listed in full, the rest as a count and sample summaries. */ +export function catalogIndex(operations: ApiOperation[]): { + areas: AreaEntry[]; + total: number; +} { + const areas = [...groupByArea(operations)].map( + ([area, ops]): AreaEntry => + ops.length <= INLINE_AREA_MAX + ? { area, operations: ops.map(toOperationSummary) } + : { + area, + operationCount: ops.length, + sample: readsFirst(ops) + .slice(0, AREA_SAMPLE_MAX) + .map((op) => op.summary), + }, + ); + return { areas, total: operations.length }; +} + +/** One area's operations, at most `max`; `truncated` says the area holds more. */ +export function areaListing( + area: string, + operations: ApiOperation[], + max = AREA_OPERATIONS_MAX, +) { + return { + area, + operations: operations.slice(0, max).map(toOperationSummary), + total: operations.length, + truncated: operations.length > max, + }; +} diff --git a/packages/cli/src/core/api-spec/contract.ts b/packages/cli/src/core/api-spec/contract.ts new file mode 100644 index 000000000..bc26e3484 --- /dev/null +++ b/packages/cli/src/core/api-spec/contract.ts @@ -0,0 +1,240 @@ +import { type ApiOperation, resolveComponentRef } from "./operations.js"; +import { + MediaContentSchema, + type OpenApiSpec, + ParameterSchema, + ResponseObjectSchema, +} from "./schema.js"; +import { cleanText } from "./text.js"; + +const SCHEMA_REF_PREFIX = "#/components/schemas/"; +const MAX_REF_DEPTH = 4; + +/** + * Replace component `$ref`s with their schemas so a contract reads on its own. + * A cycle, an unknown ref, or nesting past MAX_REF_DEPTH keeps its `$ref`. + */ +function inlineRefs( + node: unknown, + schemas: Record, + seen: ReadonlySet = new Set(), + depth = 0, +): unknown { + if (depth > MAX_REF_DEPTH) { + return node; + } + if (Array.isArray(node)) { + return node.map((item) => inlineRefs(item, schemas, seen, depth)); + } + if (node === null || typeof node !== "object") { + return node; + } + const { $ref, ...siblings } = node as Record; + if (typeof $ref === "string" && $ref.startsWith(SCHEMA_REF_PREFIX)) { + const name = $ref.slice(SCHEMA_REF_PREFIX.length); + if (seen.has(name) || !(name in schemas)) { + return node; + } + const resolved = inlineRefs( + schemas[name], + schemas, + new Set([...seen, name]), + depth + 1, + ); + // OpenAPI 3.1: keys beside a $ref (description, ...) override the target's. + return resolved !== null && typeof resolved === "object" + ? { ...resolved, ...siblings } + : resolved; + } + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + inlineRefs(value, schemas, seen, depth), + ]), + ); +} + +/** A body's media type and schema: JSON when offered, else the first (e.g. a multipart upload). */ +function bodyOf(value: unknown): { contentType?: string; schema?: unknown } { + const parsed = MediaContentSchema.safeParse(value); + const content = parsed.success ? (parsed.data.content ?? {}) : {}; + const contentType = + "application/json" in content + ? "application/json" + : Object.keys(content)[0]; + return contentType + ? { contentType, schema: content[contentType]?.schema } + : {}; +} + +/** Schemas still referenced after inlining (cycles, depth), so every `$ref` resolves. */ +function leftoverRefs( + node: unknown, + schemas: Record, + found: Map = new Map(), +): Map { + if (Array.isArray(node)) { + for (const item of node) { + leftoverRefs(item, schemas, found); + } + } else if (node !== null && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + if ( + key === "$ref" && + typeof value === "string" && + value.startsWith(SCHEMA_REF_PREFIX) && + !found.has(value) + ) { + const schema = schemas[value.slice(SCHEMA_REF_PREFIX.length)]; + // A ref to a schema the spec lacks has nothing to list. + if (schema !== undefined) { + found.set(value, schema); + leftoverRefs(schema, schemas, found); + } + } else { + leftoverRefs(value, schemas, found); + } + } + } + return found; +} + +// Keys whose values are data, not schemas: never strip a `title` inside them. +const SCHEMA_DATA_KEYS = new Set([ + "example", + "examples", + "default", + "enum", + "const", +]); + +/** + * A schema without FastAPI's generated `title`s, with `anyOf: [X, null]` + * written as `type: [X, "null"]`, and with cleaned descriptions. Keys inside + * `properties` are field names, so a field called `title` stays. + */ +function slimSchema(node: unknown, isPropertyMap = false): unknown { + if (Array.isArray(node)) { + return node.map((item) => slimSchema(item)); + } + if (node === null || typeof node !== "object") { + return node; + } + const entries = Object.entries(node as Record); + if (isPropertyMap) { + return Object.fromEntries( + entries.map(([name, value]) => [name, slimSchema(value)]), + ); + } + const slim: Record = {}; + for (const [key, value] of entries) { + if (key === "title" && typeof value === "string") { + continue; + } + slim[key] = SCHEMA_DATA_KEYS.has(key) + ? value + : key === "properties" + ? slimSchema(value, true) + : key === "description" && typeof value === "string" + ? cleanText(value) + : slimSchema(value); + } + const { anyOf, ...rest } = slim; + const nullable = + Array.isArray(anyOf) && anyOf.length === 2 + ? nullableType(anyOf as unknown[]) + : undefined; + return nullable ? { type: [nullable, "null"], ...rest } : slim; +} + +/** `string` for `[{type: "string"}, {type: "null"}]`, in either order. */ +function nullableType(variants: unknown[]): string | undefined { + const types = variants.map((variant) => + variant !== null && + typeof variant === "object" && + Object.keys(variant).length === 1 && + typeof (variant as { type?: unknown }).type === "string" + ? (variant as { type: string }).type + : undefined, + ); + if (types.includes(undefined) || !types.includes("null")) { + return undefined; + } + return types.find((type) => type !== "null"); +} + +/** + * The operation's contract: what a caller needs to build the request and read + * the result. Error shapes are left out, as in the MCP bridge: a failed call + * returns the server's own error. + */ +export function getOperationContract(spec: OpenApiSpec, op: ApiOperation) { + const schemas = spec.components?.schemas ?? {}; + const inline = (node: unknown) => + node === undefined ? null : slimSchema(inlineRefs(node, schemas)); + const responses = Object.entries(op.operation.responses ?? {}).flatMap( + ([status, response]) => { + const parsed = ResponseObjectSchema.safeParse( + resolveComponentRef(spec, response), + ); + return parsed.success ? [[status, parsed.data] as const] : []; + }, + ); + const success = responses + .filter(([status]) => status.startsWith("2")) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, response]) => bodyOf(response).schema) + .find(Boolean); + const requestBody = bodyOf( + resolveComponentRef(spec, op.operation.requestBody), + ); + const contract = { + endpoint: op.endpoint, + method: op.method, + path: op.path, + summary: op.summary, + description: op.description, + parameters: (op.operation.parameters ?? []).flatMap((parameter) => { + const parsed = ParameterSchema.safeParse(parameter); + if (!parsed.success) { + // An unresolvable `$ref` is kept as is rather than dropped. + return [parameter as Record]; + } + const { name, in: location, required, schema, description } = parsed.data; + // FastAPI repeats the description inside the schema; keep one copy. + const inSchema = + schema !== null && + typeof schema === "object" && + "description" in schema; + return [ + { + name, + in: location, + required: required ?? false, + schema: inline(schema ?? {}), + ...(description && !inSchema + ? { description: cleanText(description) } + : {}), + ...("example" in parsed.data ? { example: parsed.data.example } : {}), + }, + ]; + }), + ...(requestBody.contentType && + requestBody.contentType !== "application/json" + ? { requestBodyContentType: requestBody.contentType } + : {}), + requestBodySchema: inline(requestBody.schema), + responseSchema: inline(success), + }; + const refs = leftoverRefs(contract, schemas); + return { + ...contract, + ...(refs.size > 0 + ? { + refs: Object.fromEntries( + [...refs].map(([ref, schema]) => [ref, slimSchema(schema)]), + ), + } + : {}), + }; +} diff --git a/packages/cli/src/core/api-spec/grep.ts b/packages/cli/src/core/api-spec/grep.ts new file mode 100644 index 000000000..4af4ab649 --- /dev/null +++ b/packages/cli/src/core/api-spec/grep.ts @@ -0,0 +1,22 @@ +import { getOperationContract } from "./contract.js"; +import type { ApiOperation } from "./operations.js"; +import type { OpenApiSpec } from "./schema.js"; +import { type OperationSummary, toOperationSummary } from "./search.js"; + +/** + * Operations whose contract matches a regular expression. The contract has + * its schemas inlined, so a field such as `webhook_secret` finds the + * operation that sends or returns it, not just the schema that declares it. + */ +export function grepOperations( + spec: OpenApiSpec, + operations: ApiOperation[], + pattern: string, + options: { ignoreCase?: boolean; limit?: number } = {}, +): OperationSummary[] { + const regex = new RegExp(pattern, options.ignoreCase ? "i" : ""); + return operations + .filter((op) => regex.test(JSON.stringify(getOperationContract(spec, op)))) + .slice(0, options.limit ?? 20) + .map(toOperationSummary); +} diff --git a/packages/cli/src/core/api-spec/index.ts b/packages/cli/src/core/api-spec/index.ts new file mode 100644 index 000000000..6c3991e8f --- /dev/null +++ b/packages/cli/src/core/api-spec/index.ts @@ -0,0 +1,8 @@ +export * from "./catalog.js"; +export * from "./contract.js"; +export * from "./grep.js"; +export * from "./operations.js"; +export * from "./request.js"; +export * from "./schema.js"; +export * from "./search.js"; +export * from "./spec.js"; diff --git a/packages/cli/src/core/api-spec/operations.ts b/packages/cli/src/core/api-spec/operations.ts new file mode 100644 index 000000000..82ef88fce --- /dev/null +++ b/packages/cli/src/core/api-spec/operations.ts @@ -0,0 +1,170 @@ +import { + HTTP_METHODS, + type OpenApiOperation, + type OpenApiSpec, + OperationSchema, + ParameterListSchema, + ParameterSchema, +} from "./schema.js"; +import { cleanText } from "./text.js"; + +const PERSONAL_ACCESS_TOKEN_SCHEME = "PersonalAccessTokenAuth"; +const READ_ONLY_SCOPE = "read_only"; + +export interface ApiOperation { + /** `METHOD /path/template`, the operation's identity in every command's output. */ + endpoint: string; + method: string; + path: string; + summary: string; + description: string; + /** Parameter names and descriptions, for search. */ + parameterText: string; + readOnly: boolean; + operation: OpenApiOperation; +} + +/** Read-only means a read-only personal access token may call it (the backend's `@read_safe`). */ +function isReadOnly(operation: OpenApiOperation, spec: OpenApiSpec): boolean { + const security = operation.security ?? spec.security ?? []; + return security.some((requirement) => + requirement[PERSONAL_ACCESS_TOKEN_SCHEME]?.includes(READ_ONLY_SCOPE), + ); +} + +function parameterText(operation: OpenApiOperation): string { + return (operation.parameters ?? []) + .flatMap((parameter) => { + const parsed = ParameterSchema.safeParse(parameter); + return parsed.success + ? [parsed.data.name, cleanText(parsed.data.description ?? "")] + : []; + }) + .join(" "); +} + +const COMPONENT_REF = /^#\/components\/([^/]+)\/([^/]+)$/; + +/** + * A `$ref` to `#/components//` (parameters, requestBodies, + * responses, ...) replaced by its target; anything else, or a ref the spec + * can't resolve, as is. + */ +export function resolveComponentRef( + spec: OpenApiSpec, + value: unknown, +): unknown { + const ref = + value !== null && typeof value === "object" + ? (value as { $ref?: unknown }).$ref + : undefined; + const match = typeof ref === "string" ? COMPONENT_REF.exec(ref) : null; + if (!match?.[1] || !match[2]) { + return value; + } + const components = spec.components as + | Record | undefined> + | undefined; + return components?.[match[1]]?.[match[2]] ?? value; +} + +/** Path-level parameters apply to every method; the operation's own win by name and location. */ +function mergeParameters( + shared: unknown[] | undefined, + own: unknown[] | undefined, +): unknown[] | undefined { + if (!shared?.length) { + return own; + } + const key = (parameter: unknown) => { + const parsed = ParameterSchema.safeParse(parameter); + return parsed.success ? `${parsed.data.in}:${parsed.data.name}` : undefined; + }; + const ownKeys = new Set((own ?? []).map(key)); + return [ + ...shared.filter((parameter) => !ownKeys.has(key(parameter))), + ...(own ?? []), + ]; +} + +export function listOperations(spec: OpenApiSpec): ApiOperation[] { + return Object.entries(spec.paths).flatMap(([path, item]) => + HTTP_METHODS.flatMap((method) => { + const parsed = OperationSchema.safeParse(item[method]); + if (!parsed.success) { + return []; + } + const shared = ParameterListSchema.safeParse(item.parameters); + const resolve = (list: unknown[] | undefined) => + list?.map((parameter) => resolveComponentRef(spec, parameter)); + const operation = { + ...parsed.data, + parameters: mergeParameters( + resolve(shared.success ? shared.data : undefined), + resolve(parsed.data.parameters), + ), + }; + return [ + { + endpoint: `${method.toUpperCase()} ${path}`, + method: method.toUpperCase(), + path, + summary: operation.summary ?? "", + // Without docs links and callout tags, which search would read as words. + description: cleanText(operation.description ?? ""), + parameterText: parameterText(operation), + readOnly: isReadOnly(operation, spec), + operation, + }, + ]; + }), + ); +} + +/** Segments without the outer slashes; an empty inner one (`//`) stays, and matches no template. */ +function pathSegments(path: string): string[] { + const pathname = path.split(/[?#]/, 1)[0] ?? ""; + return pathname.replace(/^\/+|\/+$/g, "").split("/"); +} + +function segmentPattern(segment: string): RegExp { + const source = segment + .split(/(\{[^}]+\})/) + .map((part) => + part.startsWith("{") + ? "[^/]+" + : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ) + .join(""); + return new RegExp(`^${source}$`); +} + +/** "1" per literal segment, so `/workflows/runs` ("11") outranks `/workflows/{workflow_id}` ("10"). */ +function literalSegments(path: string): string { + return pathSegments(path) + .map((segment) => (segment.includes("{") ? "0" : "1")) + .join(""); +} + +/** Match a concrete path (query string allowed) to its operation in the spec. */ +export function matchOperation( + operations: ApiOperation[], + method: string, + concretePath: string, +): ApiOperation | undefined { + const upperMethod = method.toUpperCase(); + const segments = pathSegments(concretePath); + const candidates = operations.filter((op) => { + if (op.method !== upperMethod) { + return false; + } + const template = pathSegments(op.path); + return ( + template.length === segments.length && + template.every((part, i) => segmentPattern(part).test(segments[i] ?? "")) + ); + }); + return candidates.sort((a, b) => + literalSegments(b.path).localeCompare(literalSegments(a.path)), + )[0]; +} diff --git a/packages/cli/src/core/api-spec/request.ts b/packages/cli/src/core/api-spec/request.ts new file mode 100644 index 000000000..4a534ed01 --- /dev/null +++ b/packages/cli/src/core/api-spec/request.ts @@ -0,0 +1,102 @@ +import { HTTPError } from "ky"; +import { base44Client } from "@/core/clients/index.js"; +import { ApiError } from "@/core/errors.js"; + +const MAX_ERROR_BODY_CHARS = 4000; +const QUERY_STRING = /\?\S*/g; + +async function readBody(response: Response): Promise { + try { + const text = (await response.clone().text()).trim(); + return text.length > MAX_ERROR_BODY_CHARS + ? `${text.slice(0, MAX_ERROR_BODY_CHARS)}…` + : text || undefined; + } catch { + return undefined; + } +} + +export interface ApiResponse { + status: number; + contentType: string; + body: Uint8Array; +} + +/** + * The path as it will be sent: `.`, `..` and their encodings resolved the + * way the request's URL resolves them, so a `--read-only` check of this path + * is a check of the request. Keeps the query string. + */ +export function normalizePath(path: string): string { + const url = new URL(path.replace(/^\/*/, "/"), "http://placeholder"); + return `${url.pathname}${url.search}`; +} + +/** Send a request as is through the authenticated client; the server validates it. */ +export async function sendApiRequest( + method: string, + path: string, + body?: string | FormData, +): Promise { + try { + const response = await base44Client(path.replace(/^\/+/, ""), { + method, + body, + // A FormData body sets its own multipart Content-Type and boundary. + headers: + typeof body === "string" ? { "Content-Type": "application/json" } : {}, + // The call may be a long deploy; the built-in commands wait as long. + timeout: false, + // Re-sending a write the server may have applied is not safe. + retry: { methods: ["get", "head"] }, + }); + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + body: new Uint8Array(await response.arrayBuffer()), + }; + } catch (error) { + const apiError = await ApiError.fromHttpError( + error, + `calling ${method} ${path}`, + ); + const response = error instanceof HTTPError ? error.response : undefined; + const raw = response ? await readBody(response) : undefined; + const retryAfter = response?.headers.get("retry-after") ?? undefined; + throw redactedApiError(apiError, raw, method, path, retryAfter); + } +} + +/** + * The error a failed call reports and shows. Any endpoint can carry secrets, + * so neither body nor the query string goes to error reporting; the server's + * raw reply goes to the caller in the details, and the message names only the + * status so that reply is printed once. + */ +export function redactedApiError( + apiError: ApiError, + raw: string | undefined, + method: string, + path: string, + retryAfter?: string, +): ApiError { + // Never reuse apiError.message: it holds the path and ky's full URL with + // the query string. A network error keeps its own words, minus any query. + // Writes aren't retried, so the server's wait time is the caller's to act on. + const wait = retryAfter + ? ` (retry after ${/^\d+$/.test(retryAfter) ? `${retryAfter} s` : retryAfter})` + : ""; + const reason = + apiError.statusCode !== undefined + ? `HTTP ${apiError.statusCode}${wait}` + : (apiError.cause?.message ?? "request failed").replace(QUERY_STRING, ""); + const message = `Error calling ${method} ${path.split("?", 1)[0]}: ${reason}`; + return new ApiError(message, { + statusCode: apiError.statusCode, + requestUrl: apiError.requestUrl?.split("?", 1)[0], + requestMethod: apiError.requestMethod, + requestId: apiError.requestId, + hints: apiError.hints, + details: raw ? [`Response body: ${raw}`] : apiError.details, + }); +} diff --git a/packages/cli/src/core/api-spec/schema.ts b/packages/cli/src/core/api-spec/schema.ts new file mode 100644 index 000000000..c957d2f50 --- /dev/null +++ b/packages/cli/src/core/api-spec/schema.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +export const HTTP_METHODS = [ + "get", + "put", + "post", + "delete", + "patch", + "head", + "options", + "trace", +] as const; + +const ParameterSchema = z.looseObject({ + name: z.string(), + in: z.string(), + required: z.boolean().optional(), + description: z.string().optional(), + schema: z.unknown().optional(), + example: z.unknown().optional(), +}); + +const MediaTypesSchema = z.record( + z.string(), + z.looseObject({ schema: z.unknown().optional() }), +); + +/** A request body or a response: both carry `content` keyed by media type. */ +export const MediaContentSchema = z.looseObject({ + content: MediaTypesSchema.optional(), +}); + +export const ResponseObjectSchema = z.looseObject({ + description: z.string().optional(), + content: MediaTypesSchema.optional(), +}); + +const SecurityRequirementSchema = z.record(z.string(), z.array(z.string())); + +export const OperationSchema = z.looseObject({ + summary: z.string().optional(), + description: z.string().optional(), + operationId: z.string().optional(), + parameters: z.array(z.unknown()).optional(), + requestBody: z.unknown().optional(), + responses: z.record(z.string(), z.unknown()).optional(), + security: z.array(SecurityRequirementSchema).optional(), +}); + +export const OpenApiSpecSchema = z.looseObject({ + openapi: z.string(), + // Operations are validated one by one in listOperations, so one malformed + // route is skipped instead of failing every command. + paths: z.record(z.string(), z.record(z.string(), z.unknown())), + components: z + .looseObject({ + schemas: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + security: z.array(SecurityRequirementSchema).optional().catch(undefined), +}); + +export const ParameterListSchema = z.array(z.unknown()); + +export type OpenApiSpec = z.infer; +export type OpenApiOperation = z.infer; +export { ParameterSchema }; diff --git a/packages/cli/src/core/api-spec/search.ts b/packages/cli/src/core/api-spec/search.ts new file mode 100644 index 000000000..7bfe5215c --- /dev/null +++ b/packages/cli/src/core/api-spec/search.ts @@ -0,0 +1,132 @@ +import MiniSearch from "minisearch"; +import type { ApiOperation } from "./operations.js"; + +const BOOST = { + method: 1, + path: 8, + summary: 5, + description: 2, + parameters: 0.5, +}; +// Below this share of the best score a hit is noise from one common word. +const MIN_RELATIVE_SCORE = 0.2; +// MiniSearch's default keeps symbols, so `failed` and would stay glued. +const TOKEN_SEPARATOR = /[\n\r\p{Z}\p{P}\p{S}]+/u; +// Words every description is full of; "who am i" should find nothing, not five guesses. +const STOPWORDS = new Set( + "a an and are at be by do does for from how i id in is it its me my of on or our that the this to we what who with you your".split( + " ", + ), +); + +/** Words of two letters or more, without stopwords; a lone "x" matches junk. */ +function tokenize(text: string): string[] { + return text + .split(TOKEN_SEPARATOR) + .filter((word) => word.length >= 2 && !STOPWORDS.has(word.toLowerCase())); +} + +export interface OperationSummary { + endpoint: string; + summary: string; +} + +export function toOperationSummary(op: ApiOperation): OperationSummary { + return { endpoint: op.endpoint, summary: op.summary }; +} + +function buildSearchIndex(operations: ApiOperation[]): MiniSearch { + const index = new MiniSearch({ + // The method lets "DELETE workflow" find "Archive workflow". + fields: ["method", "path", "summary", "description", "parameters"], + tokenize, + }); + index.addAll( + operations.map((op, id) => ({ + id, + method: op.method, + // Placeholders like {workflow_id} would count "workflow" twice; names are under parameters. + path: op.path.replace(/\{[^}]*\}/g, ""), + summary: op.summary, + description: op.description, + parameters: op.parameterText, + })), + ); + return index; +} + +/** + * How the query matches the summary, the operation's own name: `coverage` is + * the share of query words found in it, `precision` the share of its words + * the query names. "get app" covers "Get app" fully and precisely, and + * "Get app user" fully but less precisely. + */ +function summaryMatch( + terms: string[], + op: ApiOperation | undefined, +): { coverage: number; precision: number } { + const words = tokenize(op?.summary.toLowerCase() ?? ""); + const named = (word: string) => terms.some((term) => word.startsWith(term)); + return { + coverage: + terms.filter((term) => words.some((word) => word.startsWith(term))) + .length / terms.length, + precision: words.length ? words.filter(named).length / words.length : 0, + }; +} + +const readsFirst = (op: ApiOperation | undefined) => + op?.method === "GET" ? 0 : 1; + +/** + * Hits whose summary holds every query word rank first, by precision, then + * reads before writes; the rest follow by score, raised by summary coverage. + */ +export function searchOperations( + operations: ApiOperation[], + query: string, + options: { limit?: number; readOnly?: boolean } = {}, +): OperationSummary[] { + const terms = tokenize(query.toLowerCase()); + if (terms.length === 0) { + return []; + } + const index = buildSearchIndex(operations); + const hits = index + .search(query, { + // Prefixes and typos on short words ("api", "id") match half the spec. + prefix: (term) => term.length >= 3, + fuzzy: (term) => (term.length >= 4 ? 0.2 : false), + boost: BOOST, + filter: options.readOnly + ? (hit) => operations[hit.id]?.readOnly === true + : undefined, + }) + .map((hit) => { + const match = summaryMatch(terms, operations[hit.id]); + return { + ...hit, + ...match, + full: match.coverage === 1, + score: hit.score * (1 + match.coverage), + }; + }) + .sort( + (a, b) => + Number(b.full) - Number(a.full) || + (a.full + ? b.precision - a.precision || + readsFirst(operations[a.id]) - readsFirst(operations[b.id]) + : 0) || + b.score - a.score, + ); + const baseline = + Math.max(0, ...hits.map((hit) => hit.score)) * MIN_RELATIVE_SCORE; + return hits + .filter((hit) => hit.full || hit.score >= baseline) + .slice(0, options.limit ?? 5) + .flatMap((hit) => { + const op = operations[hit.id]; + return op ? [toOperationSummary(op)] : []; + }); +} diff --git a/packages/cli/src/core/api-spec/spec.ts b/packages/cli/src/core/api-spec/spec.ts new file mode 100644 index 000000000..42eab4f29 --- /dev/null +++ b/packages/cli/src/core/api-spec/spec.ts @@ -0,0 +1,88 @@ +import { rename, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { base44Client } from "@/core/clients/index.js"; +import { getBase44ApiUrl, getBase44GlobalDir } from "@/core/config.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { readTextFile, writeFile } from "@/core/utils/fs.js"; +import { type OpenApiSpec, OpenApiSpecSchema } from "./schema.js"; + +export const SPEC_CACHE_TTL_MS = 60 * 60 * 1000; + +export interface LoadedSpec { + text: string; + spec: OpenApiSpec; +} + +/** One cache file per server, e.g. ~/.base44/cache/app.base44.com/openapi.json. */ +export function getSpecCachePath(apiUrl = getBase44ApiUrl()): string { + const server = new URL(apiUrl).host.replace(/[^a-zA-Z0-9.-]/g, "_"); + return join(getBase44GlobalDir(), "cache", server, "openapi.json"); +} + +export function isSpecCacheFresh(mtimeMs: number, now = Date.now()): boolean { + return now - mtimeMs < SPEC_CACHE_TTL_MS; +} + +export function parseSpec(text: string): OpenApiSpec { + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new ApiError("The API spec from the server is not valid JSON"); + } + const result = OpenApiSpecSchema.safeParse(json); + if (!result.success) { + throw new SchemaValidationError("Invalid API spec", result.error); + } + return result.data; +} + +const SPEC_DOWNLOAD_TIMEOUT_MS = 60_000; + +/** A fresh cached spec, or null when it is missing, stale or unreadable. */ +async function readFreshCache(path: string): Promise { + try { + const { mtimeMs } = await stat(path); + if (!isSpecCacheFresh(mtimeMs)) { + return null; + } + const text = await readTextFile(path); + return { text, spec: parseSpec(text) }; + } catch { + return null; + } +} + +async function downloadSpec(): Promise { + try { + const response = await base44Client.get("api/openapi.json", { + timeout: SPEC_DOWNLOAD_TIMEOUT_MS, + }); + return await response.text(); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching the API spec"); + } +} + +/** The server's OpenAPI spec, from a cache under an hour old or the server. */ +export async function loadSpec( + options: { refresh?: boolean } = {}, +): Promise { + const path = getSpecCachePath(); + const cached = options.refresh ? null : await readFreshCache(path); + if (cached !== null) { + return cached; + } + const text = await downloadSpec(); + const spec = parseSpec(text); + // Write then rename, so a concurrent run never reads a half-written file. + // A cache that can't be written (full or read-only disk) only costs speed. + const partial = `${path}.${process.pid}.tmp`; + try { + await writeFile(partial, text); + await rename(partial, path); + } catch { + await rm(partial, { force: true }).catch(() => undefined); + } + return { text, spec }; +} diff --git a/packages/cli/src/core/api-spec/text.ts b/packages/cli/src/core/api-spec/text.ts new file mode 100644 index 000000000..dba495f02 --- /dev/null +++ b/packages/cli/src/core/api-spec/text.ts @@ -0,0 +1,12 @@ +// Docs-site markup: Mintlify callouts and links relative to the docs site. +const DOCS_CALLOUT = /<(Info|Note|Tip|Check|Warning)>([\s\S]*?)<\/\1>/g; +const RELATIVE_LINK = /\[([^\]]+)\]\(\/[^)]*\)/g; + +/** Description text without docs-site markup an agent can't use. */ +export function cleanText(text: string): string { + return text + .replace(DOCS_CALLOUT, (_, kind: string, body: string) => + kind === "Warning" ? `Warning: ${body}` : body, + ) + .replace(RELATIVE_LINK, "$1"); +} diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index 6e2af15b1..0629c39d6 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -10,7 +10,7 @@ import { TestOverridesSchema, } from "@/core/project/schema.js"; -function getBase44GlobalDir(): string { +export function getBase44GlobalDir(): string { return join(homedir(), ".base44"); } diff --git a/packages/cli/src/core/utils/fs.ts b/packages/cli/src/core/utils/fs.ts index e9777fe17..8c76f1232 100644 --- a/packages/cli/src/core/utils/fs.ts +++ b/packages/cli/src/core/utils/fs.ts @@ -26,7 +26,7 @@ export async function pathExists(path: string): Promise { export async function writeFile( filePath: string, - content: string, + content: string | Uint8Array, ): Promise { const dir = dirname(filePath); if (!(await pathExists(dir))) { diff --git a/packages/cli/tests/cli/api.spec.ts b/packages/cli/tests/cli/api.spec.ts new file mode 100644 index 000000000..8e4240340 --- /dev/null +++ b/packages/cli/tests/cli/api.spec.ts @@ -0,0 +1,783 @@ +import { + access, + mkdir, + readdir, + readFile, + utimes, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +interface CapturedRequest { + url: string; + authorization?: string; + contentType?: string; + body: string; +} + +function capture(req: Request): CapturedRequest { + const body = Buffer.isBuffer(req.body) + ? req.body.toString("utf-8") + : req.body && Object.keys(req.body).length > 0 + ? JSON.stringify(req.body) + : ""; + return { + url: req.originalUrl, + authorization: req.headers.authorization, + contentType: req.headers["content-type"], + body, + }; +} + +describe("api command", () => { + const t = setupCLITests(); + let specText: string; + let specFetches: number; + let sent: CapturedRequest[]; + + const cachePath = () => { + const server = new URL(t.api.baseUrl).host.replace(":", "_"); + return join(t.getTempDir(), ".base44", "cache", server, "openapi.json"); + }; + + beforeEach(async () => { + specText = await readFile(fixture("api-spec/openapi.json"), "utf-8"); + specFetches = 0; + sent = []; + t.api.mockRoute("GET", "/api/openapi.json", (_req, res) => { + specFetches++; + res.type("application/json").send(specText); + }); + }); + + describe("search", () => { + it("returns the best matches as JSON", async () => { + const result = await t.run("api", "search", "workflow", "runs"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)[0]).toEqual({ + endpoint: "GET /api/apps/{app_id}/workflows/runs", + summary: "List workflow runs for an app", + }); + }); + + it("returns only read-only operations with --read-only", async () => { + const result = await t.run("api", "search", "workflow", "--read-only"); + + t.expectResult(result).toSucceed(); + const results = JSON.parse(result.stdout); + expect(results.length).toBeGreaterThan(0); + expect( + results.every((r: { endpoint: string }) => + r.endpoint.startsWith("GET "), + ), + ).toBe(true); + }); + + it("rejects a malformed --limit", async () => { + const result = await t.run("api", "search", "apps", "--limit", "0"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--limit must be a positive integer"); + }); + }); + + describe("spec cache", () => { + it("saves the spec as is and reuses it within the hour", async () => { + await t.run("api", "search", "apps"); + const second = await t.run("api", "search", "apps"); + + t.expectResult(second).toSucceed(); + expect(specFetches).toBe(1); + expect(await readFile(cachePath(), "utf-8")).toBe(specText); + }); + + it("fetches again with --refresh", async () => { + await t.run("api", "search", "apps"); + await t.run("api", "search", "apps", "--refresh"); + + expect(specFetches).toBe(2); + }); + + it("fetches again once the cached copy is over an hour old", async () => { + await t.run("api", "search", "apps"); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await utimes(cachePath(), twoHoursAgo, twoHoursAgo); + + await t.run("api", "search", "apps"); + + expect(specFetches).toBe(2); + }); + + it("downloads again when the cached copy is unreadable", async () => { + await t.run("api", "search", "apps"); + await writeFile(cachePath(), '{"truncated'); + + const result = await t.run("api", "search", "apps"); + + t.expectResult(result).toSucceed(); + expect(specFetches).toBe(2); + expect(await readFile(cachePath(), "utf-8")).toBe(specText); + expect(await readdir(dirname(cachePath()))).toEqual(["openapi.json"]); + }); + + it("still answers when the cache cannot be written", async () => { + await mkdir(join(t.getTempDir(), ".base44"), { recursive: true }); + await writeFile(join(t.getTempDir(), ".base44", "cache"), "not a dir"); + + const result = await t.run("api", "search", "apps"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout).length).toBeGreaterThan(0); + }); + + it("does not cache a spec it cannot parse", async () => { + specText = '{"not":"a spec"}'; + const result = await t.run("api", "search", "apps"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid API spec"); + await expect(access(cachePath())).rejects.toThrow(); + }); + }); + + describe("catalog", () => { + it("lists every area", async () => { + const result = await t.run("api", "catalog"); + + t.expectResult(result).toSucceed(); + const index = JSON.parse(result.stdout); + expect(index.total).toBe(11); + expect(index.areas.map((a: { area: string }) => a.area)).toContain( + "workflows", + ); + }); + + it("lists one area's operations", async () => { + const result = await t.run("api", "catalog", "secrets"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + area: "secrets", + operations: [ + { + endpoint: "GET /api/apps/{app_id}/secrets", + summary: "List secrets", + }, + { + endpoint: "POST /api/apps/{app_id}/secrets", + summary: "Set secret", + }, + ], + total: 2, + truncated: false, + }); + }); + + it("lists an area with no read-only operations as empty", async () => { + const result = await t.run("api", "catalog", "deploy", "--read-only"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + area: "deploy", + operations: [], + total: 0, + truncated: false, + }); + }); + + it("lists only read-only operations with --read-only", async () => { + const result = await t.run("api", "catalog", "secrets", "--read-only"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toMatchObject({ + operations: [{ endpoint: "GET /api/apps/{app_id}/secrets" }], + total: 1, + }); + }); + + it("fails on an unknown area and lists the real ones", async () => { + const result = await t.run("api", "catalog", "nope"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Unknown API area: nope"); + t.expectResult(result).toContain("workflows"); + }); + }); + + describe("grep", () => { + it("searches one area with --area", async () => { + const result = await t.run( + "api", + "grep", + "workflow", + "--area", + "workflows", + ); + + t.expectResult(result).toSucceed(); + const hits = JSON.parse(result.stdout); + expect(hits).toHaveLength(4); + expect( + hits.every((h: { endpoint: string }) => + h.endpoint.includes("/workflows"), + ), + ).toBe(true); + }); + + it("searches only read-only operations with --read-only", async () => { + const result = await t.run("api", "grep", "workflow", "--read-only"); + + t.expectResult(result).toSucceed(); + expect( + JSON.parse(result.stdout).map((h: { endpoint: string }) => h.endpoint), + ).toEqual([ + "GET /api/apps/{app_id}/workflows/runs", + "GET /api/apps/{app_id}/workflows/{workflow_id}", + ]); + }); + + it("fails on an unknown --area", async () => { + const result = await t.run("api", "grep", "x", "--area", "nope"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Unknown API area: nope"); + }); + + it("returns the operations whose contract matches", async () => { + const result = await t.run("api", "grep", "trigger_\\w+"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual([ + { + endpoint: "POST /api/apps/{app_id}/workflows", + summary: "Create workflow", + }, + ]); + }); + + it("rejects an invalid regular expression", async () => { + const result = await t.run("api", "grep", "(unclosed"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid regular expression"); + }); + }); + + describe("fetch", () => { + beforeEach(async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const record = (status: number) => (req: Request, res: Response) => { + sent.push(capture(req)); + res.status(status).json({ ok: true, echoed: sent.at(-1)?.body }); + }; + t.api + .mockRoute("GET", "/api/apps/:appId/workflows/runs", record(200)) + .mockRoute("POST", "/api/apps/:appId/secrets", record(200)) + .mockRoute("DELETE", "/api/apps/:appId/workflows/:id", record(200)) + .mockRoute("GET", "/api/brand-new-route", record(200)) + .mockRoute("DELETE", "/api/apps/:appId/secrets", (_req, res) => { + res.status(204).end(); + }) + .mockRoute("GET", "/api/apps/:appId/big-number", (_req, res) => { + res + .type("application/json") + .send('{"id":12345678901234567890, "n": 1}'); + }) + .mockRoute("GET", "/api/apps/:appId/untyped", (_req, res) => { + res.end(Buffer.from([0xff, 0xfe, 0x00, 0x81])); + }) + .mockRoute("GET", "/api/apps/:appId/report.csv", (_req, res) => { + res.type("text/csv").send("a,b\n1,2"); + }) + .mockRoute("GET", "/api/apps/:appId/export", (_req, res) => { + res + .type("application/zip") + .send(Buffer.from([0x50, 0x4b, 0xff, 0x00])); + }) + .mockRoute("GET", "/api/apps/:appId/forbidden", (_req, res) => { + res.status(403).json({ detail: "You cannot open this app" }); + }); + }); + + it("sends the path and query string as is, with the login attached", async () => { + const result = await t.run( + "api", + "fetch", + "get", + "/api/apps/68a1/workflows/runs?status=failed&since=2026-10-10", + ); + + t.expectResult(result).toSucceed(); + expect(sent).toEqual([ + expect.objectContaining({ + url: "/api/apps/68a1/workflows/runs?status=failed&since=2026-10-10", + authorization: "Bearer test-access-token", + }), + ]); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: true }); + expect(specFetches).toBe(0); + }); + + it("prints the response compact when piped", async () => { + const result = await t.run("api", "fetch", "GET", "/api/brand-new-route"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toBe('{"ok":true,"echoed":""}'); + }); + + it("sends a --body JSON flag", async () => { + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/secrets", + "--body", + '{"name":"KEY","value":"v"}', + ); + + t.expectResult(result).toSucceed(); + expect(sent[0]).toMatchObject({ + contentType: "application/json", + body: '{"name":"KEY","value":"v"}', + }); + }); + + it("sends a --body @file", async () => { + const file = join(t.getTempDir(), "body.json"); + await writeFile(file, '{"from":"file"}'); + + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/secrets", + "--body", + `@${file}`, + ); + + t.expectResult(result).toSucceed(); + expect(sent[0]?.body).toBe('{"from":"file"}'); + }); + + it("sends stdin with --body @-", async () => { + t.givenStdin('{"from":"stdin"}'); + + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/secrets", + "--body", + "@-", + ); + + t.expectResult(result).toSucceed(); + expect(sent[0]?.body).toBe('{"from":"stdin"}'); + }); + + it("reads stdin only with --body @-", async () => { + t.givenStdin('{"from":"stdin"}'); + + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/secrets", + ); + + t.expectResult(result).toSucceed(); + expect(sent[0]?.body).toBe(""); + }); + + it("uploads a file as multipart with --form", async () => { + let received: { contentType?: string; body: string } | undefined; + t.api.mockRoute( + "POST", + "/api/apps/:appId/entities/:e/import", + (req, res) => { + received = { + contentType: req.headers["content-type"], + body: Buffer.isBuffer(req.body) ? req.body.toString("utf-8") : "", + }; + res.json({ imported: 2 }); + }, + ); + const csv = join(t.getTempDir(), "tasks.csv"); + await writeFile(csv, "title\nfirst\nsecond\n"); + + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/entities/Task/import", + "--form", + `file=@${csv};type=text/csv`, + "--form", + "mode=append", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ imported: 2 }); + expect(received?.contentType).toMatch(/^multipart\/form-data; boundary=/); + expect(received?.body).toContain('name="file"; filename="tasks.csv"'); + expect(received?.body).toContain("Content-Type: text/csv"); + expect(received?.body).toContain("title\nfirst\nsecond\n"); + expect(received?.body).toContain('name="mode"'); + }); + + it("refuses --body and --form together", async () => { + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/secrets", + "--body", + "{}", + "--form", + "a=b", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Use --body or --form, not both."); + expect(sent).toEqual([]); + }); + + it("ignores piped stdin on a GET", async () => { + t.givenStdin('{"ignored":true}'); + + const result = await t.run("api", "fetch", "GET", "/api/brand-new-route"); + + t.expectResult(result).toSucceed(); + expect(sent[0]?.body).toBe(""); + }); + + it("accepts a path without the leading slash", async () => { + const result = await t.run("api", "fetch", "GET", "api/brand-new-route"); + + t.expectResult(result).toSucceed(); + expect(sent[0]?.url).toBe("/api/brand-new-route"); + }); + + it("prints a piped JSON body exactly as the server sent it", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/big-number", + ); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toBe('{"id":12345678901234567890, "n": 1}'); + }); + + it("describes a body with no content type that isn't UTF-8 as binary", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/untyped", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toMatchObject({ + status: 200, + bytes: 4, + }); + }); + + it("prints the status when the response has no body", async () => { + const result = await t.run( + "api", + "fetch", + "DELETE", + "/api/apps/68a1/secrets", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ status: 204 }); + }); + + it("describes a binary response instead of printing it", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/export", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + status: 200, + contentType: "application/zip", + bytes: 4, + note: "Binary body not printed; add --output to save it.", + }); + }); + + it("wraps a text body in JSON under --json", async () => { + const result = await t.run( + "--json", + "api", + "fetch", + "GET", + "/api/apps/68a1/report.csv", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + status: 200, + contentType: "text/csv; charset=utf-8", + body: "a,b\n1,2", + }); + }); + + it("prints a text body as is without --json", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/report.csv", + ); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toBe("a,b\n1,2"); + }); + + it("saves the response as is with --output", async () => { + const file = join(t.getTempDir(), "export.zip"); + + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/export", + "--output", + file, + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + status: 200, + contentType: "application/zip", + bytes: 4, + output: file, + }); + expect([...(await readFile(file))]).toEqual([0x50, 0x4b, 0xff, 0x00]); + }); + + it("reports the server's error", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/forbidden", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("You cannot open this app"); + t.expectResult(result).toContain( + 'Response body: {"detail":"You cannot open this app"}', + ); + }); + + it("shows the server's wait on a rate-limited write, without retrying it", async () => { + let attempts = 0; + t.api.mockRoute("POST", "/api/apps/:appId/limited", (_req, res) => { + attempts++; + res.status(429).set("Retry-After", "30").json({ detail: "Slow down" }); + }); + + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/limited", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("HTTP 429 (retry after 30 s)"); + expect(attempts).toBe(1); + }); + + it("puts the status and raw body in the --json error", async () => { + const result = await t.run( + "--json", + "api", + "fetch", + "GET", + "/api/apps/68a1/forbidden", + ); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + statusCode: 403, + details: ['Response body: {"detail":"You cannot open this app"}'], + }); + }); + + it("sends a path the spec does not know", async () => { + const result = await t.run("api", "fetch", "GET", "/api/brand-new-route"); + + t.expectResult(result).toSucceed(); + expect(sent).toHaveLength(1); + }); + + describe("--read-only", () => { + it("refuses an unsafe operation without sending it", async () => { + const result = await t.run( + "api", + "fetch", + "DELETE", + "/api/apps/68a1/workflows/wf_9", + "--read-only", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "DELETE /api/apps/{app_id}/workflows/{workflow_id} is not a read-only operation", + ); + expect(sent).toEqual([]); + }); + + it("checks the path that would be sent, after dot segments resolve", async () => { + // Matches "Get workflow" as typed, but would call GET /api/apps/68a1/workflows/. + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/workflows/wf_9/..", + "--read-only", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "No operation in the API spec matches GET /api/apps/68a1/workflows/.", + ); + expect(sent).toEqual([]); + }); + + it("refuses a path with an empty segment", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1//workflows/runs", + "--read-only", + ); + + t.expectResult(result).toFail(); + expect(sent).toEqual([]); + }); + + it("refuses a path the spec does not know without sending it", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/brand-new-route", + "--read-only", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "No operation in the API spec matches", + ); + t.expectResult(result).toContain( + "base44 api fetch GET /api/brand-new-route --read-only --refresh", + ); + expect(sent).toEqual([]); + }); + + it("sends a read-only operation", async () => { + const result = await t.run( + "api", + "fetch", + "GET", + "/api/apps/68a1/workflows/runs?status=failed", + "--read-only", + ); + + t.expectResult(result).toSucceed(); + expect(sent).toHaveLength(1); + }); + }); + + describe("--help", () => { + it("prints the matched operation's contract without sending", async () => { + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps/68a1/workflows", + "--help", + ); + + t.expectResult(result).toSucceed(); + const contract = JSON.parse(result.stdout); + expect(contract).toMatchObject({ + endpoint: "POST /api/apps/{app_id}/workflows", + parameters: [expect.objectContaining({ name: "app_id" })], + requestBodySchema: { + properties: { trigger_status: { type: "string" } }, + }, + }); + expect(contract).not.toHaveProperty("readOnly"); + expect(sent).toEqual([]); + }); + + it("tells how to send a multipart body", async () => { + const result = await t.run( + "api", + "fetch", + "POST", + "/api/apps", + "--help", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toMatchObject({ + requestBodyContentType: "multipart/form-data", + hint: "Send this body with --form name=value, and --form name=@path for files.", + }); + }); + + it("prints the command help without a path", async () => { + const result = await t.run("api", "fetch", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Usage: base44 api fetch"); + }); + + it("wraps the command help in JSON under --json", async () => { + const result = await t.run("--json", "api", "fetch", "--help"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout).usage).toContain( + "Usage: base44 api fetch", + ); + }); + }); + }); +}); + +describe("api fetch when logged out", () => { + const t = setupCLITests(); + + it("prints a contract without a login", async () => { + const specText = await readFile(fixture("api-spec/openapi.json"), "utf-8"); + t.api.mockRoute("GET", "/api/openapi.json", (_req, res) => { + res.type("application/json").send(specText); + }); + + const result = await t.run("api", "fetch", "GET", "/api/apps", "--help"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toMatchObject({ + endpoint: "GET /api/apps", + }); + }); +}); diff --git a/packages/cli/tests/cli/api_redaction.spec.ts b/packages/cli/tests/cli/api_redaction.spec.ts new file mode 100644 index 000000000..fc95e9813 --- /dev/null +++ b/packages/cli/tests/cli/api_redaction.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { reportedFetchCommand } from "../../src/cli/commands/api/shared.js"; +import { redactedApiError } from "../../src/core/api-spec/request.js"; +import { ApiError } from "../../src/core/errors.js"; + +// Error reporting sends ApiError fields and command options to PostHog, and +// any endpoint `api fetch` reaches can carry secrets. +describe("api fetch error reporting", () => { + const failed = new ApiError("Error calling POST /api/apps/a1/secrets: Bad", { + statusCode: 422, + requestUrl: "https://app.base44.com/api/apps/a1/secrets?token=t0k", + requestMethod: "POST", + requestBody: '{"name":"STRIPE_KEY","value":"sk_live_123"}', + responseBody: { detail: "sk_live_123 is not valid" }, + requestId: "req-1", + }); + + it("drops both bodies and the query string from the error", () => { + const reported = redactedApiError( + failed, + '{"detail":"sk_live_123 is not valid"}', + "POST", + "/api/apps/a1/secrets?token=t0k", + ); + + expect(reported.requestBody).toBeUndefined(); + expect(reported.responseBody).toBeUndefined(); + expect(reported.requestUrl).toBe( + "https://app.base44.com/api/apps/a1/secrets", + ); + expect(reported.message).toBe( + "Error calling POST /api/apps/a1/secrets: HTTP 422", + ); + expect(reported).toMatchObject({ statusCode: 422, requestId: "req-1" }); + }); + + it("gives the caller the server's raw reply in the details", () => { + const reported = redactedApiError(failed, '{"detail":"x"}', "POST", "/p"); + + expect(reported.details).toEqual(['Response body: {"detail":"x"}']); + }); + + it("names only the status when the error body is empty", () => { + const reported = redactedApiError( + failed, + undefined, + "POST", + "/api/apps/a1/secrets?token=t0k", + ); + + expect(reported.message).toBe( + "Error calling POST /api/apps/a1/secrets: HTTP 422", + ); + }); + + it("keeps a network error's words without the query string", () => { + const networkError = new ApiError( + "Error calling GET /api/x?token=t0k: Request timed out: GET https://h/api/x?token=t0k", + { + cause: new Error( + "Request timed out: GET https://h/api/x?token=t0k after 10s", + ), + }, + ); + + const reported = redactedApiError( + networkError, + undefined, + "GET", + "/api/x?token=t0k", + ); + + expect(reported.message).toBe( + "Error calling GET /api/x: Request timed out: GET https://h/api/x after 10s", + ); + expect(reported.cause).toBeUndefined(); + }); + + it("adds the server's Retry-After wait to the message", () => { + const limited = new ApiError("Error calling POST /p: limited", { + statusCode: 429, + }); + + expect( + redactedApiError(limited, undefined, "POST", "/p", "30").message, + ).toBe("Error calling POST /p: HTTP 429 (retry after 30 s)"); + expect( + redactedApiError( + limited, + undefined, + "POST", + "/p", + "Wed, 21 Oct 2026 07:28:00 GMT", + ).message, + ).toBe( + "Error calling POST /p: HTTP 429 (retry after Wed, 21 Oct 2026 07:28:00 GMT)", + ); + }); + + it("records the command without --body or the query string", () => { + expect( + reportedFetchCommand("POST", "/api/apps/a1/secrets?token=t0k", { + body: '{"value":"sk_live_123"}', + readOnly: true, + }), + ).toEqual({ + name: "api fetch", + args: ["POST", "/api/apps/a1/secrets"], + options: { body: "[not reported]", readOnly: true }, + }); + }); + + it("records the command without --form values", () => { + expect( + reportedFetchCommand("POST", "/api/fonts", { + form: ["file=@font.ttf", "token=sk_live_123"], + }).options, + ).toEqual({ form: "[not reported]" }); + }); + + it("leaves options without a body as they are", () => { + expect(reportedFetchCommand("GET", "/api/apps", {}).options).toEqual({}); + }); +}); diff --git a/packages/cli/tests/core/api-spec.spec.ts b/packages/cli/tests/core/api-spec.spec.ts new file mode 100644 index 000000000..15261e8fd --- /dev/null +++ b/packages/cli/tests/core/api-spec.spec.ts @@ -0,0 +1,534 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + areaListing, + areaOf, + catalogIndex, + getOperationContract, + getSpecCachePath, + grepOperations, + groupByArea, + isSpecCacheFresh, + listOperations, + matchOperation, + normalizePath, + type OpenApiSpec, + parseSpec, + SPEC_CACHE_TTL_MS, + searchOperations, +} from "../../src/core/api-spec/index.js"; + +const FIXTURE = resolve(__dirname, "../fixtures/api-spec/openapi.json"); + +let spec: OpenApiSpec; +beforeAll(async () => { + spec = parseSpec(await readFile(FIXTURE, "utf-8")); +}); + +const ops = () => listOperations(spec); +const find = (method: string, path: string) => + ops().find((op) => op.method === method && op.path === path); + +describe("listOperations", () => { + it("lists every method of every path, skipping path-level keys", () => { + expect(ops()).toHaveLength(11); + expect( + find("GET", "/api/apps/{app_id}/workflows/{workflow_id}"), + ).toBeDefined(); + }); + + it("marks read-only from the personal access token requirement", () => { + expect(find("GET", "/api/apps")?.readOnly).toBe(true); + expect(find("POST", "/api/apps")?.readOnly).toBe(false); + expect(find("POST", "/api/apps/{app_id}/deploy")?.readOnly).toBe(false); + expect(find("GET", "/api/health")?.readOnly).toBe(false); + }); + + it("falls back to the spec-wide security when an operation lists none", () => { + expect(find("GET", "/api/whoami")?.readOnly).toBe(false); + const readOnlyByDefault = { + ...spec, + security: [{ PersonalAccessTokenAuth: ["read_only"] }], + }; + const whoami = listOperations(readOnlyByDefault).find( + (op) => op.path === "/api/whoami", + ); + expect(whoami?.readOnly).toBe(true); + }); +}); + +describe("normalizePath", () => { + it("resolves dot segments and their encodings the way the request URL does", () => { + expect(normalizePath("/api/apps/x/workflows/..")).toBe("/api/apps/x/"); + expect(normalizePath("api/apps/x/workflows/%2e%2e/secrets?a=1")).toBe( + "/api/apps/x/secrets?a=1", + ); + expect(normalizePath("/api/apps/./x")).toBe("/api/apps/x"); + }); +}); + +describe("component refs outside schemas", () => { + const withRefs = () => + parseSpec( + JSON.stringify({ + openapi: "3.1.0", + paths: { + "/api/things/{id}": { + post: { + summary: "Create thing", + parameters: [ + { $ref: "#/components/parameters/Id" }, + { $ref: "#/components/parameters/Missing" }, + ], + requestBody: { $ref: "#/components/requestBodies/Thing" }, + responses: { "200": { $ref: "#/components/responses/Thing" } }, + }, + }, + }, + components: { + parameters: { + Id: { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + }, + requestBodies: { + Thing: { + content: { "application/json": { schema: { type: "object" } } }, + }, + }, + responses: { + Thing: { + description: "OK", + content: { "application/json": { schema: { type: "array" } } }, + }, + }, + }, + }), + ); + + it("resolves parameters, request bodies and responses, and keeps an unresolvable ref", () => { + const loaded = withRefs(); + const [op] = listOperations(loaded); + if (!op) throw new Error("no operation"); + const contract = getOperationContract(loaded, op); + expect(contract.parameters).toEqual([ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { $ref: "#/components/parameters/Missing" }, + ]); + expect(contract.requestBodySchema).toEqual({ type: "object" }); + expect(contract.responseSchema).toEqual({ type: "array" }); + }); +}); + +describe("a malformed route", () => { + it("is skipped, and the rest of the spec still loads", () => { + const loaded = parseSpec( + JSON.stringify({ + openapi: "3.1.0", + security: "not a list", + paths: { + "/api/good": { get: { summary: "Good" } }, + "/api/bad": { + parameters: "not a list", + get: { summary: "Bad", security: "not a list" }, + post: { summary: "Fine" }, + }, + }, + }), + ); + expect(listOperations(loaded).map((op) => op.endpoint)).toEqual([ + "GET /api/good", + "POST /api/bad", + ]); + }); +}); + +describe("parseSpec", () => { + it("rejects text that is not an OpenAPI document", () => { + expect(() => parseSpec("not json")).toThrow("not valid JSON"); + expect(() => parseSpec('{"paths":{}}')).toThrow("Invalid API spec"); + }); +}); + +describe("searchOperations", () => { + it("ranks the operation whose path and summary match first", () => { + const [top] = searchOperations(ops(), "workflow runs"); + expect(top).toEqual({ + endpoint: "GET /api/apps/{app_id}/workflows/runs", + summary: "List workflow runs for an app", + }); + }); + + it("matches word prefixes and light typos", () => { + expect(searchOperations(ops(), "secr")[0]?.endpoint).toContain( + "/api/apps/{app_id}/secrets", + ); + expect(searchOperations(ops(), "secrts")[0]?.endpoint).toContain( + "/api/apps/{app_id}/secrets", + ); + }); + + it("finds an operation by a parameter description", () => { + expect(searchOperations(ops(), "failed")[0]?.endpoint).toBe( + "GET /api/apps/{app_id}/workflows/runs", + ); + }); + + it("returns only read-only operations under readOnly", () => { + const results = searchOperations(ops(), "workflow", { readOnly: true }); + expect(results.length).toBeGreaterThan(0); + expect(results.every((r) => r.endpoint.startsWith("GET "))).toBe(true); + expect(results.map((r) => r.summary)).not.toContain("Delete workflow"); + }); + + it("drops results far below the best score", () => { + const results = searchOperations(ops(), "secrets", { limit: 20 }); + expect(results.every((r) => r.endpoint.includes("secrets"))).toBe(true); + }); + + it("returns at most limit results, 5 by default", () => { + expect( + searchOperations(ops(), "api", { limit: 20 }).length, + ).toBeGreaterThan(5); + expect(searchOperations(ops(), "api")).toHaveLength(5); + expect(searchOperations(ops(), "api", { limit: 2 })).toHaveLength(2); + }); + + it("drops filler words, so a question of them finds nothing", () => { + expect(searchOperations(ops(), "who am i")).toEqual([]); + expect(searchOperations(ops(), "list my apps")[0]?.endpoint).toBe( + "GET /api/apps", + ); + }); + + it("ranks the operation whose summary holds the query", () => { + expect(searchOperations(ops(), "deploy app")[0]?.endpoint).toBe( + "POST /api/apps/{app_id}/deploy", + ); + }); + + it("puts the summary that names the query exactly first", () => { + expect(searchOperations(ops(), "get workflow")[0]).toEqual({ + endpoint: "GET /api/apps/{app_id}/workflows/{workflow_id}", + summary: "Get workflow", + }); + }); + + it("puts reads first among equally exact summaries", () => { + expect(searchOperations(ops(), "workflow")[0]?.endpoint).toBe( + "GET /api/apps/{app_id}/workflows/{workflow_id}", + ); + }); + + it("ignores one-letter words and id", () => { + expect(searchOperations(ops(), "x")).toEqual([]); + expect(searchOperations(ops(), "id")).toEqual([]); + }); + + it("does not read words from docs link targets or callout tags", () => { + // "guides" is only in a link target, "Warning" only a tag name. + expect(searchOperations(ops(), "guides")).toEqual([]); + expect(searchOperations(ops(), "info")).toEqual([]); + }); + + it("returns nothing for words the spec does not hold", () => { + expect(searchOperations(ops(), "zebra")).toEqual([]); + }); +}); + +describe("matchOperation", () => { + it("matches a concrete path to its template", () => { + const op = matchOperation(ops(), "get", "/api/apps/68a1/workflows/wf_9"); + expect(op?.path).toBe("/api/apps/{app_id}/workflows/{workflow_id}"); + expect(op?.method).toBe("GET"); + }); + + it("prefers a literal segment over a placeholder", () => { + const op = matchOperation(ops(), "GET", "/api/apps/68a1/workflows/runs"); + expect(op?.path).toBe("/api/apps/{app_id}/workflows/runs"); + }); + + it("ignores the query string and a trailing slash", () => { + const op = matchOperation( + ops(), + "GET", + "/api/apps/68a1/workflows/runs/?status=failed&since=2026-10-10", + ); + expect(op?.path).toBe("/api/apps/{app_id}/workflows/runs"); + }); + + it("matches the method too", () => { + expect( + matchOperation(ops(), "DELETE", "/api/apps/68a1/workflows/wf_9") + ?.readOnly, + ).toBe(false); + expect( + matchOperation(ops(), "PUT", "/api/apps/68a1/workflows/wf_9"), + ).toBeUndefined(); + }); + + it("matches no template for a path with an empty segment", () => { + expect( + matchOperation(ops(), "GET", "/api/apps/68a1//workflows/runs"), + ).toBeUndefined(); + }); + + it("returns undefined for a path the spec does not know", () => { + expect(matchOperation(ops(), "GET", "/api/apps/68a1/nope")).toBeUndefined(); + expect( + matchOperation(ops(), "GET", "/api/apps/68a1/workflows/a/b"), + ).toBeUndefined(); + }); +}); + +describe("getOperationContract", () => { + const contractOf = (method: string, path: string) => { + const op = find(method, path); + if (!op) throw new Error("fixture changed"); + return getOperationContract(spec, op); + }; + + it("includes path-level parameters shared by every method", () => { + const names = (method: string) => + contractOf( + method, + "/api/apps/{app_id}/workflows/{workflow_id}", + ).parameters.map((p) => `${p.in}:${p.name}`); + expect(names("GET")).toEqual([ + "header:x-request-source", + "path:app_id", + "path:workflow_id", + ]); + expect(names("DELETE")).toContain("header:x-request-source"); + }); + + it("names a body that is not JSON, with its schema", () => { + expect(contractOf("POST", "/api/apps")).toMatchObject({ + requestBodyContentType: "multipart/form-data", + requestBodySchema: { properties: { file: { format: "binary" } } }, + }); + }); + + it("lists every schema still referenced after inlining", () => { + expect(Object.keys(contractOf("GET", "/api/apps").refs ?? {})).toEqual([ + "#/components/schemas/App", + ]); + expect(contractOf("GET", "/api/apps/{app_id}/secrets")).not.toHaveProperty( + "refs", + ); + }); + + it("keeps the success response schema with its refs inlined", () => { + const contract = contractOf("GET", "/api/apps"); + expect(contract).toMatchObject({ + endpoint: "GET /api/apps", + method: "GET", + path: "/api/apps", + summary: "List apps", + parameters: [], + requestBodySchema: null, + }); + expect(contract.responseSchema).toMatchObject({ + properties: { + apps: { items: { properties: { id: { type: "string" } } } }, + }, + }); + }); + + it("stops at a cycle and lets keys beside a $ref win", () => { + const parent = ( + contractOf("GET", "/api/apps").responseSchema as { + properties: { apps: { items: { properties: { parent: unknown } } } }; + } + ).properties.apps.items.properties.parent; + expect(parent).toEqual({ + $ref: "#/components/schemas/App", + description: "The app this one was remixed from.", + }); + }); + + it("drops FastAPI's generated titles but keeps a field named title", () => { + const body = contractOf("POST", "/api/apps/{app_id}/workflows") + .requestBodySchema as Record; + expect(body).not.toHaveProperty("title"); + expect(body.properties).toMatchObject({ + title: { type: "string", description: "Shown in the workflow list." }, + }); + expect(JSON.stringify(body)).not.toContain('"title":"Title"'); + }); + + it("writes anyOf with null as a nullable type", () => { + const status = contractOf( + "GET", + "/api/apps/{app_id}/workflows/runs", + ).parameters.find((p) => p.name === "status"); + expect(status?.schema).toEqual({ type: ["string", "null"] }); + }); + + it("strips docs callout tags and keeps their text", () => { + const { description } = contractOf( + "GET", + "/api/apps/{app_id}/workflows/{workflow_id}", + ); + expect(description).not.toMatch(/<\/?(Info|Warning)>/); + expect(description).toContain("This endpoint is in beta."); + expect(description).toContain( + "Warning: Archived workflows are not returned.", + ); + }); + + it("turns relative docs links into their text", () => { + const { description } = contractOf( + "GET", + "/api/apps/{app_id}/workflows/{workflow_id}", + ); + expect(description).toContain("Start it with Run a workflow now."); + expect(description).not.toContain("](/"); + }); + + it("leaves error responses out, as the MCP bridge does", () => { + expect(contractOf("GET", "/api/apps")).not.toHaveProperty("errors"); + }); + + it("returns the request body schema and trimmed parameters", () => { + const contract = contractOf("GET", "/api/apps/{app_id}/workflows/runs"); + expect(contract.parameters).toEqual([ + { + name: "app_id", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "status", + in: "query", + required: false, + schema: { type: ["string", "null"] }, + description: "Keep only runs in this state, for example `failed`.", + }, + ]); + expect( + contractOf("POST", "/api/apps/{app_id}/workflows").requestBodySchema, + ).toMatchObject({ properties: { trigger_status: { type: "string" } } }); + }); +}); + +describe("grepOperations", () => { + it("finds the operation whose inlined schema holds the field", () => { + expect(grepOperations(spec, ops(), "trigger_\\w+")).toEqual([ + { + endpoint: "POST /api/apps/{app_id}/workflows", + summary: "Create workflow", + }, + ]); + }); + + it("searches only the operations given", () => { + const workflows = ops().filter((op) => op.path.includes("/workflows")); + expect(grepOperations(spec, workflows, "workflow")).toHaveLength(4); + }); + + it("does not search the generated operationId", () => { + expect(grepOperations(spec, ops(), "list_app_runs")).toEqual([]); + }); + + it("matches the endpoint, case-insensitively when asked", () => { + expect(grepOperations(spec, ops(), "DEPLOY AN APP")).toEqual([]); + expect( + grepOperations(spec, ops(), "DEPLOY AN APP", { ignoreCase: true }), + ).toEqual([ + { endpoint: "POST /api/apps/{app_id}/deploy", summary: "Deploy an app" }, + ]); + }); +}); + +describe("catalog", () => { + it("caps one area's listing and says when it is truncated", () => { + const workflows = groupByArea(ops()).get("workflows") ?? []; + expect(areaListing("workflows", workflows)).toMatchObject({ + total: 4, + truncated: false, + }); + const capped = areaListing("workflows", workflows, 3); + expect(capped.operations).toHaveLength(3); + expect(capped).toMatchObject({ total: 4, truncated: true }); + }); + + it("names the area after {app_id}, else after /api", () => { + expect(areaOf("/api/apps/{app_id}/deploy")).toBe("deploy"); + expect(areaOf("/api/apps/{app_id}/workflows/{workflow_id}")).toBe( + "workflows", + ); + expect(areaOf("/api/apps/{app_id}")).toBe("apps"); + expect(areaOf("/api/apps")).toBe("apps"); + expect(areaOf("/api/workspace/{workspace_id}/connectors")).toBe( + "workspace", + ); + expect(areaOf("/health")).toBe("health"); + expect(areaOf("/")).toBe("other"); + }); + + it("sorts areas and their operations", () => { + const areas = groupByArea(ops()); + expect([...areas.keys()]).toEqual([ + "apps", + "deploy", + "health", + "secrets", + "whoami", + "workflows", + ]); + expect( + areas.get("workflows")?.map((op) => `${op.method} ${op.path}`), + ).toEqual([ + "DELETE /api/apps/{app_id}/workflows/{workflow_id}", + "GET /api/apps/{app_id}/workflows/runs", + "GET /api/apps/{app_id}/workflows/{workflow_id}", + "POST /api/apps/{app_id}/workflows", + ]); + }); + + it("lists small areas in full and collapses the rest", () => { + const { areas, total } = catalogIndex(ops()); + expect(total).toBe(11); + expect(areas.find((a) => a.area === "secrets")).toEqual({ + area: "secrets", + operations: [ + { endpoint: "GET /api/apps/{app_id}/secrets", summary: "List secrets" }, + { endpoint: "POST /api/apps/{app_id}/secrets", summary: "Set secret" }, + ], + }); + expect(areas.find((a) => a.area === "workflows")).toEqual({ + area: "workflows", + operationCount: 4, + sample: [ + "List workflow runs for an app", + "Get workflow", + "Delete workflow", + ], + }); + }); +}); + +describe("spec cache", () => { + it("keeps one cache file per server", () => { + expect(getSpecCachePath("https://app.base44.com")).toBe( + join(homedir(), ".base44", "cache", "app.base44.com", "openapi.json"), + ); + expect(getSpecCachePath("http://localhost:8000/")).toBe( + join(homedir(), ".base44", "cache", "localhost_8000", "openapi.json"), + ); + }); + + it("is fresh for one hour", () => { + const now = 1_000_000_000; + expect(isSpecCacheFresh(now - 1000, now)).toBe(true); + expect(isSpecCacheFresh(now - SPEC_CACHE_TTL_MS + 1, now)).toBe(true); + expect(isSpecCacheFresh(now - SPEC_CACHE_TTL_MS, now)).toBe(false); + }); +}); diff --git a/packages/cli/tests/fixtures/api-spec/openapi.json b/packages/cli/tests/fixtures/api-spec/openapi.json new file mode 100644 index 000000000..1ce2e8e32 --- /dev/null +++ b/packages/cli/tests/fixtures/api-spec/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Fixture","version":"1.0.0"},"security":[{"PersonalAccessTokenAuth":[]}],"paths":{"/api/apps":{"get":{"summary":"List apps","description":"Returns every app you can open.","operationId":"list_apps","parameters":[],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppList"}}}},"403":{"description":"You can't open this workspace."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"PersonalAccessTokenAuth":["read_only"]}]},"post":{"summary":"Create app","description":"Creates a new app.","operationId":"create_app","requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/App"}}}}},"security":[{"PersonalAccessTokenAuth":["full_access"]}]}},"/api/apps/{app_id}/workflows":{"post":{"summary":"Create workflow","description":"Creates a workflow on the app.","operationId":"create_workflow","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCreate"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["full_access"]}]}},"/api/apps/{app_id}/workflows/runs":{"get":{"summary":"List workflow runs for an app","description":"Returns runs across every workflow on the app, newest first.","operationId":"list_app_runs","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"},"description":"Keep only runs in this state, for example `failed`."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["read_only"]}]}},"/api/apps/{app_id}/workflows/{workflow_id}":{"parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"x-request-source","in":"header","required":false,"schema":{"type":"string"}}],"get":{"summary":"Get workflow","description":"This endpoint is in beta. Returns one workflow. Start it with [Run a workflow now](/guides/run-now). Archived workflows are not returned.","operationId":"get_workflow","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["read_only"]}]},"delete":{"summary":"Delete workflow","description":"Deletes a workflow and its runs.","operationId":"delete_workflow","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["full_access"]}]}},"/api/apps/{app_id}/secrets":{"get":{"summary":"List secrets","description":"Lists the names of the app's secrets.\n\nValues are never \"returned\".","operationId":"list_secrets","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["read_only"]}]},"post":{"summary":"Set secret","description":"Sets one secret value.","operationId":"set_secret","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["full_access"]}]}},"/api/apps/{app_id}/deploy":{"post":{"summary":"Deploy an app","description":"Publishes the app.","operationId":"deploy_app","parameters":[{"name":"app_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"PersonalAccessTokenAuth":["full_access"]},{"WorkspaceApiKeyAuth":["apps:deploy"]}]}},"/api/health":{"get":{"summary":"Health check","description":"Reports whether the server is up.","operationId":"health","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[]}},"/api/whoami":{"get":{"summary":"Current user","description":"Uses the spec-wide security.","operationId":"whoami","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}}},"components":{"schemas":{"App":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/App","description":"The app this one was remixed from."}}},"AppList":{"type":"object","properties":{"apps":{"type":"array","items":{"$ref":"#/components/schemas/App"}}}},"WorkflowCreate":{"type":"object","properties":{"name":{"type":"string"},"trigger_status":{"type":"string"},"title":{"type":"string","title":"Title","description":"Shown in the workflow list."}},"title":"WorkflowCreate"},"HTTPValidationError":{"type":"object","properties":{"detail":{"type":"array"}}}},"securitySchemes":{"PersonalAccessTokenAuth":{"type":"http","scheme":"bearer"}}}} \ No newline at end of file