diff --git a/CHANGELOG.md b/CHANGELOG.md index 836bd87c9..9dcd97d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Actors (realtime handlers): deploy from `base44/actors/` via `base44 actors deploy`, remove with `base44 actors delete`, included in unified `base44 deploy`; `base44 types generate` emits `ActorNameRegistry`. Actor names are validated locally against the server's rule (a JavaScript identifier — no nesting, no dots), and a name shared with a backend function is rejected up front. - App visibility: `base44 visibility ` sets it on the server directly (accepts `--app-id` to target any app). Also configurable via `"visibility"` in `config.jsonc`, which `base44 deploy` applies. New projects scaffold `"visibility": "public"`. - `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id. - `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt. diff --git a/docs/error-handling.md b/docs/error-handling.md index 6792a6c5f..beff7a8a3 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -19,6 +19,7 @@ CLIError (abstract base class) │ └── SystemError (something broke - needs investigation) ├── ApiError # HTTP/network failures + ├── ResourceDeployError # One or more sequential deploy items failed ├── FileNotFoundError # File doesn't exist ├── FileReadError # Can't read file └── InternalError # Unexpected errors @@ -110,6 +111,7 @@ See [api-patterns.md](api-patterns.md) for the full `ApiError.fromHttpError()` p | `SCHEMA_INVALID` | `SchemaValidationError` | Zod validation failed | | `INVALID_INPUT` | `InvalidInputError` | User provided invalid input | | `API_ERROR` | `ApiError` | API request failed | +| `RESOURCE_DEPLOY_FAILED` | `ResourceDeployError` | One or more functions or actors failed to deploy | | `FILE_NOT_FOUND` | `FileNotFoundError` | File doesn't exist | | `FILE_READ_ERROR` | `FileReadError` | Can't read/write file | | `INTERNAL_ERROR` | `InternalError` | Unexpected error | diff --git a/docs/resources.md b/docs/resources.md index 8fc9be4f7..adf51dc01 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -1,8 +1,8 @@ # Working with Resources -**Keywords:** resource, entity, function, agent, agent skill, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData +**Keywords:** resource, entity, function, actor, agent, agent skill, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData -Resources are project-specific collections (entities, functions, agents, agent skills, connectors) that can be read from the filesystem and pushed to the Base44 API. +Resources are project-specific collections (entities, functions, actors, agents, agent skills, connectors) that can be read from the filesystem and pushed to the Base44 API. ## Resource Interface @@ -85,6 +85,27 @@ Deploy ships file contents verbatim — the source is never parsed or linted — Entry files may also import `secrets` and `waitUntil` from `base44:runtime`. Locally, `base44 dev` runs functions on workerd via Miniflare by default — each function is bundled with esbuild + `@deno/loader` (`src/cli/dev/dev-server/function-bundler.ts`), with `base44:runtime` served as a virtual module, secrets as real Worker env bindings and `waitUntil` riding `ctx.waitUntil`. A fallback runtime covers installations where workerd is unavailable (compiled binaries, `B44_DEV_FUNCTIONS_RUNTIME=deno`) and supplies `base44:runtime` via an import map. A project-level `deno.json` import map is not applied to functions — locally or deployed — since only files under `base44/` are uploaded. See [`packages/cli/backend-runtime/README.md`](../packages/cli/backend-runtime/README.md) for the local implementation and its intentional differences from production. +## Actors (project layout) + +Actors are stateful realtime handlers, read from the project's actors directory (`base44/actors/`, or `actorsDir` in `config.jsonc`). Discovery is zero-config only: a folder containing `entry.ts` (or `entry.js`) is an actor, and the folder name is the actor name (`actors/ChatRoom/entry.ts` → `ChatRoom`). All `**/*.{js,ts,json,jsonc}` files under that folder are included in the deploy payload, sent via `PUT /api/apps/{app_id}/actors/{name}`. The entry file must default-export the actor class — the deploy bundler imports the default export. + +**Naming is enforced locally, mirroring the server.** An actor name becomes a Durable Object class and the WebSocket connect handler, so it must be a plain ASCII JavaScript identifier: `[A-Za-z_][A-Za-z0-9_]*`, max 128 characters, not a JS reserved word, PascalCase by convention. Two consequences differ from functions: + +- **Actors cannot be nested.** `actors/games/Arena/entry.ts` is an error, not an actor named `games/Arena`. Since every `entry.{js,ts}` under the actors root is an entry file at any depth, this is also what a helper accidentally named `entry.ts` reports — the error hints at both causes. +- **Folders with a dot in the name are skipped**, using the same `ENTRY_IGNORE_DOT_PATHS` exclusion as functions. A dotted name can never be valid, so `actors/ChatRoom.bak/` is treated as scratch rather than a deploy that would 422. + +Both checks run in `readAllActors` (before any upload), and `readProjectConfig` additionally rejects a name shared with a backend function — actors deploy onto the same server-side namespace. + +Deliberate gaps (vs functions): no `base44/shared/` inclusion, no `--force` prune, no `list`/`pull`, no plugin actors, and no local `base44 dev` runtime. Authoring guidance (scaffolding, message typing, editor setup for the `base44:runtime/actors` virtual module) lives in the realtime skill, not the CLI. Type generation only emits `ActorNameRegistry` (actor names) into `types.d.ts`. + +```bash +base44 actors deploy # Deploy all actors +base44 actors deploy ChatRoom # Deploy specific actors by name +base44 actors delete ChatRoom # Tear down a deployed actor +``` + +`actors delete` calls `DELETE /api/apps/{app_id}/actors/{name}`, which destroys the published script — it is not a local operation and does not need the actor to still exist on disk. A 404 is reported as "not found" rather than an error, so re-running it is safe. + ## Agent skills Agent skills are app-scoped instruction snippets shared across the app's agents. Unlike other resources they are stored as one markdown file per skill under the agent-skills directory (`base44/agent-skills/`, or `agentSkillsDir` in `config.jsonc`): the filename (without `.md`) is the skill name, the frontmatter `description` is the summary, and the body is the instruction text. Agents reference skills by name via `selected_skill_names`; `selected_workspace_skill_ids` (org-shared workspace skills) is not managed here and is passed through pull/push/deploy untouched. @@ -135,11 +156,15 @@ const { appUrl } = await deployAll(projectData); What it deploys (in order): 1. Entities (via `entityResource.push()`) -2. Functions (via `functionResource.push()`) -3. Agent skills (via `agentSkillResource.push()`) -4. Agents (via `agentResource.push()`) -5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). +2. Functions (via `deployFunctionsSequentially()`) +3. Actors (via `deployActorsSequentially()`) +4. Agent skills (via `agentSkillResource.push()`) +5. Agents (via `agentResource.push()`) +6. Auth config (via `authConfigResource.push()`) +7. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs +8. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)). + +Functions and actors deploy sequentially within their resource batch. If any item fails, the command reports every failed item as a structured `ResourceDeployError`, exits non-zero, and does not continue to later resource types. ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index f34052dc6..e183ddd3f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -210,6 +210,21 @@ t.api.mockFunctionsPush({ deployed: ["handler"], deleted: [], errors: null }); t.api.mockFunctionsPushError({ status: 400, body: { error: "Invalid" } }); ``` +### Actor Mocks + +```typescript +t.api.mockSingleActorDeploy({ status: "deployed" }); +t.api.mockSingleActorDeployError({ status: 400, body: { error: "Invalid" } }); +t.api.mockSingleActorDelete(); +t.api.mockSingleActorDeleteError({ status: 404, body: { error: "Not found" } }); + +// Successful requests are captured for payload assertions. +expect(t.api.actorDeployRequests[0]).toMatchObject({ + name: "ChatRoom", + entry: "entry.ts", +}); +``` + ### Agent Mocks ```typescript diff --git a/packages/cli/README.md b/packages/cli/README.md index 4a48fb8c9..80ff35cb7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,6 +51,8 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`login`](https://docs.base44.com/developers/references/cli/commands/login) | Authenticate with Base44 | | [`logout`](https://docs.base44.com/developers/references/cli/commands/logout) | Sign out and clear stored credentials | | [`whoami`](https://docs.base44.com/developers/references/cli/commands/whoami) | Display the current authenticated user | +| `actors deploy` | Deploy local actors to Base44 | +| `actors delete` | Delete deployed actors from Base44 | | [`agents pull`](https://docs.base44.com/developers/references/cli/commands/agents-pull) | Pull agents from Base44 to local files | | [`agents push`](https://docs.base44.com/developers/references/cli/commands/agents-push) | Push local agents to Base44 | | [`connectors initiate`](https://docs.base44.com/developers/references/cli/commands/connectors-initiate) | Initialize a connector on an app and start its OAuth flow | diff --git a/packages/cli/src/cli/commands/actors/delete.ts b/packages/cli/src/cli/commands/actors/delete.ts new file mode 100644 index 000000000..c1154211a --- /dev/null +++ b/packages/cli/src/cli/commands/actors/delete.ts @@ -0,0 +1,61 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, parseNames } from "@/cli/utils/index.js"; +import { ApiError } from "@/core/errors.js"; +import { deleteSingleActor } from "@/core/resources/actor/api.js"; + +async function deleteActorsAction( + { runTask }: CLIContext, + names: string[], +): Promise { + let deleted = 0; + let notFound = 0; + let errors = 0; + + for (const name of names) { + try { + await runTask(`Deleting ${name}...`, () => deleteSingleActor(name), { + successMessage: `${name} deleted`, + errorMessage: `Failed to delete ${name}`, + }); + deleted++; + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + notFound++; + } else { + errors++; + } + } + } + + if (names.length === 1) { + if (deleted) return { outroMessage: `Actor "${names[0]}" deleted` }; + if (notFound) return { outroMessage: `Actor "${names[0]}" not found` }; + return { outroMessage: `Failed to delete "${names[0]}"` }; + } + + const total = names.length; + const parts: string[] = []; + if (deleted > 0) parts.push(`${deleted}/${total} deleted`); + if (notFound > 0) parts.push(`${notFound} not found`); + if (errors > 0) parts.push(`${errors} error${errors !== 1 ? "s" : ""}`); + return { outroMessage: parts.join(", ") }; +} + +function validateNames(command: Command): void { + const names = parseNames(command.args); + if (names.length === 0) { + command.error("At least one actor name is required"); + } +} + +export function getDeleteCommand(): Command { + return new Base44Command("delete") + .description("Delete deployed actors") + .argument("", "Actor names to delete") + .hook("preAction", validateNames) + .action(async (ctx: CLIContext, rawNames: string[]) => { + const names = parseNames(rawNames); + return deleteActorsAction(ctx, names); + }); +} diff --git a/packages/cli/src/cli/commands/actors/deploy.ts b/packages/cli/src/cli/commands/actors/deploy.ts new file mode 100644 index 000000000..e31d22b6b --- /dev/null +++ b/packages/cli/src/cli/commands/actors/deploy.ts @@ -0,0 +1,79 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { + Base44Command, + buildDeploySummary, + formatDeployResult, + parseNames, + theme, +} from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import { throwIfDeployFailed } from "@/core/resources/types.js"; + +function resolveActorsToDeploy(names: string[], allActors: Actor[]): Actor[] { + if (names.length === 0) return allActors; + + const notFound = names.filter((n) => !allActors.some((a) => a.name === n)); + if (notFound.length > 0) { + throw new InvalidInputError( + `Actor${notFound.length > 1 ? "s" : ""} not found in project: ${notFound.join(", ")}`, + ); + } + return allActors.filter((a) => names.includes(a.name)); +} + +async function deployActorsAction( + { log }: CLIContext, + names: string[], +): Promise { + const { actors, project } = await readProjectConfig(); + const toDeploy = resolveActorsToDeploy(names, actors); + + if (toDeploy.length === 0) { + return { + outroMessage: `No actors found. Create actors in the '${project.actorsDir}' directory.`, + }; + } + + log.info( + `Found ${toDeploy.length} ${toDeploy.length === 1 ? "actor" : "actors"} to deploy`, + ); + + let completed = 0; + const total = toDeploy.length; + + const results = await deployActorsSequentially(toDeploy, { + onStart: (startNames) => { + const label = + startNames.length === 1 ? startNames[0] : `${startNames.length} actors`; + log.step( + theme.styles.dim(`[${completed + 1}/${total}] Deploying ${label}...`), + ); + }, + onResult: (result) => { + completed++; + formatDeployResult(result, log); + }, + }); + + const hasFailures = results.some((r) => r.status === "error"); + if (hasFailures) { + log.message(buildDeploySummary(results, "actors")); + throwIfDeployFailed(results, "actor"); + } + + return { outroMessage: buildDeploySummary(results, "actors") }; +} + +export function getDeployCommand(): Command { + return new Base44Command("deploy") + .description("Deploy actors to Base44") + .argument("[names...]", "Actor names to deploy (deploys all if omitted)") + .action(async (ctx: CLIContext, rawNames: string[]) => { + const names = parseNames(rawNames); + return deployActorsAction(ctx, names); + }); +} diff --git a/packages/cli/src/cli/commands/actors/index.ts b/packages/cli/src/cli/commands/actors/index.ts new file mode 100644 index 000000000..bc6f5965f --- /dev/null +++ b/packages/cli/src/cli/commands/actors/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getDeleteCommand } from "./delete.js"; +import { getDeployCommand } from "./deploy.js"; + +export function getActorsCommand(): Command { + return new Command("actors") + .description("Manage actors") + .addCommand(getDeployCommand()) + .addCommand(getDeleteCommand()); +} diff --git a/packages/cli/src/cli/commands/functions/delete.ts b/packages/cli/src/cli/commands/functions/delete.ts index 56d04789a..235f30dad 100644 --- a/packages/cli/src/cli/commands/functions/delete.ts +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, parseNames } from "@/cli/utils/index.js"; import { ApiError } from "@/core/errors.js"; import { deleteSingleFunction } from "@/core/resources/function/api.js"; @@ -42,14 +42,6 @@ async function deleteFunctionsAction( return { outroMessage: parts.join(", ") }; } -/** Parse names from variadic CLI args, supporting comma-separated values. */ -function parseNames(args: string[]): string[] { - return args - .flatMap((arg) => arg.split(",")) - .map((n) => n.trim()) - .filter(Boolean); -} - function validateNames(command: Command): void { const names = parseNames(command.args); if (names.length === 0) { diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index e282e6633..98c94e754 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,19 +1,22 @@ import type { Logger } from "@base44-cli/logger"; import type { Command } from "commander"; -import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; -import { parseNames } from "@/cli/commands/functions/parseNames.js"; -import { CLIExitError } from "@/cli/errors.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { + Base44Command, + buildDeploySummary, + formatDeployResult, + parseNames, + theme, +} from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import { deployFunctionsSequentially, type PruneResult, pruneRemovedFunctions, - type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; +import { throwIfDeployFailed } from "@/core/resources/types.js"; function resolveFunctionsToDeploy( names: string[], @@ -45,18 +48,6 @@ function formatPruneSummary(pruneResults: PruneResult[], log: Logger): void { } } -function buildDeploySummary(results: SingleFunctionDeployResult[]): string { - const deployed = results.filter((r) => r.status === "deployed").length; - const unchanged = results.filter((r) => r.status === "unchanged").length; - const failed = results.filter((r) => r.status === "error").length; - - const parts: string[] = []; - if (deployed > 0) parts.push(`${deployed} deployed`); - if (unchanged > 0) parts.push(`${unchanged} unchanged`); - if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); - return parts.join(", ") || "No functions deployed"; -} - async function deployFunctionsAction( { log }: CLIContext, names: string[], @@ -103,8 +94,8 @@ async function deployFunctionsAction( const hasFailures = results.some((r) => r.status === "error"); if (hasFailures) { - log.message(buildDeploySummary(results)); - throw new CLIExitError(1); + log.message(buildDeploySummary(results, "functions")); + throwIfDeployFailed(results, "function"); } if (options.force) { @@ -133,7 +124,7 @@ async function deployFunctionsAction( formatPruneSummary(pruneResults, log); } - return { outroMessage: buildDeploySummary(results) }; + return { outroMessage: buildDeploySummary(results, "functions") }; } export function getDeployCommand(): Command { diff --git a/packages/cli/src/cli/commands/functions/formatDeployResult.ts b/packages/cli/src/cli/commands/functions/formatDeployResult.ts deleted file mode 100644 index 39406a203..000000000 --- a/packages/cli/src/cli/commands/functions/formatDeployResult.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Logger } from "@base44-cli/logger"; -import { theme } from "@/cli/utils/theme.js"; -import type { SingleFunctionDeployResult } from "@/core/resources/function/deploy.js"; - -function formatDuration(ms: number): string { - return `${(ms / 1000).toFixed(1)}s`; -} - -export function formatDeployResult( - result: SingleFunctionDeployResult, - log: Logger, -): void { - const label = result.name.padEnd(25); - if (result.status === "deployed") { - const timing = result.durationMs - ? theme.styles.dim(` (${formatDuration(result.durationMs)})`) - : ""; - log.success(`${label} deployed${timing}`); - } else if (result.status === "unchanged") { - log.success(`${label} unchanged`); - } else { - log.error(`${label} error: ${result.error}`); - } -} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 986c01ffc..1260a491a 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -5,11 +5,11 @@ import { filterPendingOAuth, promptOAuthFlows, } from "@/cli/commands/connectors/oauth-prompt.js"; -import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, + formatDeployResult, getConnectorsUrl, getDashboardUrl, theme, @@ -48,8 +48,15 @@ export async function deployAction( }; } - const { project, entities, functions, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + actors, + agents, + connectors, + authConfig, + } = projectData; // Build summary of what will be deployed const summaryLines: string[] = []; @@ -63,6 +70,11 @@ export async function deployAction( ` - ${functions.length} ${functions.length === 1 ? "function" : "functions"}`, ); } + if (actors.length > 0) { + summaryLines.push( + ` - ${actors.length} ${actors.length === 1 ? "actor" : "actors"}`, + ); + } if (agents.length > 0) { summaryLines.push( ` - ${agents.length} ${agents.length === 1 ? "agent" : "agents"}`, @@ -102,9 +114,11 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress + // Deploy resources with per-function and per-actor progress let functionCompleted = 0; const functionTotal = functions.length; + let actorCompleted = 0; + const actorTotal = actors.length; const result = await deployAll(projectData, { onVisibilitySet: (level) => { @@ -122,6 +136,18 @@ export async function deployAction( functionCompleted++; formatDeployResult(r, log); }, + onActorStart: (names) => { + const label = names.length === 1 ? names[0] : `${names.length} actors`; + log.step( + theme.styles.dim( + `[${actorCompleted + 1}/${actorTotal}] Deploying ${label}...`, + ), + ); + }, + onActorResult: (r) => { + actorCompleted++; + formatDeployResult(r, log); + }, }); // Handle connector-specific post-deploy flows @@ -147,7 +173,7 @@ export async function deployAction( export function getDeployCommand(): Command { return new Base44Command("deploy") .description( - "Deploy all project resources (entities, functions, agents, connectors, and site)", + "Deploy entities, functions, actors, agent skills, agents, auth, connectors, and site", ) .option("-y, --yes", "Skip confirmation prompt") .option("--build", "Build the site before deploying (skips the prompt)") diff --git a/packages/cli/src/cli/commands/types/generate.ts b/packages/cli/src/cli/commands/types/generate.ts index f74545af1..e06de2b45 100644 --- a/packages/cli/src/cli/commands/types/generate.ts +++ b/packages/cli/src/cli/commands/types/generate.ts @@ -9,7 +9,7 @@ const TYPES_FILE_PATH = "base44/.types/types.d.ts"; async function generateTypesAction({ runTask, }: CLIContext): Promise { - const { entities, functions, agents, connectors, project } = + const { entities, functions, agents, connectors, actors, project } = await readProjectConfig(); await runTask("Generating types", async () => { @@ -19,6 +19,7 @@ async function generateTypesAction({ functions, agents, connectors, + actors, }); }); diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index fe8bc8f6a..c1c64c022 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -1,4 +1,5 @@ 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 { getAuthCommand } from "@/cli/commands/auth/index.js"; @@ -95,6 +96,9 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + // Register actors commands + program.addCommand(getActorsCommand()); + // Register workflows commands program.addCommand(getWorkflowsCommand()); diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 903dc2b37..301baffa4 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -15,7 +15,7 @@ import { formatPlainUpgradeMessage, startUpgradeCheck, } from "@/cli/utils/upgradeNotification.js"; -import { ApiError, isCLIError } from "@/core/errors.js"; +import { ApiError, isCLIError, ResourceDeployError } from "@/core/errors.js"; /** * Write a command result to stdout as a single JSON document (the `--json` @@ -63,6 +63,26 @@ function writeJsonError(error: unknown): void { envelope.requestId = error.requestId; } } + if (error instanceof ResourceDeployError) { + envelope.failures = error.failures.map((failure) => { + const item: Record = { + name: failure.name, + error: failure.message, + }; + if (isCLIError(failure.cause)) { + item.code = failure.cause.code; + } + if (failure.cause instanceof ApiError) { + if (failure.cause.statusCode !== undefined) { + item.statusCode = failure.cause.statusCode; + } + if (failure.cause.requestId !== undefined) { + item.requestId = failure.cause.requestId; + } + } + return item; + }); + } process.stdout.write(`${JSON.stringify(envelope)}\n`); } diff --git a/packages/cli/src/cli/utils/deploy-reporting.ts b/packages/cli/src/cli/utils/deploy-reporting.ts new file mode 100644 index 000000000..6f13518bc --- /dev/null +++ b/packages/cli/src/cli/utils/deploy-reporting.ts @@ -0,0 +1,39 @@ +import type { Logger } from "@base44-cli/logger"; +import { theme } from "@/cli/utils/theme.js"; +import type { SingleDeployResult } from "@/core/resources/types.js"; + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +export function formatDeployResult( + result: SingleDeployResult, + log: Logger, +): void { + const label = result.name.padEnd(25); + if (result.status === "deployed") { + const timing = result.durationMs + ? theme.styles.dim(` (${formatDuration(result.durationMs)})`) + : ""; + log.success(`${label} deployed${timing}`); + } else if (result.status === "unchanged") { + log.success(`${label} unchanged`); + } else { + log.error(`${label} error: ${result.error}`); + } +} + +export function buildDeploySummary( + results: SingleDeployResult[], + noun: "functions" | "actors", +): string { + const deployed = results.filter((r) => r.status === "deployed").length; + const unchanged = results.filter((r) => r.status === "unchanged").length; + const failed = results.filter((r) => r.status === "error").length; + + const parts: string[] = []; + if (deployed > 0) parts.push(`${deployed} deployed`); + if (unchanged > 0) parts.push(`${unchanged} unchanged`); + if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); + return parts.join(", ") || `No ${noun} deployed`; +} diff --git a/packages/cli/src/cli/utils/index.ts b/packages/cli/src/cli/utils/index.ts index f8313a547..43253966d 100644 --- a/packages/cli/src/cli/utils/index.ts +++ b/packages/cli/src/cli/utils/index.ts @@ -3,7 +3,9 @@ export * from "./banner.js"; export * from "./command/index.js"; export * from "./confirm-push.js"; export * from "./datetime.js"; +export * from "./deploy-reporting.js"; export * from "./json.js"; +export * from "./parse-names.js"; export * from "./prompts.js"; export * from "./runTask.js"; export * from "./secret-input.js"; diff --git a/packages/cli/src/cli/commands/functions/parseNames.ts b/packages/cli/src/cli/utils/parse-names.ts similarity index 100% rename from packages/cli/src/cli/commands/functions/parseNames.ts rename to packages/cli/src/cli/utils/parse-names.ts diff --git a/packages/cli/src/core/consts.ts b/packages/cli/src/core/consts.ts index ed326e2bf..abefd699f 100644 --- a/packages/cli/src/core/consts.ts +++ b/packages/cli/src/core/consts.ts @@ -6,12 +6,12 @@ export const CONFIG_FILE_EXTENSION_GLOB = "{json,jsonc}"; /** Glob for discovering function config files at any depth under functions dir. */ export const FUNCTION_CONFIG_GLOB = `**/function.${CONFIG_FILE_EXTENSION_GLOB}`; -/** Glob for zero-config function entry files (any depth). */ +/** Glob for zero-config function and actor entry files (any depth). */ export const ENTRY_FILE_GLOB = "**/entry.{js,ts}"; /** - * Glob for source files bundled into a backend function's deploy payload — - * used for both the function directory and the shared (`base44/shared/`) dir. + * Glob for source files bundled into a function's or actor's deploy payload — + * for functions it also covers the shared (`base44/shared/`) dir. */ export const BACKEND_FILE_GLOB = "**/*.{js,ts,json,jsonc}"; diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index b5bf8049a..62f2e878a 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -484,6 +484,41 @@ export class ApiError extends SystemError { } } +interface ResourceDeployFailure { + name: string; + message: string; + cause?: Error; +} + +/** + * Thrown when one or more items in a sequential resource deploy fail. + */ +export class ResourceDeployError extends SystemError { + readonly code = "RESOURCE_DEPLOY_FAILED"; + readonly failures: ResourceDeployFailure[]; + + constructor(resource: string, failures: ResourceDeployFailure[]) { + const label = failures.length === 1 ? resource : `${resource}s`; + const hints = failures.flatMap((failure) => + failure.cause instanceof CLIError ? failure.cause.hints : [], + ); + + super(`Failed to deploy ${failures.length} ${label}`, { + details: failures.map((failure) => `${failure.name}: ${failure.message}`), + hints: hints.filter( + (hint, index, all) => + all.findIndex( + (candidate) => + candidate.message === hint.message && + candidate.command === hint.command, + ) === index, + ), + cause: failures.find((failure) => failure.cause)?.cause, + }); + this.failures = failures; + } +} + /** * Thrown when a file is not found. */ diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 4f32856c6..d9a2f96d6 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -22,6 +22,8 @@ import type { ProjectRoot, ProjectWithPaths, } from "@/core/project/types.js"; +import type { Actor } from "@/core/resources/actor/index.js"; +import { actorResource } from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -67,11 +69,13 @@ class ProjectConfigReader { ...pluginResources.functions, ]; this.validateFunctionNames(functions, configPath); + this.validateActorNames(localResources.actors, functions, configPath); return { project, entities, functions, + actors: localResources.actors, agents: localResources.agents, agentSkills: localResources.agentSkills, connectors: localResources.connectors, @@ -118,17 +122,33 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [entities, functions, agents, agentSkills, connectors, authConfig] = - await Promise.all([ - entityResource.readAll(join(configDir, project.entitiesDir)), - functionResource.readAll(join(configDir, project.functionsDir)), - agentResource.readAll(join(configDir, project.agentsDir)), - agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - authConfigResource.readAll(join(configDir, project.authDir)), - ]); - - return { entities, functions, agents, agentSkills, connectors, authConfig }; + const [ + entities, + functions, + actors, + agents, + agentSkills, + connectors, + authConfig, + ] = await Promise.all([ + entityResource.readAll(join(configDir, project.entitiesDir)), + functionResource.readAll(join(configDir, project.functionsDir)), + actorResource.readAll(join(configDir, project.actorsDir)), + agentResource.readAll(join(configDir, project.agentsDir)), + agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + ]); + + return { + entities, + functions, + actors, + agents, + agentSkills, + connectors, + authConfig, + }; } private assertPluginProjectDoesNotLoadPlugins( @@ -198,6 +218,7 @@ class ProjectConfigReader { return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), + actors: [], agents: [], agentSkills: [], connectors: [], @@ -255,6 +276,7 @@ class ProjectConfigReader { return { entities, functions, + actors: [], agents: [], agentSkills: [], connectors: [], @@ -287,6 +309,36 @@ class ProjectConfigReader { functionsByName.set(fn.name, fn); } } + + /** + * An actor deploys onto the same backend-function namespace server-side, so a + * name shared with a function is rejected there. Catch it at read time — the + * alternative is a mid-deploy failure after earlier resources already landed. + */ + private validateActorNames( + actors: Actor[], + functions: BackendFunction[], + configPath: string, + ): void { + const functionNames = new Set(functions.map((fn) => fn.name)); + + for (const actor of actors) { + if (functionNames.has(actor.name)) { + throw new ConfigInvalidError( + `"${actor.name}" exists as both a backend function and an actor.`, + configPath, + { + hints: [ + { + message: + "Actors and functions share one deploy namespace — rename one of them.", + }, + ], + }, + ); + } + } + } } /** diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef95..01973be2d 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -3,6 +3,10 @@ import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; import { setAppVisibility } from "@/core/project/api.js"; import type { Visibility } from "@/core/project/schema.js"; import type { ProjectData } from "@/core/project/types.js"; +import { + deployActorsSequentially, + type SingleActorDeployResult, +} from "@/core/resources/actor/deploy.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -15,6 +19,7 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; +import { throwIfDeployFailed } from "@/core/resources/types.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -28,6 +33,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { project, entities, functions, + actors, agents, agentSkills, connectors, @@ -36,6 +42,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; + const hasActors = actors.length > 0; const hasAgents = agents.length > 0; const hasAgentSkills = agentSkills.length > 0; const hasConnectors = connectors.length > 0; @@ -45,6 +52,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || + hasActors || hasAgents || hasAgentSkills || hasConnectors || @@ -71,11 +79,13 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; + onActorStart?: (names: string[]) => void; + onActorResult?: (result: SingleActorDeployResult) => void; onVisibilitySet?: (visibility: Visibility) => void; } /** - * Deploys all project resources (entities, functions, agents, connectors, and site) to Base44. + * Deploys all project resources (entities, functions, actors, agents, connectors, and site) to Base44. * * @param projectData - The project configuration and resources to deploy * @param options - Optional progress callbacks for resource deployment @@ -89,6 +99,7 @@ export async function deployAll( project, entities, functions, + actors, agents, agentSkills, connectors, @@ -100,10 +111,17 @@ export async function deployAll( options?.onVisibilitySet?.(project.visibility); } await entityResource.push(entities); - await deployFunctionsSequentially(functions, { + const functionResults = await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); + throwIfDeployFailed(functionResults, "function"); + + const actorResults = await deployActorsSequentially(actors, { + onStart: options?.onActorStart, + onResult: options?.onActorResult, + }); + throwIfDeployFailed(actorResults, "actor"); await agentSkillResource.push(agentSkills); await agentResource.push(agents); await authConfigResource.push(authConfig); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 6d4412f3b..42041acfb 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -49,6 +49,7 @@ export const ProjectConfigSchema = z.object({ site: SiteConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), + actorsDir: z.string().optional().default("actors"), agentsDir: z.string().optional().default("agents"), agentSkillsDir: z.string().optional().default("agent-skills"), connectorsDir: z.string().optional().default("connectors"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index b69b14682..f25f4c5d3 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -1,4 +1,5 @@ import type { ProjectConfig } from "@/core/project/schema.js"; +import type { Actor } from "@/core/resources/actor/index.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { AgentSkill } from "@/core/resources/agent-skill/index.js"; import type { AuthConfig } from "@/core/resources/auth-config/index.js"; @@ -20,6 +21,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; + actors: Actor[]; agents: AgentConfig[]; agentSkills: AgentSkill[]; connectors: ConnectorResource[]; diff --git a/packages/cli/src/core/resources/actor/api.ts b/packages/cli/src/core/resources/actor/api.ts new file mode 100644 index 000000000..577455ae3 --- /dev/null +++ b/packages/cli/src/core/resources/actor/api.ts @@ -0,0 +1,47 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { DeployActorResponse } from "@/core/resources/actor/schema.js"; +import { DeployActorResponseSchema } from "@/core/resources/actor/schema.js"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; + +export async function deploySingleActor( + name: string, + payload: { entry: string; files: FunctionFile[] }, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.put(`actors/${encodeURIComponent(name)}`, { + json: payload, + timeout: false, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, `deploying actor "${name}"`); + } + + const result = DeployActorResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * Tear down a deployed actor. The server destroys the published script, so this + * is not reversible by a redeploy of the same instance state. + */ +export async function deleteSingleActor(name: string): Promise { + const appClient = getAppClient(); + try { + await appClient.delete(`actors/${encodeURIComponent(name)}`, { + timeout: 60_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, `deleting actor "${name}"`); + } +} diff --git a/packages/cli/src/core/resources/actor/config.ts b/packages/cli/src/core/resources/actor/config.ts new file mode 100644 index 000000000..514d1a4f4 --- /dev/null +++ b/packages/cli/src/core/resources/actor/config.ts @@ -0,0 +1,140 @@ +import { basename, dirname, relative } from "node:path"; +import { globby } from "globby"; +import { + BACKEND_FILE_GLOB, + ENTRY_FILE_GLOB, + ENTRY_IGNORE_DOT_PATHS, +} from "@/core/consts.js"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import { pathExists } from "@/core/utils/fs.js"; + +/** + * An actor's name becomes a Durable Object class and the WebSocket connect + * handler on the server, so it has to be a plain ASCII JavaScript identifier. + * Mirrors the server's own rule — `PUT /api/apps/{app_id}/actors/{name}` 422s + * anything else — so a bad folder name fails locally instead of mid-deploy. + */ +const VALID_ACTOR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; + +/** + * ES reserved words + strict-mode reserved words + `eval`/`arguments`. The + * server interpolates the name into `import from …; export { }`, + * so a reserved word would compile to invalid JavaScript. + */ +const JS_RESERVED_ACTOR_NAMES = new Set( + ( + "await break case catch class const continue debugger default delete do else " + + "enum export extends false finally for function if import in instanceof let " + + "new null return static super switch this throw true try typeof var void " + + "while with yield implements interface package private protected public " + + "eval arguments" + ).split(" "), +); + +function assertValidActorName(name: string): void { + if (name.includes("/")) { + throw new ConfigInvalidError( + `Invalid actor name "${name}" — actors cannot be nested in subfolders`, + null, + { + hints: [ + { + message: `Use a single folder level (e.g. actors/${name.split("/").pop()}/entry.ts)`, + }, + { + message: + "A nested name can also mean a helper file was named entry.ts — every entry file under the actors directory is treated as an actor, so rename the helper", + }, + ], + }, + ); + } + + if (!VALID_ACTOR_NAME.test(name)) { + throw new ConfigInvalidError( + `Invalid actor name "${name}" — actor names become a JavaScript class binding, so they must match [A-Za-z_][A-Za-z0-9_]* (max 128 characters, no "-", "." or ":")`, + null, + { + hints: [ + { message: "Rename the folder in PascalCase (e.g. actors/ChatRoom)" }, + ], + }, + ); + } + + if (JS_RESERVED_ACTOR_NAMES.has(name)) { + throw new ConfigInvalidError( + `Invalid actor name "${name}" — it is a reserved word in JavaScript, and actor names become a class binding`, + null, + { + hints: [ + { message: "Rename the folder in PascalCase (e.g. actors/ChatRoom)" }, + ], + }, + ); + } +} + +async function readActor(entryFile: string, actorsDir: string): Promise { + const actorDir = dirname(entryFile); + const name = relative(actorsDir, actorDir).split(/[/\\]/).join("/"); + if (!name) { + const entryName = basename(entryFile); + throw new InvalidInputError( + `${entryName} found directly in the actors directory — it must be inside a named subfolder`, + { + hints: [ + { + message: `Move ${entryFile} into a subfolder (e.g. actors/MyActor/entry.ts)`, + }, + ], + }, + ); + } + assertValidActorName(name); + + const filePaths = await globby(BACKEND_FILE_GLOB, { + cwd: actorDir, + absolute: true, + }); + + return { + name, + entry: basename(entryFile), + entryPath: entryFile, + filePaths, + source: { type: "project" }, + }; +} + +export async function readAllActors(actorsDir: string): Promise { + if (!(await pathExists(actorsDir))) { + return []; + } + + // Same dot-path exclusion as functions: a folder with a dot in its name can + // never be a valid actor name, so treat it as scratch (`ChatRoom.bak/`) + // rather than a deploy that would 422. + const entryFiles = await globby(ENTRY_FILE_GLOB, { + cwd: actorsDir, + absolute: true, + ignore: ENTRY_IGNORE_DOT_PATHS, + }); + + const actors = await Promise.all( + entryFiles.map((entryFile) => readActor(entryFile, actorsDir)), + ); + + const names = new Set(); + for (const actor of actors) { + if (names.has(actor.name)) { + throw new ConfigInvalidError( + `Duplicate actor name "${actor.name}" in ${actorsDir}`, + ); + } + names.add(actor.name); + } + + return actors; +} diff --git a/packages/cli/src/core/resources/actor/deploy.ts b/packages/cli/src/core/resources/actor/deploy.ts new file mode 100644 index 000000000..4751412b8 --- /dev/null +++ b/packages/cli/src/core/resources/actor/deploy.ts @@ -0,0 +1,64 @@ +import { dirname, relative } from "node:path"; +import { deploySingleActor } from "@/core/resources/actor/api.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import type { FunctionFile } from "@/core/resources/function/schema.js"; +import type { SingleDeployResult } from "@/core/resources/types.js"; +import { readTextFile } from "@/core/utils/fs.js"; + +async function loadActorCode( + actor: Actor, +): Promise<{ name: string; entry: string; files: FunctionFile[] }> { + const actorDir = dirname(actor.entryPath); + const resolvedFiles: FunctionFile[] = await Promise.all( + actor.filePaths.map(async (filePath) => { + const content = await readTextFile(filePath); + const path = relative(actorDir, filePath).split(/[/\\]/).join("/"); + return { path, content }; + }), + ); + return { name: actor.name, entry: actor.entry, files: resolvedFiles }; +} + +export type SingleActorDeployResult = SingleDeployResult; + +async function deployOne(actor: Actor): Promise { + const start = Date.now(); + try { + const loaded = await loadActorCode(actor); + const response = await deploySingleActor(loaded.name, { + entry: loaded.entry, + files: loaded.files, + }); + return { + name: loaded.name, + status: response.status, + durationMs: Date.now() - start, + }; + } catch (error) { + return { + name: actor.name, + status: "error", + error: error instanceof Error ? error.message : String(error), + cause: error instanceof Error ? error : undefined, + }; + } +} + +export async function deployActorsSequentially( + actors: Actor[], + options?: { + onStart?: (names: string[]) => void; + onResult?: (result: SingleActorDeployResult) => void; + }, +): Promise { + if (actors.length === 0) return []; + + const results: SingleActorDeployResult[] = []; + for (const actor of actors) { + options?.onStart?.([actor.name]); + const result = await deployOne(actor); + results.push(result); + options?.onResult?.(result); + } + return results; +} diff --git a/packages/cli/src/core/resources/actor/index.ts b/packages/cli/src/core/resources/actor/index.ts new file mode 100644 index 000000000..90b197a7b --- /dev/null +++ b/packages/cli/src/core/resources/actor/index.ts @@ -0,0 +1,5 @@ +export * from "./api.js"; +export * from "./config.js"; +export * from "./deploy.js"; +export * from "./resource.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/actor/resource.ts b/packages/cli/src/core/resources/actor/resource.ts new file mode 100644 index 000000000..c3e0893ed --- /dev/null +++ b/packages/cli/src/core/resources/actor/resource.ts @@ -0,0 +1,13 @@ +import { readAllActors } from "@/core/resources/actor/config.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import { type Resource, throwIfDeployFailed } from "@/core/resources/types.js"; + +export const actorResource: Resource = { + readAll: readAllActors, + push: async (actors) => { + const results = await deployActorsSequentially(actors); + throwIfDeployFailed(results, "actor"); + return results; + }, +}; diff --git a/packages/cli/src/core/resources/actor/schema.ts b/packages/cli/src/core/resources/actor/schema.ts new file mode 100644 index 000000000..3bf86decc --- /dev/null +++ b/packages/cli/src/core/resources/actor/schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { ResourceSourceSchema } from "@/core/resources/types.js"; + +const ActorSchema = z.object({ + name: z.string().min(1), + entry: z.string().min(1), + entryPath: z.string().min(1), + filePaths: z.array(z.string()).min(1), + source: ResourceSourceSchema, +}); + +export const DeployActorResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), +}); + +export type Actor = z.infer; +export type DeployActorResponse = z.infer; diff --git a/packages/cli/src/core/resources/function/deploy.ts b/packages/cli/src/core/resources/function/deploy.ts index c0e3f66bf..6b9744396 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -9,6 +9,7 @@ import type { FunctionFile, FunctionWithCode, } from "@/core/resources/function/schema.js"; +import type { SingleDeployResult } from "@/core/resources/types.js"; import { readTextFile } from "@/core/utils/fs.js"; async function loadFunctionCode( @@ -25,12 +26,7 @@ async function loadFunctionCode( return { ...fn, files: resolvedFiles }; } -export interface SingleFunctionDeployResult { - name: string; - status: "deployed" | "unchanged" | "error"; - error?: string | null; - durationMs?: number; -} +export type SingleFunctionDeployResult = SingleDeployResult; async function deployOne( fn: BackendFunction, @@ -53,6 +49,7 @@ async function deployOne( name: fn.name, status: "error", error: error instanceof Error ? error.message : String(error), + cause: error instanceof Error ? error : undefined, }; } } diff --git a/packages/cli/src/core/resources/function/resource.ts b/packages/cli/src/core/resources/function/resource.ts index 8cf9815df..196202884 100644 --- a/packages/cli/src/core/resources/function/resource.ts +++ b/packages/cli/src/core/resources/function/resource.ts @@ -1,9 +1,13 @@ import { readAllFunctions } from "@/core/resources/function/config.js"; import { deployFunctionsSequentially } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; -import type { Resource } from "@/core/resources/types.js"; +import { type Resource, throwIfDeployFailed } from "@/core/resources/types.js"; export const functionResource: Resource = { readAll: readAllFunctions, - push: (functions) => deployFunctionsSequentially(functions), + push: async (functions) => { + const results = await deployFunctionsSequentially(functions); + throwIfDeployFailed(results, "function"); + return results; + }, }; diff --git a/packages/cli/src/core/resources/index.ts b/packages/cli/src/core/resources/index.ts index a8b80eaff..74bc62530 100644 --- a/packages/cli/src/core/resources/index.ts +++ b/packages/cli/src/core/resources/index.ts @@ -1,3 +1,4 @@ +export * from "./actor/index.js"; export * from "./agent/index.js"; export * from "./auth-config/index.js"; export * from "./connector/index.js"; diff --git a/packages/cli/src/core/resources/types.ts b/packages/cli/src/core/resources/types.ts index 25c4596af..aa3a342f6 100644 --- a/packages/cli/src/core/resources/types.ts +++ b/packages/cli/src/core/resources/types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ResourceDeployError } from "@/core/errors.js"; export const ResourceSourceSchema = z.discriminatedUnion("type", [ z.object({ @@ -10,6 +11,34 @@ export const ResourceSourceSchema = z.discriminatedUnion("type", [ }), ]); +/** + * Per-item outcome of a sequential deploy (functions, actors). + */ +export interface SingleDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + cause?: Error; + durationMs?: number; +} + +export function throwIfDeployFailed( + results: SingleDeployResult[], + resource: "actor" | "function", +): void { + const failures = results + .filter((result) => result.status === "error") + .map((result) => ({ + name: result.name, + message: result.error ?? "Unknown deployment error", + cause: result.cause, + })); + + if (failures.length > 0) { + throw new ResourceDeployError(resource, failures); + } +} + /** * Base interface for all project resources (entities, functions, etc.). * Resources are project-specific collections that can be loaded from the filesystem diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 6297f9c1b..e3a5094bf 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -3,6 +3,7 @@ import type { JSONSchema4 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import { getTypesOutputPath } from "@/core/config.js"; import { TypeGenerationError } from "@/core/errors.js"; +import type { Actor } from "@/core/resources/actor/index.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; @@ -15,6 +16,7 @@ interface GenerateTypesInput { functions: BackendFunction[]; agents: AgentConfig[]; connectors: ConnectorResource[]; + actors: Actor[]; } const HEADER = stripIndent` @@ -26,8 +28,8 @@ const EMPTY_TEMPLATE = stripIndent` // Auto-generated by Base44 CLI - DO NOT EDIT // Regenerate with: base44 types // - // No entities, functions, agents, or connectors found in project. - // Add resources to base44/entities/, base44/functions/, base44/agents/, or base44/connectors/ + // No entities, functions, agents, connectors, or actors found in project. + // Add resources to base44/entities/, base44/functions/, base44/agents/, base44/connectors/, or base44/actors/ // and run \`base44 types generate\` again. declare module '@base44/sdk' { @@ -46,13 +48,14 @@ export async function generateTypesFile( } async function generateContent(input: GenerateTypesInput): Promise { - const { entities, functions, agents, connectors } = input; + const { entities, functions, agents, connectors, actors } = input; if ( !entities.length && !functions.length && !agents.length && - !connectors.length + !connectors.length && + !actors.length ) { return EMPTY_TEMPLATE; } @@ -70,6 +73,7 @@ async function generateContent(input: GenerateTypesInput): Promise { ["FunctionNameRegistry", functions.map((f) => `"${f.name}": true;`)], ["AgentNameRegistry", agents.map((a) => `"${a.name}": true;`)], ["ConnectorTypeRegistry", connectors.map((c) => `"${c.type}": true;`)], + ["ActorNameRegistry", actors.map((a) => `"${a.name}": true;`)], ]; // Generate registries (only for non-empty entries) diff --git a/packages/cli/tests/cli/actors_delete.spec.ts b/packages/cli/tests/cli/actors_delete.spec.ts new file mode 100644 index 000000000..5dd7e7db2 --- /dev/null +++ b/packages/cli/tests/cli/actors_delete.spec.ts @@ -0,0 +1,81 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("actors delete command", () => { + const t = setupCLITests(); + + it("deletes a single actor successfully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDelete(); + + const result = await t.run("actors", "delete", "ChatRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("deleted"); + }); + + it("deletes multiple actors with summary", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDelete(); + + const result = await t.run("actors", "delete", "ChatRoom", "BoardRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("ChatRoom deleted"); + t.expectResult(result).toContain("BoardRoom deleted"); + t.expectResult(result).toContain("2/2 deleted"); + }); + + it("accepts comma-separated actor names", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDelete(); + + const result = await t.run("actors", "delete", "ChatRoom,BoardRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("2/2 deleted"); + }); + + it("reports not found for non-existent actor", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDeleteError({ + status: 404, + body: { error: "Not found" }, + }); + + const result = await t.run("actors", "delete", "nonexistent"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("not found"); + }); + + it("reports API errors gracefully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleActorDeleteError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("actors", "delete", "ChatRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Failed to delete"); + }); + + it("requires at least one actor name", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("actors", "delete", ","); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("At least one actor name is required"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("actors", "delete", "ChatRoom"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/actors_deploy.spec.ts b/packages/cli/tests/cli/actors_deploy.spec.ts new file mode 100644 index 000000000..251166e17 --- /dev/null +++ b/packages/cli/tests/cli/actors_deploy.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("actors deploy command", () => { + const t = setupCLITests(); + + it("warns when no actors found in project", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No actors found"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 app ID found"); + }); + + it("deploys actors successfully", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying ChatRoom"); + t.expectResult(result).toContain("1 deployed"); + + expect(t.api.actorDeployRequests).toHaveLength(1); + const request = t.api.actorDeployRequests[0]!; + expect(request.name).toBe("ChatRoom"); + expect(request.entry).toBe("entry.ts"); + expect(request.files.map((file) => file.path).sort()).toEqual([ + "entry.ts", + "helper.ts", + ]); + expect( + request.files.find((file) => file.path === "entry.ts")?.content, + ).toContain('from "base44:runtime/actors"'); + expect( + request.files.find((file) => file.path === "helper.ts")?.content, + ).toContain("formatMessage"); + }); + + it("reports unchanged actor", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "unchanged" }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("unchanged"); + t.expectResult(result).toContain("1 unchanged"); + }); + + it("deploys specific actor by name", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + + const result = await t.run("actors", "deploy", "ChatRoom"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying ChatRoom"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("accepts comma-separated actor names", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + + const result = await t.run("actors", "deploy", "ChatRoom,"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("1 deployed"); + }); + + it("fails when actor name not found in project", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + + const result = await t.run("actors", "deploy", "nonexistent"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("not found in project"); + }); + + it("reports error when API fails for an actor", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeployError({ + status: 400, + body: { error: "Invalid actor code" }, + }); + + const result = await t.run("actors", "deploy"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("error"); + t.expectResult(result).toContain("1 error"); + }); + + it("returns structured actor failures in JSON mode", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeployError({ + status: 422, + body: { message: "Invalid actor code" }, + }); + + const result = await t.run("actors", "deploy", "--json"); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + error: "Failed to deploy 1 actor", + code: "RESOURCE_DEPLOY_FAILED", + failures: [ + { + name: "ChatRoom", + code: "API_ERROR", + statusCode: 422, + }, + ], + }); + expect(result.stdout).toContain("Invalid actor code"); + }); +}); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index c32ed161f..d94ca5a92 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -4,6 +4,13 @@ import { fixture, setupCLITests } from "./testkit/index.js"; describe("deploy command (unified)", () => { const t = setupCLITests(); + it("lists actors in deploy help", async () => { + const result = await t.run("deploy", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("actors"); + }); + it("applies app visibility from config during deploy", async () => { await t.givenLoggedInWithProject(fixture("with-visibility")); @@ -96,6 +103,36 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("deploys actors with unified deploy", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeploy({ status: "deployed" }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("1 actor"); + t.expectResult(result).toContain("Deploying ChatRoom"); + t.expectResult(result).toContain("deployed"); + t.expectResult(result).toContain("App deployed successfully"); + }); + + it("fails unified deploy when an actor fails", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + t.api.mockSingleActorDeployError({ + status: 400, + body: { error: "Invalid actor code" }, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid actor code"); + t.expectResult(result).toNotContain("App deployed successfully"); + }); + it("deploys entities successfully with --yes flag", async () => { await t.givenLoggedInWithProject(fixture("with-entities")); t.api.mockEntitiesPush({ @@ -127,6 +164,21 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("fails unified deploy when a function fails", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockEntitiesPush({ created: ["Order"], updated: [], deleted: [] }); + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid function code"); + t.expectResult(result).toNotContain("App deployed successfully"); + }); + it("deploys zero-config functions (path-based names) with unified deploy", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); diff --git a/packages/cli/tests/cli/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 573119202..4f1c46d66 100644 --- a/packages/cli/tests/cli/functions_deploy.spec.ts +++ b/packages/cli/tests/cli/functions_deploy.spec.ts @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("functions deploy command", () => { @@ -115,6 +115,30 @@ describe("functions deploy command", () => { t.expectResult(result).toContain("1 error"); }); + it("returns structured function failures in JSON mode", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSingleFunctionDeployError({ + status: 422, + body: { message: "Invalid function code" }, + }); + + const result = await t.run("functions", "deploy", "--json"); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + error: "Failed to deploy 1 function", + code: "RESOURCE_DEPLOY_FAILED", + failures: [ + { + name: "process-order", + code: "API_ERROR", + statusCode: 422, + }, + ], + }); + expect(result.stdout).toContain("Invalid function code"); + }); + it("reports validation error from 422 response", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); t.api.mockSingleFunctionDeployError({ diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 17d7f6ac6..d24d619f4 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -115,6 +115,16 @@ interface SingleFunctionDeployResponse { status: "deployed" | "unchanged"; } +interface SingleActorDeployResponse { + status: "deployed" | "unchanged"; +} + +interface ActorDeployRequest { + name: string; + entry: string; + files: Array<{ path: string; content: string }>; +} + interface AutomationBase { name: string; description?: string | null; @@ -381,6 +391,9 @@ export class TestAPIServer { private server: Server | null = null; private _port = 0; + /** Captured actor deploy request bodies and decoded actor names. */ + readonly actorDeployRequests: ActorDeployRequest[] = []; + constructor(readonly appId: string) { this.app = express(); this.app.use(express.json()); @@ -527,6 +540,36 @@ export class TestAPIServer { ); } + /** Mock PUT /api/apps/{appId}/actors/{name} - Deploy single actor */ + mockSingleActorDeploy(response: SingleActorDeployResponse): this { + this.pendingRoutes.push({ + method: "PUT", + path: `/api/apps/${this.appId}/actors/:name`, + handler: (req, res) => { + const body = req.body as Omit; + this.actorDeployRequests.push({ + name: String(req.params.name), + entry: body.entry, + files: body.files, + }); + res.status(200).json(response); + }, + }); + return this; + } + + /** Mock DELETE /api/apps/{appId}/actors/{name} - Delete single actor */ + mockSingleActorDelete(): this { + this.pendingRoutes.push({ + method: "DELETE", + path: `/api/apps/${this.appId}/actors/:name`, + handler: (_req, res) => { + res.status(200).json({ status: "deleted" }); + }, + }); + return this; + } + mockSiteDeploy(response: SiteDeployResponse): this { return this.addRoute( "POST", @@ -923,6 +966,15 @@ export class TestAPIServer { ); } + /** Mock single actor deploy to return an error */ + mockSingleActorDeployError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/actors/:name`, + error, + ); + } + /** Mock single function delete to return an error */ mockSingleFunctionDeleteError(error: ErrorResponse): this { return this.addErrorRoute( @@ -932,6 +984,15 @@ export class TestAPIServer { ); } + /** Mock single actor delete to return an error */ + mockSingleActorDeleteError(error: ErrorResponse): this { + return this.addErrorRoute( + "DELETE", + `/api/apps/${this.appId}/actors/:name`, + error, + ); + } + mockSiteDeployError(error: ErrorResponse): this { return this.addErrorRoute( "POST", diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index 9232d9e13..7338d42a3 100644 --- a/packages/cli/tests/cli/types_generate.spec.ts +++ b/packages/cli/tests/cli/types_generate.spec.ts @@ -45,6 +45,10 @@ describe("types generate command", () => { // Contains the ConnectorTypeRegistry with the connector type expect(typesContent).toContain("ConnectorTypeRegistry"); expect(typesContent).toContain(`"slack": true`); + + // Contains the ActorNameRegistry with the actor name + expect(typesContent).toContain("ActorNameRegistry"); + expect(typesContent).toContain(`"ChatRoom": true`); }); it("updates tsconfig.json to include types path", async () => { @@ -103,7 +107,7 @@ describe("types generate command", () => { const typesContent = await t.readProjectFile("base44/.types/types.d.ts"); expect(typesContent).not.toBeNull(); expect(typesContent).toContain( - "No entities, functions, agents, or connectors found", + "No entities, functions, agents, connectors, or actors found", ); }); diff --git a/packages/cli/tests/core/actor-config.spec.ts b/packages/cli/tests/core/actor-config.spec.ts new file mode 100644 index 000000000..9322a8b1b --- /dev/null +++ b/packages/cli/tests/core/actor-config.spec.ts @@ -0,0 +1,94 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readAllActors } from "@/core/resources/actor/config.js"; + +const FIXTURES_DIR = resolve(__dirname, "../fixtures"); +const fwd = (path: string) => path.replace(/\\/g, "/"); + +describe("readAllActors", () => { + it("returns an empty array when the actors directory does not exist", async () => { + const actors = await readAllActors( + resolve(FIXTURES_DIR, "nonexistent-actors"), + ); + + expect(actors).toEqual([]); + }); + + it("discovers actors and skips folders whose name contains a dot", async () => { + const actors = await readAllActors( + resolve(FIXTURES_DIR, "actor-discovery"), + ); + + // Chat.Room/ is present in the fixture but can never be a valid actor name, + // so it is excluded rather than deployed into a server-side rejection. + expect(actors.map((actor) => actor.name)).toEqual(["BoardRoom"]); + }); + + it("collects actor files recursively", async () => { + const actors = await readAllActors( + resolve(FIXTURES_DIR, "actor-discovery"), + ); + const boardRoom = actors.find((actor) => actor.name === "BoardRoom"); + + expect(boardRoom).toBeDefined(); + expect(boardRoom!.entry).toBe("entry.ts"); + expect(boardRoom!.filePaths.map(fwd).sort()).toEqual( + expect.arrayContaining([ + expect.stringMatching(/BoardRoom\/entry\.ts$/), + expect.stringMatching(/BoardRoom\/lib\/helper\.ts$/), + ]), + ); + }); + + it("rejects an entry file directly under the actors root", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-discovery-entry-at-root"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /entry\.js found directly in the actors directory/, + ); + }); + + it("rejects a nested actor folder", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-nested"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "games\/Arena" — actors cannot be nested/, + ); + }); + + it("rejects a helper file named entry.ts inside an actor folder", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-helper-entry"); + + // The failure mode the nesting error's second hint covers. + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "BoardRoom\/lib" — actors cannot be nested/, + ); + }); + + it("rejects an actor name that is not a JavaScript identifier", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-charset"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "chat-room" — actor names become a JavaScript class binding/, + ); + }); + + it("rejects an actor name that is a JavaScript reserved word", async () => { + const actorsDir = resolve(FIXTURES_DIR, "actor-invalid-reserved"); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Invalid actor name "class" — it is a reserved word in JavaScript/, + ); + }); + + it("rejects folders containing both entry.js and entry.ts", async () => { + const actorsDir = resolve( + FIXTURES_DIR, + "duplicate-actor-names/base44/actors", + ); + + await expect(readAllActors(actorsDir)).rejects.toThrow( + /Duplicate actor name "Duplicate"/, + ); + }); +}); diff --git a/packages/cli/tests/core/errors.spec.ts b/packages/cli/tests/core/errors.spec.ts index d14924f4d..9d6c2a7e2 100644 --- a/packages/cli/tests/core/errors.spec.ts +++ b/packages/cli/tests/core/errors.spec.ts @@ -14,6 +14,7 @@ import { isCLIError, isSystemError, isUserError, + ResourceDeployError, SchemaValidationError, } from "../../src/core/errors.js"; @@ -132,6 +133,22 @@ describe("UserError subclasses", () => { }); describe("SystemError subclasses", () => { + it("ResourceDeployError preserves per-resource failures", () => { + const cause = new ApiError("Invalid actor code", { statusCode: 422 }); + const error = new ResourceDeployError("actor", [ + { name: "ChatRoom", message: cause.message, cause }, + ]); + + expect(error.code).toBe("RESOURCE_DEPLOY_FAILED"); + expect(error.message).toBe("Failed to deploy 1 actor"); + expect(error.details).toEqual(["ChatRoom: Invalid actor code"]); + expect(error.failures).toEqual([ + { name: "ChatRoom", message: cause.message, cause }, + ]); + expect(error.cause).toBe(cause); + expect(isSystemError(error)).toBe(true); + }); + it("ApiError provides default hints based on status code", () => { const error401 = new ApiError("Unauthorized", { statusCode: 401 }); expect(error401.hints.some((h) => h.command === "base44 login")).toBe(true); diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 9e9e0defc..501ec990b 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -48,6 +48,16 @@ describe("readProjectConfig", () => { expect(result.agents).toEqual([]); }); + it("rejects a name used by both a function and an actor", async () => { + // Actors deploy onto the backend-function namespace, so the server rejects + // the collision — fail at read time instead of mid-deploy. + await expect( + readProjectConfig(resolve(FIXTURES_DIR, "actor-function-name-collision")), + ).rejects.toThrow( + /"ChatRoom" exists as both a backend function and an actor/, + ); + }); + it("reads project plugins with automatic entity merging and namespaced functions", async () => { const result = await readProjectConfig( resolve(FIXTURES_DIR, "with-config-plugins"), diff --git a/packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js b/packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js new file mode 100644 index 000000000..a07086906 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery-entry-at-root/entry.js @@ -0,0 +1 @@ +export default class RootActor {} diff --git a/packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts new file mode 100644 index 000000000..595e583b0 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/entry.ts @@ -0,0 +1,7 @@ +import { formatMessage } from "./lib/helper.js"; + +export default class BoardRoom { + handleMessage(message: unknown): string { + return formatMessage(message); + } +} diff --git a/packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts new file mode 100644 index 000000000..76283bf5b --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery/BoardRoom/lib/helper.ts @@ -0,0 +1,3 @@ +export function formatMessage(message: unknown): string { + return JSON.stringify(message); +} diff --git a/packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts b/packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts new file mode 100644 index 000000000..9d395876f --- /dev/null +++ b/packages/cli/tests/fixtures/actor-discovery/Chat.Room/entry.ts @@ -0,0 +1,3 @@ +// A dotted folder name can never be a valid actor name, so discovery skips it +// (same dot-path exclusion functions use) instead of failing the deploy. +export default class ScratchCopy {} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc b/packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc new file mode 100644 index 000000000..e1fbc58f2 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts new file mode 100644 index 000000000..c09f37e14 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/actors/ChatRoom/entry.ts @@ -0,0 +1 @@ +export default class ChatRoom {} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc b/packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc new file mode 100644 index 000000000..29f45c91c --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Actor/Function Collision Project" +} diff --git a/packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts b/packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts new file mode 100644 index 000000000..8ce2e6645 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-function-name-collision/base44/functions/ChatRoom/entry.ts @@ -0,0 +1,3 @@ +export default async function handler() { + return new Response("ok"); +} diff --git a/packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js b/packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js new file mode 100644 index 000000000..c09f37e14 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-charset/chat-room/entry.js @@ -0,0 +1 @@ +export default class ChatRoom {} diff --git a/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts new file mode 100644 index 000000000..2551fe7fb --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/entry.ts @@ -0,0 +1 @@ +export default class BoardRoom {} diff --git a/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts new file mode 100644 index 000000000..a83cdf9b1 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-helper-entry/BoardRoom/lib/entry.ts @@ -0,0 +1,5 @@ +// Named entry.ts by mistake: discovery treats every entry file under the actors +// directory as an actor, so this resolves to the nested name "BoardRoom/lib". +export function formatMessage(message: unknown): string { + return JSON.stringify(message); +} diff --git a/packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js b/packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js new file mode 100644 index 000000000..e87af5d5b --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-nested/games/Arena/entry.js @@ -0,0 +1 @@ +export default class Arena {} diff --git a/packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js b/packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js new file mode 100644 index 000000000..095725595 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-invalid-reserved/class/entry.js @@ -0,0 +1 @@ +export default class ReservedName {} diff --git a/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js new file mode 100644 index 000000000..c74e2fdea --- /dev/null +++ b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.js @@ -0,0 +1 @@ +export default class DuplicateActor {} diff --git a/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts new file mode 100644 index 000000000..c74e2fdea --- /dev/null +++ b/packages/cli/tests/fixtures/duplicate-actor-names/base44/actors/Duplicate/entry.ts @@ -0,0 +1 @@ +export default class DuplicateActor {} diff --git a/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc b/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc new file mode 100644 index 000000000..e1fbc58f2 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts new file mode 100644 index 000000000..7a9311934 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts @@ -0,0 +1,15 @@ +import { Actor } from "base44:runtime/actors"; +import { formatMessage } from "./helper.js"; + +interface TestConnection { + send(message: string): void; +} + +export default class ChatRoom extends Actor { + handleConnect(_conn: TestConnection) {} + handleMessage(conn: TestConnection, msg: unknown) { + conn.send(formatMessage(msg)); + } + handleTick() {} + handleClose(_conn: TestConnection) {} +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts new file mode 100644 index 000000000..aa63324fe --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/helper.ts @@ -0,0 +1,3 @@ +export function formatMessage(msg: unknown): string { + return JSON.stringify(msg); +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/config.jsonc b/packages/cli/tests/fixtures/with-actors/base44/config.jsonc new file mode 100644 index 000000000..701ea3eef --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Actors Test Project" +} diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts new file mode 100644 index 000000000..e11dd6287 --- /dev/null +++ b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts @@ -0,0 +1,8 @@ +import { Actor } from "base44:runtime/actors"; + +export default class ChatRoom extends Actor { + handleConnect() {} + handleMessage() {} + handleTick() {} + handleClose() {} +}