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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

Commands live in `src/cli/commands/<domain>/`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`.

## Actor command wiring

`src/cli/commands/actors/` registers deploy and delete through the same lifecycle as
functions. Keep name validation inside the action so failures use the global
JSON error envelope. Successful results use `RunCommandResult.stdout`; progress
and server warnings use the context logger. Deployment reads project resources;
deletion only needs the selected app context.

## Branch targeting

Discover names with `base44 branches list --app-id <app-id> --json`.
Expand Down
6 changes: 5 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ The namespace must be unique in the host project. It may contain letters, number
7. Append plugin functions after renaming them to `<namespace>__<name>`.
8. Validate the final entity/function names before returning `ProjectData`.

Actor discovery is skipped for imported plugins. Host actor names are checked
against both local functions and the final namespaced plugin functions before any
deployment starts.

Project-owned resources use `source: { type: "project" }`.

## Entities
Expand Down Expand Up @@ -111,7 +115,7 @@ Rules:
## Rules

- Plugins may contribute only entities and backend functions.
- Plugin agents, connectors, and auth config are ignored.
- Plugin actors, agents, connectors, and auth config are ignored.
- A project that declares `plugin` cannot also define `plugins`.
- Plugin entity names are global within the host project; they are not namespaced.
- Entity extensions cannot add or merge top-level RLS rules yet.
Expand Down
40 changes: 32 additions & 8 deletions docs/resources.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -85,6 +85,22 @@ 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.

## Actor integration

The resource implementation is in `src/core/resources/actor/`. The upload adapter
reads discovered files with the existing file utilities and strips local paths
and source metadata. Actor payloads never use function shared-file assembly.
The platform validates the source and handles publication.

`ProjectConfigReader` loads project actors, skips actor discovery for imported
plugins, and checks actor names against the final merged function names.
`src/core/types/generator.ts` augments the SDK with a top-level import so registry-only
output preserves SDK exports. Message declarations remain user-owned.

The dev server registers its actor handler before the production proxy. Keep
that registration unconditional; it also covers requests for actors absent from
the local checkout.

## 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.
Expand Down Expand Up @@ -137,12 +153,20 @@ 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 deployments-API transport is not reachable from here; see [deployments.md](deployments.md).
1. App visibility, when configured
2. Entities (via `entityResource.push()`)
3. Functions (via `deployFunctionsSequentially()`)
4. Actors (via `deployActorsSequentially()`)
5. Agent skills (via `agentSkillResource.push()`)
6. Agents (via `agentResource.push()`)
7. Auth config (via `authConfigResource.push()`)
8. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs
9. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The deployments-API transport is not reachable from here; see [deployments.md](deployments.md).

Function and actor batches collect each item's result. `deployAll()` must inspect
those results: any failure raises `ResourceDeploymentError` before the next stage.
Its details retain completed work and per-item outcomes for human and JSON error
output. Earlier successful deployments are not rolled back.

```bash
base44 deploy # With confirmation prompt
Expand Down
7 changes: 7 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,13 @@ function getTestOverride(): MyType | undefined {
}
```

## Actor Tests

Actor coverage lives in `core/actor-config.spec.ts`, `core/actor-types.spec.ts`,
`cli/actors.spec.ts`, `cli/actors_deploy_all.spec.ts`, and `cli/dev-actors.spec.ts`.
The command tests capture requests with `mockRoute` to verify payload boundaries,
partial outcomes, and stage ordering.

## Testing Rules

1. **Build first** -- Always `bun run build` before testing; add `bun run build:binaries` for binary mode
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/cli/commands/actors/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Command } from "commander";
import { parseNames } from "@/cli/commands/functions/parseNames.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import {
ApiError,
InvalidInputError,
ResourceDeploymentError,
} from "@/core/errors.js";
import {
actorOperationError,
deleteSingleActor,
describeActorResult,
type SingleActorDeleteResult,
validateActorName,
} from "@/core/resources/actor/index.js";

async function deleteActorsAction(
{ log, jsonMode, runTask }: CLIContext,
rawNames: string[],
): Promise<RunCommandResult> {
const names = [...new Set(parseNames(rawNames))];
if (!names.length)
throw new InvalidInputError("At least one actor name is required");
names.forEach(validateActorName);
const results: SingleActorDeleteResult[] = [];
for (const name of names) {
try {
await runTask(`Deleting ${name}...`, () => deleteSingleActor(name), {
successMessage: `${name} deleted`,
errorMessage: `Failed to delete ${name}`,
});
results.push({ name, status: "deleted" });
} catch (error) {
if (error instanceof ApiError && error.statusCode === 404) {
results.push({ name, status: "not_found" });
log.info(`${name} not found`);
} else {
const result = actorOperationError(name, error);
results.push(result);
log.error(describeActorResult(result));
}
}
}
const summary = {
deleted: results.filter((result) => result.status === "deleted").length,
notFound: results.filter((result) => result.status === "not_found").length,
failed: results.filter((result) => result.status === "error").length,
};
if (summary.failed)
throw new ResourceDeploymentError("Actor deletion failed", {
details: results.map(describeActorResult),
});
return {
outroMessage:
names.length === 1
? `Actor "${names[0]}" ${summary.deleted ? "deleted" : "not found"}`
: `${summary.deleted} deleted, ${summary.notFound} not found`,
stdout: jsonMode
? `${JSON.stringify({ actors: results, summary })}\n`
: undefined,
};
}

export function getDeleteCommand(): Command {
return new Base44Command("delete")
.description("Delete deployed actors")
.argument("[names...]", "Actor names to delete (required)")
.action(deleteActorsAction);
}
82 changes: 82 additions & 0 deletions packages/cli/src/cli/commands/actors/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Command } from "commander";
import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js";
import { parseNames } from "@/cli/commands/functions/parseNames.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { InvalidInputError, ResourceDeploymentError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/project/config.js";
import {
deployActorsSequentially,
describeActorResult,
validateActorName,
} from "@/core/resources/actor/index.js";

async function deployActorsAction(
{ log, jsonMode }: CLIContext,
rawNames: string[],
): Promise<RunCommandResult> {
const names = [...new Set(parseNames(rawNames))];
if (rawNames.length && !names.length)
throw new InvalidInputError("At least one actor name is required");
names.forEach(validateActorName);
const { actors, project } = await readProjectConfig();
const notFound = names.filter(
(name) => !actors.some((actor) => actor.name === name),
);
if (notFound.length)
throw new InvalidInputError(
`Actor not found in project: ${notFound.join(", ")}`,
);
const selected = names.length
? actors.filter((actor) => names.includes(actor.name))
: actors;
let completed = 0;
if (selected.length)
log.info(
`Found ${selected.length} ${selected.length === 1 ? "actor" : "actors"} to deploy`,
);
const results = await deployActorsSequentially(selected, {
onStart: (name) =>
log.step(
theme.styles.dim(
`[${completed + 1}/${selected.length}] Deploying ${name}...`,
),
),
onResult: (result) => {
completed++;
formatDeployResult(result, log);
if (result.status !== "error")
for (const warning of result.warnings)
log.warn(`${result.name}: ${warning}`);
},
});
const summary = {
deployed: results.filter((result) => result.status === "deployed").length,
unchanged: results.filter((result) => result.status === "unchanged").length,
failed: results.filter((result) => result.status === "error").length,
};
const message = Object.entries(summary)
.filter(([, count]) => count > 0)
.map(([status, count]) => `${count} ${status}`)
.join(", ");
if (summary.failed) {
throw new ResourceDeploymentError(message, {
details: results.map(describeActorResult),
});
}
return {
outroMessage:
message ||
`No actors found. Create actors in the '${project.actorsDir}' directory.`,
stdout: jsonMode
? `${JSON.stringify({ actors: results, summary })}\n`
: undefined,
};
}

export function getDeployCommand(): Command {
return new Base44Command("deploy")
.description("Deploy actors to Base44")
.argument("[names...]", "Actor names to deploy (deploys all if omitted)")
.action(deployActorsAction);
}
10 changes: 10 additions & 0 deletions packages/cli/src/cli/commands/actors/index.ts
Original file line number Diff line number Diff line change
@@ -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 realtime actors")
.addCommand(getDeployCommand())
.addCommand(getDeleteCommand());
}
33 changes: 30 additions & 3 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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"}`,
Expand Down Expand Up @@ -105,6 +117,7 @@ export async function deployAction(
// Deploy resources with per-function progress
let functionCompleted = 0;
const functionTotal = functions.length;
let actorCompleted = 0;

const result = await deployAll(projectData, {
onVisibilitySet: (level) => {
Expand All @@ -122,6 +135,20 @@ export async function deployAction(
functionCompleted++;
formatDeployResult(r, log);
},
onActorStart: (name) => {
log.step(
theme.styles.dim(
`[${actorCompleted + 1}/${actors.length}] Deploying ${name}...`,
),
);
},
onActorResult: (result) => {
actorCompleted++;
formatDeployResult(result, log);
if (result.status !== "error")
for (const warning of result.warnings)
log.warn(`${result.name}: ${warning}`);
},
});

// Handle connector-specific post-deploy flows
Expand All @@ -147,7 +174,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 all project resources (entities, functions, actors, agents, connectors, and site)",
)
.option("-y, --yes", "Skip confirmation prompt")
.option("--build", "Build the site before deploying (skips the prompt)")
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/cli/commands/types/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ const TYPES_FILE_PATH = "base44/.types/types.d.ts";
async function generateTypesAction({
runTask,
}: CLIContext): Promise<RunCommandResult> {
const { entities, functions, agents, connectors, project } =
const { entities, functions, actors, agents, connectors, project } =
await readProjectConfig();

await runTask("Generating types", async () => {
await generateTypesFile({
projectRoot: project.root,
entities,
functions,
actors,
agents,
connectors,
});
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli/dev/dev-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export async function createDevServer(

const devLogger = createDevLogger("backend", theme.styles.info);

app.use("/api/apps/:appId/actors", (_req, res) => {
const message = "Actors are not available in local development";
devLogger.error(message);
res.status(500).json({ error: message });
});

const functionManager = await createFunctionRuntime(
functions,
devLogger,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -99,6 +100,7 @@ export function createProgram(context: CLIContext): Command {

// Register functions commands
program.addCommand(getFunctionsCommand());
program.addCommand(getActorsCommand());

// Register workflows commands
program.addCommand(getWorkflowsCommand());
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,10 @@ export class InternalError extends SystemError {
}
}

export class ResourceDeploymentError extends SystemError {
readonly code = "RESOURCE_DEPLOYMENT_FAILED";
}

/**
* Thrown when type generation fails for an entity.
*/
Expand Down
Loading
Loading