diff --git a/packages/cli/src/cli/commands/automations/index.ts b/packages/cli/src/cli/commands/automations/index.ts new file mode 100644 index 00000000..f02f31c9 --- /dev/null +++ b/packages/cli/src/cli/commands/automations/index.ts @@ -0,0 +1,8 @@ +import { Command } from "commander"; +import { getAutomationsStatusCommand } from "./status.js"; + +export function getAutomationsCommand(): Command { + return new Command("automations") + .description("Manage automations") + .addCommand(getAutomationsStatusCommand()); +} diff --git a/packages/cli/src/cli/commands/automations/status.ts b/packages/cli/src/cli/commands/automations/status.ts new file mode 100644 index 00000000..de07e210 --- /dev/null +++ b/packages/cli/src/cli/commands/automations/status.ts @@ -0,0 +1,61 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask, theme } from "@/cli/utils/index.js"; +import { listDeployedFunctions } from "@/core/resources/function/api.js"; + +function formatAutomationType(automation: { + type: string; + schedule_mode?: string; + schedule_type?: string; +}): string { + if (automation.type === "entity") return "entity"; + if (automation.type === "scheduled") { + if (automation.schedule_mode === "one-time") return "scheduled (one-time)"; + if (automation.schedule_type === "cron") return "scheduled (cron)"; + return "scheduled (simple)"; + } + return automation.type; +} + +async function statusAction({ log }: CLIContext): Promise { + const { functions } = await runTask( + "Fetching automations...", + async () => listDeployedFunctions(), + { errorMessage: "Failed to fetch automations" }, + ); + + let totalAutomations = 0; + let functionsWithAutomations = 0; + + for (const fn of functions) { + if (fn.automations.length === 0) continue; + functionsWithAutomations++; + + for (const automation of fn.automations) { + totalAutomations++; + const active = automation.is_active; + const statusLabel = active + ? theme.styles.bold("active") + : theme.styles.dim("disabled"); + const typeLabel = formatAutomationType(automation); + + log.message( + ` ${fn.name} / ${automation.name} ${theme.styles.dim(typeLabel)} ${statusLabel}`, + ); + } + } + + if (totalAutomations === 0) { + return { outroMessage: "No automations found" }; + } + + return { + outroMessage: `${totalAutomations} automation${totalAutomations !== 1 ? "s" : ""} across ${functionsWithAutomations} function${functionsWithAutomations !== 1 ? "s" : ""}`, + }; +} + +export function getAutomationsStatusCommand(): Command { + return new Base44Command("status") + .description("Show status of all automations") + .action(statusAction); +} diff --git a/packages/cli/src/cli/commands/entities/count.ts b/packages/cli/src/cli/commands/entities/count.ts new file mode 100644 index 00000000..1bd34b33 --- /dev/null +++ b/packages/cli/src/cli/commands/entities/count.ts @@ -0,0 +1,26 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; +import { countEntityRecords } from "@/core/resources/entity/data-api.js"; + +async function countAction( + _ctx: CLIContext, + entityName: string, +): Promise { + const count = await runTask( + `Counting ${entityName} records...`, + async () => countEntityRecords(entityName), + { errorMessage: `Failed to count ${entityName} records` }, + ); + + return { + outroMessage: `${entityName} has ${count} record${count !== 1 ? "s" : ""}`, + }; +} + +export function getEntitiesCountCommand(): Command { + return new Base44Command("count") + .description("Count total records for an entity") + .argument("", "Name of the entity") + .action(countAction); +} diff --git a/packages/cli/src/cli/commands/entities/get.ts b/packages/cli/src/cli/commands/entities/get.ts new file mode 100644 index 00000000..f91bc41c --- /dev/null +++ b/packages/cli/src/cli/commands/entities/get.ts @@ -0,0 +1,31 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; +import { getEntityRecord } from "@/core/resources/entity/data-api.js"; + +async function getAction( + _ctx: CLIContext, + entityName: string, + id: string, +): Promise { + const record = await runTask( + `Fetching ${entityName} record...`, + async () => getEntityRecord(entityName, id), + { errorMessage: `Failed to fetch ${entityName} record "${id}"` }, + ); + + const formatted = JSON.stringify(record, null, 2); + + return { + outroMessage: `Fetched ${entityName} record ${id}`, + stdout: `${formatted}\n`, + }; +} + +export function getEntitiesGetCommand(): Command { + return new Base44Command("get") + .description("Get a single entity record by ID") + .argument("", "Name of the entity") + .argument("", "Record ID") + .action(getAction); +} diff --git a/packages/cli/src/cli/commands/entities/index.ts b/packages/cli/src/cli/commands/entities/index.ts new file mode 100644 index 00000000..bb1f2ee6 --- /dev/null +++ b/packages/cli/src/cli/commands/entities/index.ts @@ -0,0 +1,14 @@ +import { Command } from "commander"; +import { getEntitiesCountCommand } from "./count.js"; +import { getEntitiesGetCommand } from "./get.js"; +import { getEntitiesListCommand } from "./list.js"; +import { getEntitiesPushCommand } from "./push.js"; + +export function getEntitiesCommand(): Command { + return new Command("entities") + .description("Manage project entities") + .addCommand(getEntitiesPushCommand()) + .addCommand(getEntitiesListCommand()) + .addCommand(getEntitiesGetCommand()) + .addCommand(getEntitiesCountCommand()); +} diff --git a/packages/cli/src/cli/commands/entities/list.ts b/packages/cli/src/cli/commands/entities/list.ts new file mode 100644 index 00000000..20d8bfab --- /dev/null +++ b/packages/cli/src/cli/commands/entities/list.ts @@ -0,0 +1,133 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask, theme } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { + type EntityRecord, + type ListEntityRecordsOptions, + listEntityRecords, +} from "@/core/resources/entity/data-api.js"; + +interface ListOptions { + limit?: string; + sort?: string; + skip?: string; +} + +function formatRecordRow(record: EntityRecord, fields: string[]): string { + const values = fields.map((field) => { + const value = record[field]; + if (value === undefined || value === null) return "-"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); + }); + return values.join("\t"); +} + +function pickDisplayFields(records: EntityRecord[]): string[] { + const systemFields = ["id", "created_date"]; + if (records.length === 0) return systemFields; + + const skipFields = new Set([ + "id", + "created_date", + "updated_date", + "created_by", + "created_by_id", + "app_id", + "entity_name", + "is_deleted", + "deleted_date", + "environment", + "_id", + ]); + + const dataFields: string[] = []; + for (const key of Object.keys(records[0])) { + if (!skipFields.has(key) && dataFields.length < 4) { + dataFields.push(key); + } + } + + return [...systemFields, ...dataFields]; +} + +function validateLimit(limit: string | undefined): void { + if (limit === undefined) return; + const n = Number.parseInt(limit, 10); + if (Number.isNaN(n) || n < 1) { + throw new InvalidInputError( + `Invalid limit: "${limit}". Must be a positive integer.`, + ); + } +} + +function validateSkip(skip: string | undefined): void { + if (skip === undefined) return; + const n = Number.parseInt(skip, 10); + if (Number.isNaN(n) || n < 0) { + throw new InvalidInputError( + `Invalid skip: "${skip}". Must be a non-negative integer.`, + ); + } +} + +async function listAction( + { log }: CLIContext, + entityName: string, + options: ListOptions, +): Promise { + validateLimit(options.limit); + validateSkip(options.skip); + + const apiOptions: ListEntityRecordsOptions = {}; + + if (options.limit) { + apiOptions.limit = Number.parseInt(options.limit, 10); + } else { + apiOptions.limit = 10; + } + + if (options.skip) { + apiOptions.skip = Number.parseInt(options.skip, 10); + } + + if (options.sort) { + apiOptions.sort = options.sort; + } + + const records = await runTask( + `Fetching ${entityName} records...`, + async () => listEntityRecords(entityName, apiOptions), + { errorMessage: `Failed to fetch ${entityName} records` }, + ); + + if (records.length === 0) { + return { outroMessage: `No records found for ${entityName}` }; + } + + const fields = pickDisplayFields(records); + const header = fields.join("\t"); + + log.message(theme.styles.dim(header)); + for (const record of records) { + log.message(` ${formatRecordRow(record, fields)}`); + } + + return { + outroMessage: `Showing ${records.length} ${entityName} record${records.length !== 1 ? "s" : ""}`, + }; +} + +export function getEntitiesListCommand(): Command { + return new Base44Command("list") + .description("List entity records") + .argument("", "Name of the entity") + .option( + "-n, --limit ", + "Maximum number of records to return (default: 10)", + ) + .option("--sort ", "Field to sort by") + .option("--skip ", "Number of records to skip") + .action(listAction); +} diff --git a/packages/cli/src/cli/commands/entities/push.ts b/packages/cli/src/cli/commands/entities/push.ts index 5e0851b5..d55345fd 100644 --- a/packages/cli/src/cli/commands/entities/push.ts +++ b/packages/cli/src/cli/commands/entities/push.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; @@ -42,11 +42,7 @@ async function pushEntitiesAction({ } export function getEntitiesPushCommand(): Command { - return new Command("entities") - .description("Manage project entities") - .addCommand( - new Base44Command("push") - .description("Push local entities to Base44") - .action(pushEntitiesAction), - ); + return new Base44Command("push") + .description("Push local entities to Base44") + .action(pushEntitiesAction); } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index c5f8c3ff..07338f38 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -4,9 +4,10 @@ import { getAuthCommand } from "@/cli/commands/auth/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; +import { getAutomationsCommand } from "@/cli/commands/automations/index.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 { getFunctionsCommand } from "@/cli/commands/functions/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; @@ -56,7 +57,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getEjectCommand()); // Register entities commands - program.addCommand(getEntitiesPushCommand()); + program.addCommand(getEntitiesCommand()); // Register agents commands program.addCommand(getAgentsCommand()); @@ -79,6 +80,9 @@ export function createProgram(context: CLIContext): Command { // Register types command program.addCommand(getTypesCommand()); + // Register automations commands + program.addCommand(getAutomationsCommand()); + // Register exec command program.addCommand(getExecCommand()); diff --git a/packages/cli/src/core/resources/entity/data-api.ts b/packages/cli/src/core/resources/entity/data-api.ts new file mode 100644 index 00000000..646d1434 --- /dev/null +++ b/packages/cli/src/core/resources/entity/data-api.ts @@ -0,0 +1,120 @@ +import type { KyResponse } from "ky"; +import { z } from "zod"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; + +const EntityRecordSchema = z + .object({ + id: z.string(), + created_date: z.string(), + }) + .passthrough(); + +const EntityRecordListSchema = z.array(EntityRecordSchema); + +const CountResponseSchema = z.object({ + count: z.number(), +}); + +export type EntityRecord = z.infer; + +export interface ListEntityRecordsOptions { + limit?: number; + skip?: number; + sort?: string; +} + +export async function listEntityRecords( + entityName: string, + options: ListEntityRecordsOptions = {}, +): Promise { + const appClient = getAppClient(); + const searchParams = new URLSearchParams(); + + if (options.limit !== undefined) { + searchParams.set("limit", String(options.limit)); + } + if (options.skip !== undefined) { + searchParams.set("skip", String(options.skip)); + } + if (options.sort) { + searchParams.set("sort", options.sort); + } + + let response: KyResponse; + try { + response = await appClient.get( + `entities/${encodeURIComponent(entityName)}`, + { searchParams, timeout: 30_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError( + error, + `listing records for entity "${entityName}"`, + ); + } + + const result = EntityRecordListSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function getEntityRecord( + entityName: string, + id: string, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get( + `entities/${encodeURIComponent(entityName)}/${encodeURIComponent(id)}`, + { timeout: 30_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError( + error, + `fetching record "${id}" from entity "${entityName}"`, + ); + } + + const result = EntityRecordSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function countEntityRecords(entityName: string): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get( + `entities/${encodeURIComponent(entityName)}/count`, + { timeout: 30_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError( + error, + `counting records for entity "${entityName}"`, + ); + } + + const result = CountResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data.count; +} diff --git a/packages/cli/src/core/resources/entity/index.ts b/packages/cli/src/core/resources/entity/index.ts index 90b197a7..a873756e 100644 --- a/packages/cli/src/core/resources/entity/index.ts +++ b/packages/cli/src/core/resources/entity/index.ts @@ -1,5 +1,6 @@ export * from "./api.js"; export * from "./config.js"; +export * from "./data-api.js"; export * from "./deploy.js"; export * from "./resource.js"; export * from "./schema.js";