diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 31ad0870..4c855bfe 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -53,7 +53,9 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - run: npm install -g npm@latest + # Pin npm@11: npm@latest is now 12.x (needs node >=22) and fails + # EBADENGINE on the node-20 runner (.node-version). npm 11 supports node ^20.17. + run: npm install -g npm@11 - name: Setup Bun id: setup-bun diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 276012f1..c5e0cc2b 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -22,7 +22,9 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - run: npm install -g npm@latest + # Pin npm@11: npm@latest is now 12.x (needs node >=22) and fails + # EBADENGINE on the node-20 runner (.node-version). npm 11 supports node ^20.17. + run: npm install -g npm@11 working-directory: . - name: Setup Bun diff --git a/packages/cli/src/cli/commands/actor/deploy.ts b/packages/cli/src/cli/commands/actor/deploy.ts new file mode 100644 index 00000000..5232f5cc --- /dev/null +++ b/packages/cli/src/cli/commands/actor/deploy.ts @@ -0,0 +1,113 @@ +import type { Logger } from "@base44-cli/logger"; +import type { Command } from "commander"; +import { CLIExitError } from "@/cli/errors.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import { + deployActorsSequentially, + type SingleActorDeployResult, +} from "@/core/resources/actor/deploy.js"; +import type { Actor } from "@/core/resources/actor/schema.js"; + +function parseNames(args: string[]): string[] { + return args + .flatMap((arg) => arg.split(",")) + .map((n) => n.trim()) + .filter(Boolean); +} + +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)); +} + +function formatDeployResult( + result: SingleActorDeployResult, + log: Logger, +): void { + const label = result.name.padEnd(25); + if (result.status === "deployed") { + const timing = result.durationMs + ? theme.styles.dim(` (${(result.durationMs / 1000).toFixed(1)}s)`) + : ""; + log.success(`${label} deployed${timing}`); + } else if (result.status === "unchanged") { + log.success(`${label} unchanged`); + } else { + log.error(`${label} error: ${result.error}`); + } +} + +function buildDeploySummary(results: SingleActorDeployResult[]): 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 actors deployed"; +} + +async function deployActorAction( + { log }: CLIContext, + names: string[], +): Promise { + const { actors } = await readProjectConfig(); + const toDeploy = resolveActorsToDeploy(names, actors); + + if (toDeploy.length === 0) { + return { + outroMessage: "No actors found. Create actors in the 'actors' 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)); + throw new CLIExitError(1); + } + + return { outroMessage: buildDeploySummary(results) }; +} + +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 deployActorAction(ctx, names); + }); +} diff --git a/packages/cli/src/cli/commands/actor/index.ts b/packages/cli/src/cli/commands/actor/index.ts new file mode 100644 index 00000000..6be9a149 --- /dev/null +++ b/packages/cli/src/cli/commands/actor/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getDeployCommand } from "./deploy.js"; +import { getNewCommand } from "./new.js"; + +export function getActorCommand(): Command { + return new Command("actor") + .description("Manage actors") + .addCommand(getNewCommand()) + .addCommand(getDeployCommand()); +} diff --git a/packages/cli/src/cli/commands/actor/new.ts b/packages/cli/src/cli/commands/actor/new.ts new file mode 100644 index 00000000..a5f4c659 --- /dev/null +++ b/packages/cli/src/cli/commands/actor/new.ts @@ -0,0 +1,62 @@ +import { dirname, join } from "node:path"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import { pathExists, writeFile } from "@/core/utils/fs.js"; + +function buildActorScaffold(actorName: string): string { + return `import { Actor, type Conn } from "@base44/sdk"; + +interface State { + // shared state broadcast to all clients +} + +interface Message { + // messages sent from clients +} + +export class ${actorName} extends Actor { + handleConnect(conn: Conn) { + console.log("Connected:", conn.userId); + } + handleMessage(conn: Conn, msg: Message) { + console.log("Message:", msg); + } + handleTick() {} + handleClose(conn: Conn) {} +} +`; +} + +async function newActorAction( + _ctx: CLIContext, + actorName: string, +): Promise { + const { project } = await readProjectConfig(); + const actorsDir = join(dirname(project.configPath), project.actorsDir); + const actorDir = join(actorsDir, actorName); + + if (await pathExists(actorDir)) { + throw new InvalidInputError( + `Actor "${actorName}" already exists at ${actorDir}`, + ); + } + + const entryPath = join(actorDir, "entry.ts"); + await writeFile(entryPath, buildActorScaffold(actorName)); + + return { + outroMessage: `Created actor "${actorName}" at ${entryPath}`, + }; +} + +export function getNewCommand(): Command { + return new Base44Command("new") + .description("Create a new actor scaffold") + .argument("", "Name of the actor class") + .action(async (ctx: CLIContext, actorName: string) => { + return newActorAction(ctx, actorName); + }); +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index ad07fa79..98f90281 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -45,8 +45,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[] = []; @@ -60,6 +67,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"}`, diff --git a/packages/cli/src/cli/commands/types/generate.ts b/packages/cli/src/cli/commands/types/generate.ts index f74545af..e06de2b4 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 cfa1a45b..f2d5bad5 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 { getActorCommand } from "@/cli/commands/actor/index.js"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getAuthCommand } from "@/cli/commands/auth/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; @@ -82,6 +83,9 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + // Register actor commands + program.addCommand(getActorCommand()); + // Register secrets commands program.addCommand(getSecretsCommand()); diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index bb3ff5c2..f4ef9f40 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -18,6 +18,7 @@ import { ProjectConfigSchema, } from "@/core/project/schema.js"; import type { ProjectData, ProjectRoot } from "@/core/project/types.js"; +import { actorResource } from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; import { connectorResource } from "@/core/resources/connector/index.js"; @@ -60,6 +61,7 @@ class ProjectConfigReader { project: { ...project, root, configPath }, entities, functions, + actors: localResources.actors, agents: localResources.agents, connectors: localResources.connectors, authConfig: localResources.authConfig, @@ -105,16 +107,24 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [entities, functions, agents, connectors, authConfig] = + const [entities, functions, actors, agents, 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)), connectorResource.readAll(join(configDir, project.connectorsDir)), authConfigResource.readAll(join(configDir, project.authDir)), ]); - return { entities, functions, agents, connectors, authConfig }; + return { + entities, + functions, + actors, + agents, + connectors, + authConfig, + }; } private assertPluginProjectDoesNotLoadPlugins( @@ -184,6 +194,7 @@ class ProjectConfigReader { return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), + actors: [], agents: [], connectors: [], authConfig: [], @@ -240,6 +251,7 @@ class ProjectConfigReader { return { entities, functions, + actors: [], agents: [], connectors: [], authConfig: [], diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 68089173..3ba875eb 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import type { ProjectData } from "@/core/project/types.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; import { @@ -20,11 +21,19 @@ import { deploySite } from "@/core/site/index.js"; * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + actors, + agents, + connectors, + authConfig, + } = projectData; 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 hasConnectors = connectors.length > 0; const hasAuthConfig = authConfig.length > 0; @@ -32,6 +41,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || + hasActors || hasAgents || hasConnectors || hasAuthConfig || @@ -69,14 +79,22 @@ export async function deployAll( projectData: ProjectData, options?: DeployAllOptions, ): Promise { - const { project, entities, functions, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + actors, + agents, + connectors, + authConfig, + } = projectData; await entityResource.push(entities); await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); + await deployActorsSequentially(actors); await agentResource.push(agents); await authConfigResource.push(authConfig); const { results: connectorResults } = await pushConnectors(connectors); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 22938a99..24cc5364 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -45,6 +45,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"), connectorsDir: z.string().optional().default("connectors"), authDir: z.string().optional().default("auth"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index 7b581b3c..9ad7a2e2 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 { AuthConfig } from "@/core/resources/auth-config/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; @@ -19,6 +20,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; + actors: Actor[]; agents: AgentConfig[]; connectors: ConnectorResource[]; authConfig: AuthConfig[]; 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 00000000..83e3d760 --- /dev/null +++ b/packages/cli/src/core/resources/actor/api.ts @@ -0,0 +1,32 @@ +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; +} 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 00000000..74628368 --- /dev/null +++ b/packages/cli/src/core/resources/actor/config.ts @@ -0,0 +1,85 @@ +import { basename, dirname, join, relative } from "node:path"; +import { globby } from "globby"; +import { ENTRY_FILE_GLOB, ENTRY_IGNORE_DOT_PATHS } from "@/core/consts.js"; +import { InvalidInputError } from "@/core/errors.js"; +import type { + Actor, + ActorMessageSchema, +} from "@/core/resources/actor/schema.js"; +import { ActorSchemaFileSchema } from "@/core/resources/actor/schema.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; + +async function readActor(entryFile: string, actorsDir: string): Promise { + const actorDir = dirname(entryFile); + const filePaths = await globby("**/*.ts", { + cwd: actorDir, + absolute: true, + }); + + const name = relative(actorsDir, actorDir).split(/[/\\]/).join("/"); + if (!name) { + throw new InvalidInputError( + "entry.ts 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)`, + }, + ], + }, + ); + } + + const entry = basename(entryFile); + + const schemaPath = join(actorDir, "schema.jsonc"); + let messageSchema: ActorMessageSchema | undefined; + if (await pathExists(schemaPath)) { + const parsed = await readJsonFile(schemaPath); + const result = ActorSchemaFileSchema.safeParse(parsed); + if (result.success) { + messageSchema = { + types: result.data.types as Record | undefined, + toClient: result.data.toClient as Record | undefined, + toServer: result.data.toServer as Record | undefined, + }; + } + } + + return { + name, + entry, + entryPath: entryFile, + filePaths, + source: { type: "project" }, + messageSchema, + }; +} + +export async function readAllActors(actorsDir: string): Promise { + if (!(await pathExists(actorsDir))) { + return []; + } + + 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 InvalidInputError( + `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 00000000..0e02365c --- /dev/null +++ b/packages/cli/src/core/resources/actor/deploy.ts @@ -0,0 +1,67 @@ +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 { 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 interface SingleActorDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + durationMs?: number; +} + +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), + }; + } +} + +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 00000000..90b197a7 --- /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 00000000..60d5a88d --- /dev/null +++ b/packages/cli/src/core/resources/actor/resource.ts @@ -0,0 +1,9 @@ +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 } from "@/core/resources/types.js"; + +export const actorResource: Resource = { + readAll: readAllActors, + push: (actors) => deployActorsSequentially(actors), +}; 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 00000000..9dd490e6 --- /dev/null +++ b/packages/cli/src/core/resources/actor/schema.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { ResourceSourceSchema } from "@/core/resources/types.js"; + +const ActorConfigSchema = z.object({ + name: z.string().min(1), + entry: z.string().min(1), +}); + +// An actor's schema.jsonc is a catalog of named messages: `toClient` (server → +// client) and `toServer` (client → server) each map a message name to its (type-less) +// object schema, and optional `types` holds shared shapes referenced via +// `#/types/`. See the type generator. +export const ActorSchemaFileSchema = z.object({ + types: z.record(z.string(), z.unknown()).optional(), + toClient: z.record(z.string(), z.unknown()).optional(), + toServer: z.record(z.string(), z.unknown()).optional(), +}); + +export const DeployActorResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), + handler_name: z.string().optional(), +}); + +const ActorSchema = ActorConfigSchema.extend({ + entryPath: z.string().min(1), + filePaths: z.array(z.string()).min(1), + source: ResourceSourceSchema, + messageSchema: z.unknown().optional(), +}); + +export interface ActorMessageSchema { + types?: Record; + toClient?: Record; + toServer?: Record; +} + +export type Actor = Omit, "messageSchema"> & { + messageSchema?: ActorMessageSchema; +}; +export type DeployActorResponse = z.infer; diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 6297f9c1..880cf4fc 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -1,13 +1,15 @@ +import { join } from "node:path"; import { source, stripIndent } from "common-tags"; 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/schema.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"; import type { BackendFunction } from "@/core/resources/function/index.js"; -import { writeFile } from "@/core/utils/fs.js"; +import { readJsonFile, writeFile } from "@/core/utils/fs.js"; interface GenerateTypesInput { projectRoot: string; @@ -15,6 +17,7 @@ interface GenerateTypesInput { functions: BackendFunction[]; agents: AgentConfig[]; connectors: ConnectorResource[]; + actors: Actor[]; } const HEADER = stripIndent` @@ -26,8 +29,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' { @@ -35,6 +38,29 @@ const EMPTY_TEMPLATE = stripIndent` } `; +const SDK_PACKAGE_NAMES = ["@base44/sdk", "@base44-preview/sdk"] as const; +type SdkPackageName = (typeof SDK_PACKAGE_NAMES)[number]; + +async function detectSdkPackageName( + projectRoot: string, +): Promise { + try { + const pkg = (await readJsonFile( + join(projectRoot, "package.json"), + )) as Record; + const deps = { + ...(pkg.dependencies as object), + ...(pkg.devDependencies as object), + }; + for (const name of SDK_PACKAGE_NAMES) { + if (name in deps) return name; + } + } catch { + // ignore + } + return "@base44/sdk"; +} + /** * Generate and write types.d.ts file. */ @@ -45,21 +71,26 @@ export async function generateTypesFile( await writeFile(getTypesOutputPath(input.projectRoot), content); } -async function generateContent(input: GenerateTypesInput): Promise { - const { entities, functions, agents, connectors } = input; +export async function generateContent( + input: GenerateTypesInput, +): Promise { + const { entities, functions, agents, connectors, actors } = input; + const sdkPackage = await detectSdkPackageName(input.projectRoot); if ( !entities.length && !functions.length && !agents.length && - !connectors.length + !connectors.length && + !actors.length ) { return EMPTY_TEMPLATE; } - const entityInterfaces = await Promise.all( - entities.map((e) => compileEntity(e)), - ); + const [entityInterfaces, actorResults] = await Promise.all([ + Promise.all(entities.map((e) => compileEntity(e))), + Promise.all(actors.map((a) => compileActor(a))), + ]); // Build registry entries const registryEntries: [string, string[]][] = [ @@ -70,6 +101,16 @@ 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;`)], + [ + "ActorRegistry", + actors + .filter((a) => a.messageSchema) + .map((a) => { + const idx = actors.indexOf(a); + return `"${a.name}": ${actorResults[idx].entry};`; + }), + ], ]; // Generate registries (only for non-empty entries) @@ -77,11 +118,15 @@ async function generateContent(input: GenerateTypesInput): Promise { .filter(([, entries]) => entries.length > 0) .map(([name, entries]) => registry(name, entries)); + const actorInterfaces = actorResults.map((r) => r.decls).filter(Boolean); + return [ HEADER, + "export {};", // module context — ensures declare module augments rather than replaces the SDK package entityInterfaces.join("\n\n"), + actorInterfaces.join("\n\n"), source` - declare module '@base44/sdk' { + declare module '${sdkPackage}' { ${registries.join("\n\n")} } `, @@ -115,6 +160,150 @@ async function compileEntity(entity: Entity): Promise { } } +interface ActorCompileResult { + /** Top-level `export` declarations: one interface per message + shared types. */ + decls: string; + /** The registry value, e.g. `{ toClient: FooInit | FooTick; toServer: FooJoin }`. */ + entry: string; +} + +/** + * An actor's `schema.jsonc` is a *catalog* of named messages: + * { types?: { Pt, Snake, … }, toClient: { init, tick, … }, toServer: { join, … } } + * Each message is a flat object schema (no `type` field — the generator injects + * `type: ""` as the discriminant). We compile the whole catalog in ONE pass + * so json-schema-to-typescript emits a named interface per message plus the shared + * types, then assemble the toClient/toServer unions from those names. This avoids + * scraping the compiler output (the old regex broke on unions and `$defs`), and + * because every message is a single flat object, the fragile multi-declaration + * case never arises. + */ +async function compileActor(actor: Actor): Promise { + const { messageSchema } = actor; + if (!messageSchema) { + return { decls: "", entry: "{ toClient: unknown; toServer: unknown }" }; + } + + const prefix = toPascalCase(actor.name); + const types = (messageSchema.types ?? {}) as Record; + const toClient = (messageSchema.toClient ?? {}) as Record< + string, + JSONSchema4 + >; + const toServer = (messageSchema.toServer ?? {}) as Record< + string, + JSONSchema4 + >; + + // Shared types are prefixed with the actor name so names (Pt, Snake, …) can't + // collide across actors or with entity interfaces. Messages additionally carry + // their direction, since the same name (e.g. "message") may appear in both + // directions. + const typeName = (key: string) => `${prefix}${toPascalCase(key)}`; + const msgName = (dir: "ToClient" | "ToServer", key: string) => + `${prefix}${dir}${toPascalCase(key)}`; + + const defs: Record = {}; + const add = (name: string, schema: JSONSchema4) => { + if (name in defs) { + throw new TypeGenerationError( + `Duplicate generated type "${name}" in actor "${actor.name}" — a shared type and a message resolve to the same name.`, + actor.name, + ); + } + defs[name] = { ...schema, title: name }; + }; + + // Shared types are emitted as-is (author writes `type: "object"` etc.); their + // author-facing `#/types/X` refs are rewritten to the prefixed `#/$defs/` names. + for (const [key, schema] of Object.entries(types)) { + add(typeName(key), rewriteTypeRefs(schema, typeName) as JSONSchema4); + } + + // Each message → one flat object (a full JSON Schema, like an entity) with the + // `type` discriminant injected from its key. + const compileMessages = ( + msgs: Record, + dir: "ToClient" | "ToServer", + ): string[] => + Object.entries(msgs).map(([key, schema]) => { + const name = msgName(dir, key); + const rewritten = rewriteTypeRefs(schema, typeName) as JSONSchema4; + add(name, { + type: "object", + ...rewritten, + properties: { type: { const: key }, ...(rewritten.properties ?? {}) }, + required: [ + "type", + ...((rewritten.required as string[] | undefined) ?? []), + ], + additionalProperties: false, + }); + return name; + }); + + const toClientNames = compileMessages(toClient, "ToClient"); + const toServerNames = compileMessages(toServer, "ToServer"); + + // Root union over every message keeps all defs reachable so the compiler emits + // them; we keep its whole output verbatim (no scraping). + const allNames = [...toClientNames, ...toServerNames]; + const rootName = `${prefix}Message`; + const rootSchema = { + title: rootName, + $defs: defs, + oneOf: allNames.map((n) => ({ $ref: `#/$defs/${n}` })), + } as unknown as JSONSchema4; + + let decls = ""; + try { + decls = ( + await compile(rootSchema, rootName, { + bannerComment: "", + additionalProperties: false, + strictIndexSignatures: true, + }) + ).trim(); + } catch (error) { + throw new TypeGenerationError( + `Failed to generate types for actor "${actor.name}"`, + actor.name, + error, + ); + } + + const union = (names: string[]) => + names.length ? names.join(" | ") : "never"; + return { + decls, + entry: `{ toClient: ${union(toClientNames)}; toServer: ${union(toServerNames)} }`, + }; +} + +/** Rewrite author-facing `#/types/X` refs to the prefixed `#/$defs/`. */ +function rewriteTypeRefs( + node: unknown, + defName: (key: string) => string, +): unknown { + if (Array.isArray(node)) { + return node.map((n) => rewriteTypeRefs(n, defName)); + } + if (node && typeof node === "object") { + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + const match = + key === "$ref" && typeof value === "string" + ? value.match(/^#\/types\/(.+)$/) + : null; + out[key] = match + ? `#/$defs/${defName(match[1])}` + : rewriteTypeRefs(value, defName); + } + return out; + } + return node; +} + function registry(name: string, entries: string[]): string { return source` interface ${name} { diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index 9232d9e1..b6bd77f3 100644 --- a/packages/cli/tests/cli/types_generate.spec.ts +++ b/packages/cli/tests/cli/types_generate.spec.ts @@ -45,6 +45,14 @@ 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`); + + // Contains the ActorRegistry with typed inbound/outbound (from schema.jsonc) + expect(typesContent).toContain("ActorRegistry"); + expect(typesContent).toContain(`"ChatRoom"`); }); it("updates tsconfig.json to include types path", async () => { @@ -103,7 +111,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/types-actor.spec.ts b/packages/cli/tests/core/types-actor.spec.ts new file mode 100644 index 00000000..1edc0d8f --- /dev/null +++ b/packages/cli/tests/core/types-actor.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { Actor } from "@/core/resources/actor/schema.js"; +import { generateContent } from "@/core/types/generator.js"; + +const EMPTY = { + projectRoot: "/tmp/does-not-matter", // only read for package.json detect; falls back to @base44/sdk + entities: [], + functions: [], + agents: [], + connectors: [], +}; + +function actor(messageSchema: Actor["messageSchema"]): Actor { + return { + name: "GameRoom", + entry: "entry.ts", + entryPath: "base44/actors/GameRoom/entry.ts", + filePaths: ["base44/actors/GameRoom/entry.ts"], + source: { type: "project" }, + messageSchema, + }; +} + +describe("actor type generation", () => { + it("compiles a named-message catalog into a discriminated union with shared types", async () => { + const out = await generateContent({ + ...EMPTY, + actors: [ + actor({ + types: { + Pt: { + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + additionalProperties: false, + }, + }, + toClient: { + init: { + properties: { + food: { type: "array", items: { $ref: "#/types/Pt" } }, + }, + required: ["food"], + }, + died: { + properties: { id: { type: "string" }, score: { type: "number" } }, + required: ["id", "score"], + }, + }, + toServer: { + dir: { + properties: { angle: { type: "number" } }, + required: ["angle"], + }, + }, + }), + ], + }); + + // `type` discriminant is injected from the message key (author omits it). + expect(out).toContain('type: "init"'); + expect(out).toContain('type: "died"'); + expect(out).toContain('type: "dir"'); + // Shared type is emitted once, prefixed with the handler name (collision-safe), + // and referenced by name — not re-inlined. + expect(out).toContain("export interface GameRoomPt"); + expect(out).toContain("food: GameRoomPt[]"); + // Message interfaces carry their direction (so the same name can appear in both + // directions); the registry composes the unions from them. + expect(out).toContain( + '"GameRoom": { toClient: GameRoomToClientInit | GameRoomToClientDied; toServer: GameRoomToServerDir }', + ); + // Output is valid TS: no `export interface` spliced inside a type literal + // (the failure mode of the old regex-based extraction). + expect(out).not.toMatch(/\{[^}]*export interface/); + }); + + it("throws on a name collision instead of silently clobbering", async () => { + await expect( + generateContent({ + ...EMPTY, + actors: [ + actor({ + // Both keys PascalCase to the same GameRoomToClientUserJoined. + toClient: { + "user-joined": { properties: { a: { type: "string" } } }, + userJoined: { properties: { b: { type: "string" } } }, + }, + toServer: {}, + }), + ], + }), + ).rejects.toThrow(/Duplicate generated type "GameRoomToClientUserJoined"/); + }); +}); 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 00000000..db5ad716 --- /dev/null +++ b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/entry.ts @@ -0,0 +1,8 @@ +import { Actor, type Conn } from "@base44/sdk"; + +export class ChatRoom extends Actor { + handleConnect(_conn: Conn) {} + handleMessage(_conn: Conn, _msg: unknown) {} + handleTick() {} + handleClose(_conn: Conn) {} +} diff --git a/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc new file mode 100644 index 00000000..4696dd16 --- /dev/null +++ b/packages/cli/tests/fixtures/with-types-resources/base44/actors/ChatRoom/schema.jsonc @@ -0,0 +1,28 @@ +{ + // Message catalog: each entry is a full JSON Schema (like an entity), keyed by + // message name. The generator injects `type: ""` as the discriminant. + "toClient": { + "joined": { + "type": "object", + "properties": { "userId": { "type": "string" } }, + "required": ["userId"] + }, + "left": { + "type": "object", + "properties": { "userId": { "type": "string" } }, + "required": ["userId"] + }, + "message": { + "type": "object", + "properties": { "from": { "type": "string" }, "text": { "type": "string" } }, + "required": ["from", "text"] + } + }, + "toServer": { + "message": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"] + } + } +}