cli/run.ts
Rama main · réplica de lectura del repositorio original.
import type * as v from "valibot";
import {
type AdminApiTransport,
adminApiChildCall,
adminApiCustomRoute,
adminApiDelete,
adminApiGet,
adminApiList,
adminApiWrite,
} from "#shared/admin-api-client.ts";
import { parseOrThrow } from "#shared/validation/parse.ts";
import {
bodySchemaFor,
type ParsedCommand,
parseCommand,
type Table,
} from "./commands.ts";
import type { CliConfig } from "./config.ts";
import { describeEntries } from "./describe.ts";
import { CliError, fieldProblems, toErrorResult } from "./errors.ts";
/** The seams the runner runs on: the table the commands read, the transport
* the client sends through, and the two IO sides the tests replace. */
export type RunDeps = {
config: () => Promise<CliConfig>;
readStdin: () => Promise<string>;
table: Table;
transport: AdminApiTransport;
};
/** What one run answered: the streams it wrote and the exit code it asks
* for. The entrypoint applies them. */
export type RunResult = { code: number; err?: string; out?: string };
const jsonLine = (value: unknown): string => `${JSON.stringify(value)}\n`;
/** Parse one body argument: its JSON text, or all of stdin for "-". */
const parseBodyJson = async (
source: string,
readStdin: () => Promise<string>,
): Promise<unknown> => {
const text = source === "-" ? await readStdin() : source;
try {
return JSON.parse(text);
} catch (error) {
// JSON.parse answers a SyntaxError whose message names the position.
const message = (error as Error).message;
throw new CliError("validation", `The body is not valid JSON: ${message}.`);
}
};
/** Check one write's body against the schema the table declares for its
* verb, before anything is sent. A schema the table does not declare leaves
* the body to the server's own validation. */
const validatedBody = async (
command: Extract<ParsedCommand, { kind: "write" }>,
schema: v.GenericSchema | undefined,
readStdin: () => Promise<string>,
): Promise<unknown | undefined> => {
// An absent body supplies no fields. Checking it against the schema names
// every field the write would have missed (a delete without its
// confirmation, for one) instead of one round trip for the same answer.
const raw =
command.body === undefined
? undefined
: await parseBodyJson(command.body, readStdin);
if (schema === undefined) return raw;
return parseOrThrow(
schema,
raw ?? {},
(issues) =>
new CliError(
"validation",
"The body failed validation. It was not sent.",
fieldProblems(issues),
),
);
};
/** Run one request command against the typed client. */
const execute = async (
command: Exclude<ParsedCommand, { kind: "describe" }>,
deps: RunDeps,
): Promise<unknown> => {
const { transport } = deps;
if (command.kind === "list") {
return adminApiList(transport, command.entry);
}
if (command.kind === "read") {
return adminApiGet(transport, command.entry, command.id);
}
if (command.kind === "custom") {
return adminApiCustomRoute(
transport,
command.entry,
command.routeKey,
command.id,
);
}
const body = await validatedBody(
command,
bodySchemaFor(command.entry, command),
deps.readStdin,
);
if (command.child) {
return adminApiChildCall(
transport,
command.entry,
command.verb,
command.child.key,
command.parentId,
command.child.id,
body,
);
}
if (command.verb === "create") {
return adminApiWrite(transport, command.entry, "create", undefined, body);
}
if (command.verb === "update") {
return adminApiWrite(
transport,
command.entry,
"update",
command.parentId,
body,
);
}
// The body already passed DeleteBodySchema above — bodySchemaFor answers
// it for every delete — so it is the body the client asks for.
const deleteBody = body as Parameters<typeof adminApiDelete>[3];
return adminApiDelete(transport, command.entry, command.parentId, deleteBody);
};
/** One CLI run: parse the arguments, send one request, and answer with the
* contract's streams and exit code. Never throws — every failure becomes
* the stderr JSON error and its exit code. */
export const runApi = async (
args: readonly string[],
deps: RunDeps,
): Promise<RunResult> => {
try {
const command = parseCommand(deps.table, args);
if (command.kind === "describe") {
return { code: 0, out: jsonLine(describeEntries(command.resources)) };
}
// Fail before any request when the site or the key is missing.
await deps.config();
return { code: 0, out: jsonLine(await execute(command, deps)) };
} catch (error) {
return toErrorResult(error);
}
};