diff --git a/README.md b/README.md index 75b86528..38a7dd30 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`connectors pull`](https://docs.base44.com/developers/references/cli/commands/connectors-pull) | Pull connectors from Base44 to local files | | [`connectors push`](https://docs.base44.com/developers/references/cli/commands/connectors-push) | Push local connectors to Base44 | | [`entities push`](https://docs.base44.com/developers/references/cli/commands/entities-push) | Push local entities to Base44 | +| [`entities records list`](https://docs.base44.com/developers/references/cli/commands/entities-records) | List entity records | +| [`entities records get`](https://docs.base44.com/developers/references/cli/commands/entities-records) | Get a single entity record by ID | +| [`entities records create`](https://docs.base44.com/developers/references/cli/commands/entities-records) | Create a new entity record | +| [`entities records update`](https://docs.base44.com/developers/references/cli/commands/entities-records) | Update an entity record | +| [`entities records delete`](https://docs.base44.com/developers/references/cli/commands/entities-records) | Delete an entity record | | [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | | [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser | diff --git a/src/cli/commands/entities/index.ts b/src/cli/commands/entities/index.ts new file mode 100644 index 00000000..6fc9723c --- /dev/null +++ b/src/cli/commands/entities/index.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getEntitiesPushCommand } from "./push.js"; +import { getRecordsCommand } from "./records/index.js"; + +export function getEntitiesCommand(context: CLIContext): Command { + return new Command("entities") + .description("Manage project entities") + .addCommand(getEntitiesPushCommand(context)) + .addCommand(getRecordsCommand(context)); +} diff --git a/src/cli/commands/entities/push.ts b/src/cli/commands/entities/push.ts index caabb4e3..da25ff8e 100644 --- a/src/cli/commands/entities/push.ts +++ b/src/cli/commands/entities/push.ts @@ -42,13 +42,9 @@ async function pushEntitiesAction(): Promise { } export function getEntitiesPushCommand(context: CLIContext): Command { - return new Command("entities") - .description("Manage project entities") - .addCommand( - new Command("push") - .description("Push local entities to Base44") - .action(async () => { - await runCommand(pushEntitiesAction, { requireAuth: true }, context); - }), - ); + return new Command("push") + .description("Push local entity schemas to Base44") + .action(async () => { + await runCommand(pushEntitiesAction, { requireAuth: true }, context); + }); } diff --git a/src/cli/commands/entities/records/create.ts b/src/cli/commands/entities/records/create.ts new file mode 100644 index 00000000..2182e849 --- /dev/null +++ b/src/cli/commands/entities/records/create.ts @@ -0,0 +1,52 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { createRecord } from "@/core/resources/entity/index.js"; +import { parseRecordData } from "./parseRecordData.js"; + +interface CreateRecordCommandOptions { + data?: string; + file?: string; +} + +async function createRecordAction( + entityName: string, + options: CreateRecordCommandOptions, +): Promise { + const data = await parseRecordData( + options, + '{"name": "John", "email": "john@example.com"}', + ); + + const record = await runTask( + `Creating ${entityName} record...`, + async () => { + return await createRecord(entityName, data); + }, + { + successMessage: `Created ${entityName} record`, + errorMessage: `Failed to create ${entityName} record`, + }, + ); + + log.info(JSON.stringify(record, null, 2)); + + return { outroMessage: `Record created with ID: ${record.id}` }; +} + +export function getRecordsCreateCommand(context: CLIContext): Command { + return new Command("create") + .description("Create a new entity record") + .argument("", "Name of the entity (e.g. Users, Products)") + .option("-d, --data ", "JSON object with record data") + .option("--file ", "Read record data from a JSON/JSONC file") + .action(async (entityName: string, options: CreateRecordCommandOptions) => { + await runCommand( + () => createRecordAction(entityName, options), + { requireAuth: true }, + context, + ); + }); +} diff --git a/src/cli/commands/entities/records/delete.ts b/src/cli/commands/entities/records/delete.ts new file mode 100644 index 00000000..a0d503c7 --- /dev/null +++ b/src/cli/commands/entities/records/delete.ts @@ -0,0 +1,61 @@ +import { confirm } from "@clack/prompts"; +import { Command } from "commander"; +import { CLIExitError } from "@/cli/errors.js"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { deleteRecord } from "@/core/resources/entity/index.js"; + +interface DeleteRecordCommandOptions { + yes?: boolean; +} + +async function deleteRecordAction( + entityName: string, + recordId: string, + options: DeleteRecordCommandOptions, +): Promise { + if (!options.yes) { + const confirmed = await confirm({ + message: `Delete ${entityName} record ${recordId}?`, + }); + + if (confirmed !== true) { + throw new CLIExitError(0); + } + } + + await runTask( + `Deleting ${entityName} record...`, + async () => { + return await deleteRecord(entityName, recordId); + }, + { + successMessage: `Deleted ${entityName} record`, + errorMessage: `Failed to delete ${entityName} record`, + }, + ); + + return { outroMessage: `Record ${recordId} deleted` }; +} + +export function getRecordsDeleteCommand(context: CLIContext): Command { + return new Command("delete") + .description("Delete an entity record") + .argument("", "Name of the entity (e.g. Users, Products)") + .argument("", "ID of the record to delete") + .option("-y, --yes", "Skip confirmation prompt") + .action( + async ( + entityName: string, + recordId: string, + options: DeleteRecordCommandOptions, + ) => { + await runCommand( + () => deleteRecordAction(entityName, recordId, options), + { requireAuth: true }, + context, + ); + }, + ); +} diff --git a/src/cli/commands/entities/records/get.ts b/src/cli/commands/entities/records/get.ts new file mode 100644 index 00000000..4ec43549 --- /dev/null +++ b/src/cli/commands/entities/records/get.ts @@ -0,0 +1,40 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { getRecord } from "@/core/resources/entity/index.js"; + +async function getRecordAction( + entityName: string, + recordId: string, +): Promise { + const record = await runTask( + `Fetching ${entityName} record...`, + async () => { + return await getRecord(entityName, recordId); + }, + { + successMessage: `Fetched ${entityName} record`, + errorMessage: `Failed to fetch ${entityName} record`, + }, + ); + + log.info(JSON.stringify(record, null, 2)); + + return {}; +} + +export function getRecordsGetCommand(context: CLIContext): Command { + return new Command("get") + .description("Get a single entity record by ID") + .argument("", "Name of the entity (e.g. Users, Products)") + .argument("", "ID of the record") + .action(async (entityName: string, recordId: string) => { + await runCommand( + () => getRecordAction(entityName, recordId), + { requireAuth: true }, + context, + ); + }); +} diff --git a/src/cli/commands/entities/records/index.ts b/src/cli/commands/entities/records/index.ts new file mode 100644 index 00000000..fd5cd302 --- /dev/null +++ b/src/cli/commands/entities/records/index.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getRecordsCreateCommand } from "./create.js"; +import { getRecordsDeleteCommand } from "./delete.js"; +import { getRecordsGetCommand } from "./get.js"; +import { getRecordsListCommand } from "./list.js"; +import { getRecordsUpdateCommand } from "./update.js"; + +export function getRecordsCommand(context: CLIContext): Command { + return new Command("records") + .description("CRUD operations on entity records") + .addCommand(getRecordsListCommand(context)) + .addCommand(getRecordsGetCommand(context)) + .addCommand(getRecordsCreateCommand(context)) + .addCommand(getRecordsUpdateCommand(context)) + .addCommand(getRecordsDeleteCommand(context)); +} diff --git a/src/cli/commands/entities/records/list.ts b/src/cli/commands/entities/records/list.ts new file mode 100644 index 00000000..ece506bc --- /dev/null +++ b/src/cli/commands/entities/records/list.ts @@ -0,0 +1,67 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { listRecords } from "@/core/resources/entity/index.js"; + +interface ListRecordsCommandOptions { + filter?: string; + sort?: string; + limit?: string; + skip?: string; + fields?: string; +} + +async function listRecordsAction( + entityName: string, + options: ListRecordsCommandOptions, +): Promise { + const records = await runTask( + `Fetching ${entityName} records...`, + async () => { + return await listRecords(entityName, { + filter: options.filter, + sort: options.sort, + limit: options.limit ? Number(options.limit) : 50, + skip: options.skip ? Number(options.skip) : undefined, + fields: options.fields, + }); + }, + { + successMessage: `Fetched ${entityName} records`, + errorMessage: `Failed to fetch ${entityName} records`, + }, + ); + + log.info(JSON.stringify(records, null, 2)); + + return { outroMessage: `Found ${records.length} ${entityName} record(s)` }; +} + +export function getRecordsListCommand(context: CLIContext): Command { + return new Command("list") + .description("List entity records") + .argument("", "Name of the entity (e.g. Users, Products)") + .option( + "-f, --filter ", + 'JSON filter object (e.g. \'{"status":"active"}\' or \'{"age":{"$gt":18}}\')', + ) + .option( + "-s, --sort ", + "Sort field name, prefix with - for descending (e.g. -created_date)", + ) + .option("-l, --limit ", "Max number of records to return", "50") + .option("--skip ", "Number of records to skip (for pagination)") + .option( + "--fields ", + "Comma-separated list of fields to return (e.g. id,name,email)", + ) + .action(async (entityName: string, options: ListRecordsCommandOptions) => { + await runCommand( + () => listRecordsAction(entityName, options), + { requireAuth: true }, + context, + ); + }); +} diff --git a/src/cli/commands/entities/records/parseRecordData.ts b/src/cli/commands/entities/records/parseRecordData.ts new file mode 100644 index 00000000..daebb111 --- /dev/null +++ b/src/cli/commands/entities/records/parseRecordData.ts @@ -0,0 +1,56 @@ +import JSON5 from "json5"; +import { InvalidInputError } from "@/core/errors.js"; +import { readJsonFile } from "@/core/utils/fs.js"; + +interface RecordDataOptions { + data?: string; + file?: string; +} + +export async function parseRecordData( + options: RecordDataOptions, + exampleHint?: string, +): Promise> { + if (options.data && options.file) { + throw new InvalidInputError( + "Cannot use both --data and --file. Choose one.", + { + hints: [ + { + message: `Pass --data for inline JSON or --file for a JSON file path, not both.`, + }, + ], + }, + ); + } + + if (options.data) { + try { + return JSON5.parse(options.data); + } catch { + throw new InvalidInputError( + "Invalid JSON in --data flag. Provide valid JSON.", + exampleHint + ? { hints: [{ message: `Example: --data '${exampleHint}'` }] } + : undefined, + ); + } + } + + if (options.file) { + return readJsonFile(options.file) as Promise>; + } + + throw new InvalidInputError( + "Provide record data with --data or --file flag", + exampleHint + ? { + hints: [ + { + message: `Example: --data '${exampleHint}' or --file record.json`, + }, + ], + } + : undefined, + ); +} diff --git a/src/cli/commands/entities/records/update.ts b/src/cli/commands/entities/records/update.ts new file mode 100644 index 00000000..a7326639 --- /dev/null +++ b/src/cli/commands/entities/records/update.ts @@ -0,0 +1,57 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { updateRecord } from "@/core/resources/entity/index.js"; +import { parseRecordData } from "./parseRecordData.js"; + +interface UpdateRecordCommandOptions { + data?: string; + file?: string; +} + +async function updateRecordAction( + entityName: string, + recordId: string, + options: UpdateRecordCommandOptions, +): Promise { + const data = await parseRecordData(options, '{"status": "active"}'); + + const record = await runTask( + `Updating ${entityName} record...`, + async () => { + return await updateRecord(entityName, recordId, data); + }, + { + successMessage: `Updated ${entityName} record`, + errorMessage: `Failed to update ${entityName} record`, + }, + ); + + log.info(JSON.stringify(record, null, 2)); + + return { outroMessage: `Record ${recordId} updated` }; +} + +export function getRecordsUpdateCommand(context: CLIContext): Command { + return new Command("update") + .description("Update an entity record") + .argument("", "Name of the entity (e.g. Users, Products)") + .argument("", "ID of the record to update") + .option("-d, --data ", "JSON object with fields to update") + .option("--file ", "Read update data from a JSON/JSONC file") + .action( + async ( + entityName: string, + recordId: string, + options: UpdateRecordCommandOptions, + ) => { + await runCommand( + () => updateRecordAction(entityName, recordId, options), + { requireAuth: true }, + context, + ); + }, + ); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index f403f6ab..ace5a9f2 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,7 +5,7 @@ import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; -import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; +import { getEntitiesCommand } from "@/cli/commands/entities/index.js"; import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; @@ -45,7 +45,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getEjectCommand(context)); // Register entities commands - program.addCommand(getEntitiesPushCommand(context)); + program.addCommand(getEntitiesCommand(context)); // Register agents commands program.addCommand(getAgentsCommand(context)); diff --git a/src/core/clients/base44-client.ts b/src/core/clients/base44-client.ts index 8effb6e0..4966870c 100644 --- a/src/core/clients/base44-client.ts +++ b/src/core/clients/base44-client.ts @@ -127,3 +127,28 @@ export function getAppClient() { prefixUrl: new URL(`/api/apps/${id}/`, getBase44ApiUrl()).href, }); } + +/** + * Returns an HTTP client that authenticates as an app user (not a platform user). + * Exchanges the platform token for an app-user token via GET /auth/token, + * then creates a plain ky client with that token and admin bypass headers. + * + * Use this for API calls to RuntimeRouter endpoints that require app-user auth + * with admin privileges (e.g. entity record CRUD from the CLI). + */ +export async function getAppUserClient() { + const appClient = getAppClient(); + const response = await appClient.get("auth/token"); + const { token } = await response.json<{ token: string }>(); + + const { id } = getAppConfig(); + return ky.create({ + prefixUrl: new URL(`/api/apps/${id}/`, getBase44ApiUrl()).href, + headers: { + "User-Agent": "Base44 CLI", + Authorization: `Bearer ${token}`, + "X-Bypass-RLS": "true", + "X-Bypass-Entities-Filter-Limit": "true", + }, + }); +} diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts index 98bf8e86..b86ba373 100644 --- a/src/core/clients/index.ts +++ b/src/core/clients/index.ts @@ -1,2 +1,6 @@ -export { base44Client, getAppClient } from "./base44-client.js"; +export { + base44Client, + getAppClient, + getAppUserClient, +} from "./base44-client.js"; export { oauthClient } from "./oauth-client.js"; diff --git a/src/core/resources/entity/index.ts b/src/core/resources/entity/index.ts index 90b197a7..9bf09035 100644 --- a/src/core/resources/entity/index.ts +++ b/src/core/resources/entity/index.ts @@ -1,5 +1,7 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; +export * from "./records-api.js"; +export * from "./records-schema.js"; export * from "./resource.js"; export * from "./schema.js"; diff --git a/src/core/resources/entity/records-api.ts b/src/core/resources/entity/records-api.ts new file mode 100644 index 00000000..83fe7e8b --- /dev/null +++ b/src/core/resources/entity/records-api.ts @@ -0,0 +1,172 @@ +/** + * API functions for entity record CRUD operations. + * Communicates with GET/POST/PUT/DELETE /api/apps/{app_id}/entities/{entity_name} + * + * Uses a token exchange (platform token → app-user token) to authenticate with + * the RuntimeRouter, with X-Bypass-RLS to get admin-level access. + */ + +import type { KyResponse } from "ky"; +import { getAppUserClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { + type DeleteRecordResponse, + DeleteRecordResponseSchema, + type EntityRecord, + EntityRecordSchema, +} from "./records-schema.js"; + +interface ListRecordsOptions { + filter?: string; + sort?: string; + limit?: number; + skip?: number; + fields?: string; +} + +export async function listRecords( + entityName: string, + options: ListRecordsOptions = {}, +): Promise { + const client = await getAppUserClient(); + + const searchParams = new URLSearchParams(); + if (options.filter) { + searchParams.set("q", options.filter); + } + if (options.sort) { + searchParams.set("sort", options.sort); + } + if (options.limit !== undefined) { + searchParams.set("limit", String(options.limit)); + } + if (options.skip !== undefined) { + searchParams.set("skip", String(options.skip)); + } + if (options.fields) { + searchParams.set("fields", options.fields); + } + + let response: KyResponse; + try { + response = await client.get(`entities/${entityName}`, { + searchParams, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing records"); + } + + const json = await response.json(); + + // Response is an array of records + const records = Array.isArray(json) ? json : []; + return records.map((record: unknown) => { + const result = EntityRecordSchema.safeParse(record); + if (!result.success) { + throw new SchemaValidationError( + "Invalid record in response", + result.error, + ); + } + return result.data; + }); +} + +export async function getRecord( + entityName: string, + recordId: string, +): Promise { + const client = await getAppUserClient(); + + let response: KyResponse; + try { + response = await client.get(`entities/${entityName}/${recordId}`); + } catch (error) { + throw await ApiError.fromHttpError(error, "getting record"); + } + + const json = await response.json(); + const result = EntityRecordSchema.safeParse(json); + if (!result.success) { + throw new SchemaValidationError("Invalid record response", result.error); + } + + return result.data; +} + +export async function createRecord( + entityName: string, + data: Record, +): Promise { + const client = await getAppUserClient(); + + let response: KyResponse; + try { + response = await client.post(`entities/${entityName}`, { + json: data, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating record"); + } + + const json = await response.json(); + const result = EntityRecordSchema.safeParse(json); + if (!result.success) { + throw new SchemaValidationError( + "Invalid record in create response", + result.error, + ); + } + + return result.data; +} + +export async function updateRecord( + entityName: string, + recordId: string, + data: Record, +): Promise { + const client = await getAppUserClient(); + + let response: KyResponse; + try { + response = await client.put(`entities/${entityName}/${recordId}`, { + json: data, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "updating record"); + } + + const json = await response.json(); + const result = EntityRecordSchema.safeParse(json); + if (!result.success) { + throw new SchemaValidationError( + "Invalid record in update response", + result.error, + ); + } + + return result.data; +} + +export async function deleteRecord( + entityName: string, + recordId: string, +): Promise { + const client = await getAppUserClient(); + + let response: KyResponse; + try { + response = await client.delete(`entities/${entityName}/${recordId}`); + } catch (error) { + throw await ApiError.fromHttpError(error, "deleting record"); + } + + const json = await response.json(); + const result = DeleteRecordResponseSchema.safeParse(json); + if (!result.success) { + throw new SchemaValidationError("Invalid delete response", result.error); + } + + return result.data; +} diff --git a/src/core/resources/entity/records-schema.ts b/src/core/resources/entity/records-schema.ts new file mode 100644 index 00000000..712e961f --- /dev/null +++ b/src/core/resources/entity/records-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Schema for a single entity record returned by the API. + * Records are dynamic — data fields depend on the entity schema and are merged at the top level. + * System fields (id, created_date, updated_date, created_by) are always present. + */ +export const EntityRecordSchema = z.looseObject({ + id: z.string(), + created_date: z.string(), + updated_date: z.string(), + created_by: z.string().optional(), +}); + +export type EntityRecord = z.infer; + +/** + * Schema for the delete response. + */ +export const DeleteRecordResponseSchema = z.object({ + success: z.boolean(), +}); + +export type DeleteRecordResponse = z.infer; diff --git a/tests/cli/entities_records.spec.ts b/tests/cli/entities_records.spec.ts new file mode 100644 index 00000000..f911ac8c --- /dev/null +++ b/tests/cli/entities_records.spec.ts @@ -0,0 +1,294 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const SAMPLE_RECORD = { + id: "rec-123", + name: "John Doe", + email: "john@example.com", + created_date: "2025-01-01T00:00:00Z", + updated_date: "2025-01-02T00:00:00Z", + created_by: "admin@example.com", +}; + +const SAMPLE_RECORD_2 = { + id: "rec-456", + name: "Jane Smith", + email: "jane@example.com", + created_date: "2025-01-03T00:00:00Z", + updated_date: "2025-01-04T00:00:00Z", + created_by: "admin@example.com", +}; + +describe("entities records list command", () => { + const t = setupCLITests(); + + it("lists records successfully", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordsList("Customer", [SAMPLE_RECORD, SAMPLE_RECORD_2]); + + const result = await t.run("entities", "records", "list", "Customer"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 2 Customer record(s)"); + t.expectResult(result).toContainInStdout("rec-123"); + t.expectResult(result).toContainInStdout("rec-456"); + }); + + it("lists records with empty result", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordsList("Customer", []); + + const result = await t.run("entities", "records", "list", "Customer"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 0 Customer record(s)"); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordsListError("Customer", { + status: 404, + body: { message: "Entity schema Customer not found in app" }, + }); + + const result = await t.run("entities", "records", "list", "Customer"); + + t.expectResult(result).toFail(); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("entities", "records", "list", "Customer"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); +}); + +describe("entities records get command", () => { + const t = setupCLITests(); + + it("gets a single record by ID", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordGet("Customer", "rec-123", SAMPLE_RECORD); + + const result = await t.run( + "entities", + "records", + "get", + "Customer", + "rec-123", + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContainInStdout("rec-123"); + t.expectResult(result).toContainInStdout("John Doe"); + }); + + it("fails when record not found", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordGetError("Customer", "nonexistent", { + status: 404, + body: { message: "Entity Customer with ID nonexistent not found" }, + }); + + const result = await t.run( + "entities", + "records", + "get", + "Customer", + "nonexistent", + ); + + t.expectResult(result).toFail(); + }); +}); + +describe("entities records create command", () => { + const t = setupCLITests(); + + it("creates a record with --data flag", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordCreate("Customer", SAMPLE_RECORD); + + const result = await t.run( + "entities", + "records", + "create", + "Customer", + "--data", + '{"name": "John Doe", "email": "john@example.com"}', + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Record created with ID: rec-123"); + t.expectResult(result).toContainInStdout("rec-123"); + }); + + it("fails with invalid JSON in --data", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + + const result = await t.run( + "entities", + "records", + "create", + "Customer", + "--data", + "{invalid json}", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid JSON"); + }); + + it("fails when neither --data nor --file provided", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + + const result = await t.run("entities", "records", "create", "Customer"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Provide record data"); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordCreateError("Customer", { + status: 400, + body: { message: "Validation failed" }, + }); + + const result = await t.run( + "entities", + "records", + "create", + "Customer", + "--data", + '{"name": "John"}', + ); + + t.expectResult(result).toFail(); + }); +}); + +describe("entities records update command", () => { + const t = setupCLITests(); + + it("updates a record with --data flag", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordUpdate("Customer", "rec-123", { + ...SAMPLE_RECORD, + name: "John Updated", + }); + + const result = await t.run( + "entities", + "records", + "update", + "Customer", + "rec-123", + "--data", + '{"name": "John Updated"}', + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Record rec-123 updated"); + t.expectResult(result).toContainInStdout("John Updated"); + }); + + it("fails with invalid JSON in --data", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + + const result = await t.run( + "entities", + "records", + "update", + "Customer", + "rec-123", + "--data", + "not json", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid JSON"); + }); + + it("fails when record not found", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordUpdateError("Customer", "nonexistent", { + status: 404, + body: { message: "Entity Customer with ID nonexistent not found" }, + }); + + const result = await t.run( + "entities", + "records", + "update", + "Customer", + "nonexistent", + "--data", + '{"name": "test"}', + ); + + t.expectResult(result).toFail(); + }); +}); + +describe("entities records delete command", () => { + const t = setupCLITests(); + + it("deletes a record with --yes flag", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordDelete("Customer", "rec-123"); + + const result = await t.run( + "entities", + "records", + "delete", + "Customer", + "rec-123", + "--yes", + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Record rec-123 deleted"); + }); + + it("fails when record not found", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordDeleteError("Customer", "nonexistent", { + status: 404, + body: { message: "Entity Customer with ID nonexistent not found" }, + }); + + const result = await t.run( + "entities", + "records", + "delete", + "Customer", + "nonexistent", + "--yes", + ); + + t.expectResult(result).toFail(); + }); + + it("fails when API returns permission error", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockRecordDeleteError("Customer", "rec-123", { + status: 403, + body: { message: "Permission denied" }, + }); + + const result = await t.run( + "entities", + "records", + "delete", + "Customer", + "rec-123", + "--yes", + ); + + t.expectResult(result).toFail(); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index 3b2a1273..91e2da5f 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -271,6 +271,160 @@ export class Base44APIMock { return this; } + // ─── APP-USER TOKEN EXCHANGE ──────────────────────────────── + + private appUserTokenMocked = false; + + /** Mock GET /api/apps/{appId}/auth/token - Token exchange (platform → app-user) */ + mockAppUserToken(): this { + if (this.appUserTokenMocked) return this; + this.appUserTokenMocked = true; + this.handlers.push( + http.get(`${BASE_URL}/api/apps/${this.appId}/auth/token`, () => + HttpResponse.json({ token: "mock-app-user-token" }), + ), + ); + return this; + } + + // ─── ENTITY RECORD ENDPOINTS ────────────────────────────── + + /** Mock GET /api/apps/{appId}/entities/{entityName} - List records */ + mockRecordsList( + entityName: string, + response: Record[], + ): this { + this.mockAppUserToken(); + this.handlers.push( + http.get( + `${BASE_URL}/api/apps/${this.appId}/entities/${entityName}`, + () => HttpResponse.json(response), + ), + ); + return this; + } + + /** Mock GET /api/apps/{appId}/entities/{entityName}/{recordId} - Get record */ + mockRecordGet( + entityName: string, + recordId: string, + response: Record, + ): this { + this.mockAppUserToken(); + this.handlers.push( + http.get( + `${BASE_URL}/api/apps/${this.appId}/entities/${entityName}/${recordId}`, + () => HttpResponse.json(response), + ), + ); + return this; + } + + /** Mock POST /api/apps/{appId}/entities/{entityName} - Create record */ + mockRecordCreate( + entityName: string, + response: Record, + ): this { + this.mockAppUserToken(); + this.handlers.push( + http.post( + `${BASE_URL}/api/apps/${this.appId}/entities/${entityName}`, + () => HttpResponse.json(response), + ), + ); + return this; + } + + /** Mock PUT /api/apps/{appId}/entities/{entityName}/{recordId} - Update record */ + mockRecordUpdate( + entityName: string, + recordId: string, + response: Record, + ): this { + this.mockAppUserToken(); + this.handlers.push( + http.put( + `${BASE_URL}/api/apps/${this.appId}/entities/${entityName}/${recordId}`, + () => HttpResponse.json(response), + ), + ); + return this; + } + + /** Mock DELETE /api/apps/{appId}/entities/{entityName}/{recordId} - Delete record */ + mockRecordDelete(entityName: string, recordId: string): this { + this.mockAppUserToken(); + this.handlers.push( + http.delete( + `${BASE_URL}/api/apps/${this.appId}/entities/${entityName}/${recordId}`, + () => HttpResponse.json({ success: true }), + ), + ); + return this; + } + + /** Mock entity records list to return an error */ + mockRecordsListError(entityName: string, error: ErrorResponse): this { + this.mockAppUserToken(); + return this.mockError( + "get", + `/api/apps/${this.appId}/entities/${entityName}`, + error, + ); + } + + /** Mock entity record get to return an error */ + mockRecordGetError( + entityName: string, + recordId: string, + error: ErrorResponse, + ): this { + this.mockAppUserToken(); + return this.mockError( + "get", + `/api/apps/${this.appId}/entities/${entityName}/${recordId}`, + error, + ); + } + + /** Mock entity record create to return an error */ + mockRecordCreateError(entityName: string, error: ErrorResponse): this { + this.mockAppUserToken(); + return this.mockError( + "post", + `/api/apps/${this.appId}/entities/${entityName}`, + error, + ); + } + + /** Mock entity record update to return an error */ + mockRecordUpdateError( + entityName: string, + recordId: string, + error: ErrorResponse, + ): this { + this.mockAppUserToken(); + return this.mockError( + "put", + `/api/apps/${this.appId}/entities/${entityName}/${recordId}`, + error, + ); + } + + /** Mock entity record delete to return an error */ + mockRecordDeleteError( + entityName: string, + recordId: string, + error: ErrorResponse, + ): this { + this.mockAppUserToken(); + return this.mockError( + "delete", + `/api/apps/${this.appId}/entities/${entityName}/${recordId}`, + error, + ); + } + // ─── GENERAL ENDPOINTS ───────────────────────────────────── /** Mock POST /api/apps - Create new app */ diff --git a/tests/cli/testkit/CLIResultMatcher.ts b/tests/cli/testkit/CLIResultMatcher.ts index b3580831..6285d8c6 100644 --- a/tests/cli/testkit/CLIResultMatcher.ts +++ b/tests/cli/testkit/CLIResultMatcher.ts @@ -45,4 +45,13 @@ export class CLIResultMatcher { ); } } + + toContainInStdout(text: string): void { + if (!this.result.stdout.includes(text)) { + throw new Error( + `Expected stdout to contain "${text}"\n` + + `stdout: ${stripAnsi(this.result.stdout)}`, + ); + } + } }