Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>/` 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
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>` | Search every public Base44 API operation by what it does |
| `api grep <pattern>` | Search the API spec with a regular expression, for when search misses |
| `api fetch <METHOD> <path>` | 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 |
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/cli/commands/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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) {

@ronnyrin ronnyrin Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch, many undefined checks around the code

return { stdout: formatJson(catalogIndex(readOnly(operations))) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of the naming but not critical

}
// 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);
}
237 changes: 237 additions & 0 deletions packages/cli/src/cli/commands/api/fetch.ts
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must say that the term spec is a little confusing to me. Is it the standard? In wix it's called schema afaik

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 "<what you want to do>"',
},
],
},
);
}
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'";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The \n shouldn't be here but at the concatenation


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<string> {
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 <file> 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<RunCommandResult> {
// 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/<app-id>/workflows/runs?status=failed"',
)
.option(
"--body <json>",
"Request body: JSON, @file.json, or @- to read it from stdin",
)
.option(
"--form <field>",
"Multipart field: name=value, or name=@file to attach a file (repeatable)",
(field: string, fields: string[]) => [...fields, field],
[],
)
.option("--output <file>", "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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would rename it to be more explicit like --invalidate-cache

.option(
"-h, --help",
"With a method and path, print that operation's contract; otherwise this help",
)
.addHelpText("after", CONTRACT_HELP)
.action(fetchAction);
}
Loading
Loading