diff --git a/packages/cli/src/cli/commands/project/create.ts b/packages/cli/src/cli/commands/project/create.ts index 83643fbde..05a09201d 100644 --- a/packages/cli/src/cli/commands/project/create.ts +++ b/packages/cli/src/cli/commands/project/create.ts @@ -1,7 +1,7 @@ import { basename, resolve } from "node:path"; import type { Option } from "@clack/prompts"; import { group, select, text } from "@clack/prompts"; -import { Argument, type Command } from "commander"; +import { Argument, type Command, Option as CommanderOption } from "commander"; import kebabCase from "lodash/kebabCase"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { @@ -25,6 +25,7 @@ import { getTemplateById, printProjectSummary, } from "./scaffold-shared.js"; +import { resolveWorkspaceId } from "./workspace-select.js"; interface CreateOptions { name?: string; @@ -32,6 +33,8 @@ interface CreateOptions { template?: string; deploy?: boolean; skills?: boolean; + workspace?: string; + org?: string; } function validateCreateOptions(command: Command): void { @@ -107,6 +110,7 @@ async function createInteractive( projectPath: result.projectPath as string, deploy: options.deploy, skills: options.skills, + workspaceId: options.workspace ?? options.org, isInteractive: true, }, ctx, @@ -130,6 +134,7 @@ async function createNonInteractive( projectPath: options.path!, deploy: options.deploy, skills: options.skills, + workspaceId: options.workspace ?? options.org, isInteractive: false, }, ctx, @@ -144,6 +149,7 @@ async function executeCreate( projectPath, deploy, skills, + workspaceId: flagWorkspaceId, isInteractive, }: { template: Template; @@ -152,6 +158,7 @@ async function executeCreate( projectPath: string; deploy?: boolean; skills?: boolean; + workspaceId?: string; isInteractive: boolean; }, ctx: CLIContext, @@ -160,6 +167,12 @@ async function executeCreate( const name = rawName.trim(); const resolvedPath = resolve(projectPath); + const organizationId = await resolveWorkspaceId( + ctx, + flagWorkspaceId, + isInteractive, + ); + const { projectId } = await runTask( "Setting up your project...", async () => { @@ -168,6 +181,7 @@ async function executeCreate( description: description?.trim(), path: resolvedPath, template, + organizationId, }); }, { @@ -221,27 +235,38 @@ async function createAction( } export function getCreateCommand(): Command { - return new Base44Command("create", { - requireAppContext: false, - fullBanner: true, - }) - .description("Create a new Base44 project") - .addArgument(new Argument("name", "Project name").argOptional()) - .option("-p, --path ", "Path where to create the project") - .option( - "-t, --template ", - "Template ID (e.g., backend-only, backend-and-client)", - ) - .option("--deploy", "Build and deploy the site") - .option("--no-skills", "Skip AI agent skills installation") - .addHelpText( - "after", - ` + return ( + new Base44Command("create", { + requireAppContext: false, + fullBanner: true, + }) + .description("Create a new Base44 project") + .addArgument(new Argument("name", "Project name").argOptional()) + .option("-p, --path ", "Path where to create the project") + .option( + "-t, --template ", + "Template ID (e.g., backend-only, backend-and-client)", + ) + .option("--deploy", "Build and deploy the site") + .option("--no-skills", "Skip AI agent skills installation") + .option( + "-w, --workspace ", + "Workspace (organization) ID to create the app in (defaults to your personal workspace)", + ) + // Hidden alias for --workspace. + .addOption( + new CommanderOption("--org ", "Alias for --workspace").hideHelp(), + ) + .addHelpText( + "after", + ` Examples: $ base44 create my-app Creates a base44 project at ./my-app $ base44 create my-todo-app --template backend-and-client Creates a base44 backend-and-client project at ./my-todo-app - $ base44 create my-app --path ./projects/my-app --deploy Creates a base44 project at ./project/my-app and deploys it`, - ) - .hook("preAction", validateCreateOptions) - .action(createAction); + $ base44 create my-app --path ./projects/my-app --deploy Creates a base44 project at ./project/my-app and deploys it + $ base44 create my-app --workspace 507f1f77bcf86cd799439011 Creates a base44 project in the given workspace`, + ) + .hook("preAction", validateCreateOptions) + .action(createAction) + ); } diff --git a/packages/cli/src/cli/commands/project/link.ts b/packages/cli/src/cli/commands/project/link.ts index c34e155d2..ecf7594d6 100644 --- a/packages/cli/src/cli/commands/project/link.ts +++ b/packages/cli/src/cli/commands/project/link.ts @@ -24,11 +24,14 @@ import { writeAppConfig, } from "@/core/project/index.js"; import { readExplicitAppId } from "./app-id-options.js"; +import { resolveWorkspaceId } from "./workspace-select.js"; interface LinkOptions { create?: boolean; name?: string; description?: string; + workspace?: string; + org?: string; } type LinkAction = "create" | "choose"; @@ -242,10 +245,16 @@ async function link( ? { name: options.name!.trim(), description: options.description?.trim() } : await promptForNewProjectDetails(); + const organizationId = await resolveWorkspaceId( + ctx, + options.workspace ?? options.org, + !isNonInteractive, + ); + const { projectId } = await runTask( "Creating project on Base44...", async () => { - return await createProject(name, description); + return await createProject(name, description, organizationId); }, { successMessage: "Project created successfully", @@ -282,6 +291,13 @@ export function getLinkCommand(): Command { "Project name (required when --create is used)", ) .option("-d, --description ", "Project description") + .option( + "-w, --workspace ", + "Workspace (organization) ID to create the app in when using --create (defaults to your personal workspace)", + ) + .addOption( + new CommanderOption("--org ", "Alias for --workspace").hideHelp(), + ) // TODO: Remove legacy --project-id aliases once docs and Base44 CLI skills use --app-id. .addOption( new CommanderOption( diff --git a/packages/cli/src/cli/commands/project/workspace-select.ts b/packages/cli/src/cli/commands/project/workspace-select.ts new file mode 100644 index 000000000..2997f4058 --- /dev/null +++ b/packages/cli/src/cli/commands/project/workspace-select.ts @@ -0,0 +1,66 @@ +import type { Option as PromptOption } from "@clack/prompts"; +import { isCancel, select } from "@clack/prompts"; +import type { CLIContext } from "@/cli/types.js"; +import { onPromptCancel } from "@/cli/utils/index.js"; +import { listWorkspaces, type WorkspaceListEntry } from "@/core/index.js"; + +function fetchWorkspaces(ctx: CLIContext): Promise { + return ctx.runTask("Fetching workspaces...", () => listWorkspaces(), { + successMessage: "Workspaces fetched", + errorMessage: "Failed to fetch workspaces", + }); +} + +function workspaceLabel(workspace: WorkspaceListEntry): string { + const suffix = workspace.isPersonal + ? "personal" + : (workspace.userRole ?? "member"); + return `${workspace.name} (${suffix})`; +} + +/** + * Resolve the workspace a new app should belong to. + * + * - `--workspace ` set: pass it straight through — the server authorizes + * creation in that workspace and returns a clear error if you can't. + * - interactive with more than one workspace: prompt to pick (no role filter; + * personal first). The server rejects a workspace you can't create in. + * - otherwise: return `undefined` so the server defaults to the personal + * workspace (no extra API call in the common single-workspace case). + */ +export async function resolveWorkspaceId( + ctx: CLIContext, + flagWorkspaceId: string | undefined, + isInteractive: boolean, +): Promise { + if (flagWorkspaceId) { + return flagWorkspaceId; + } + + if (!isInteractive) { + return undefined; + } + + const workspaces = await fetchWorkspaces(ctx); + if (workspaces.length <= 1) { + // Only the personal workspace — nothing to choose. + return undefined; + } + + const options: PromptOption[] = workspaces.map((w) => ({ + value: w.id, + label: workspaceLabel(w), + })); + + const selected = await select({ + message: "Which workspace should this app belong to?", + options, + initialValue: workspaces[0].id, + }); + + if (isCancel(selected)) { + onPromptCancel(); + } + + return selected as string; +} diff --git a/packages/cli/src/cli/commands/sandbox/shared.ts b/packages/cli/src/cli/commands/sandbox/shared.ts index af2334ffa..baaaf2dd3 100644 --- a/packages/cli/src/cli/commands/sandbox/shared.ts +++ b/packages/cli/src/cli/commands/sandbox/shared.ts @@ -1,6 +1,10 @@ import { readStdin } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; +// Re-exported from the shared util so both sandbox and workspace commands use +// one implementation of the `--json` serializer. +export { toJsonStdout } from "@/cli/utils/index.js"; + /** * Resolve a payload that may come from a flag or piped stdin. * Returns the flag value when set, otherwise reads stdin (without trimming, so @@ -21,13 +25,6 @@ export async function resolveFlagOrStdin( return readStdin(flagName, { trim: false }); } -/** - * Serialize a tool result as pretty JSON for stdout (JSON-first output). - */ -export function toJsonStdout(result: unknown): string { - return `${JSON.stringify(result, null, 2)}\n`; -} - /** * Parse a CLI option string as a positive integer, or return undefined when * the option was not provided. Throws InvalidInputError on a malformed value. diff --git a/packages/cli/src/cli/commands/workspace/get.ts b/packages/cli/src/cli/commands/workspace/get.ts new file mode 100644 index 000000000..8a551bc24 --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/get.ts @@ -0,0 +1,52 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme, toJsonStdout } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { getWorkspace } from "@/core/index.js"; +import { workspaceTag } from "./shared.js"; + +async function getWorkspaceAction( + { runTask, log, jsonMode }: CLIContext, + workspaceId: string, +): Promise { + const workspace = await runTask( + "Fetching workspace...", + () => getWorkspace(workspaceId), + { errorMessage: "Failed to fetch workspace" }, + ); + + if (!workspace) { + throw new InvalidInputError( + `Workspace "${workspaceId}" not found, or you are not a member of it.`, + { + hints: [ + { message: "Run 'base44 workspace list' to see your workspaces" }, + ], + }, + ); + } + + if (jsonMode) { + return { + outroMessage: workspace.name, + stdout: toJsonStdout(workspace), + }; + } + + log.message( + ` ${theme.styles.bold(workspace.name)} ${theme.styles.dim(`[${workspaceTag(workspace)}]`)}\n ${theme.styles.dim(workspace.id)}${ + workspace.subscriptionTier + ? theme.styles.dim(`\n tier: ${workspace.subscriptionTier}`) + : "" + }`, + ); + + return { outroMessage: workspace.name }; +} + +export function getWorkspaceGetCommand(): Command { + return new Base44Command("get", { requireAppContext: false }) + .description("Show details for a single workspace by ID") + .argument("", "Workspace (organization) ID") + .action(getWorkspaceAction); +} diff --git a/packages/cli/src/cli/commands/workspace/index.ts b/packages/cli/src/cli/commands/workspace/index.ts new file mode 100644 index 000000000..d688a8884 --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/index.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { getWorkspaceGetCommand } from "./get.js"; +import { getWorkspaceListCommand } from "./list.js"; +import { getWorkspaceMoveCommand } from "./move.js"; + +export function getWorkspaceCommand(): Command { + return new Command("workspace") + .description("List workspaces, inspect one, and move apps between them") + .addCommand(getWorkspaceListCommand()) + .addCommand(getWorkspaceGetCommand()) + .addCommand(getWorkspaceMoveCommand()); +} diff --git a/packages/cli/src/cli/commands/workspace/list.ts b/packages/cli/src/cli/commands/workspace/list.ts new file mode 100644 index 000000000..2fe178a6a --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/list.ts @@ -0,0 +1,49 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme, toJsonStdout } from "@/cli/utils/index.js"; +import { listWorkspaces } from "@/core/index.js"; +import { workspaceTag } from "./shared.js"; + +interface ListOptions { + role?: string; +} + +async function listWorkspacesAction( + { log, runTask, jsonMode }: CLIContext, + options: ListOptions, +): Promise { + let workspaces = await runTask( + "Fetching workspaces...", + () => listWorkspaces(), + { errorMessage: "Failed to fetch workspaces" }, + ); + + if (options.role) { + const role = options.role.toLowerCase(); + workspaces = workspaces.filter((w) => w.userRole?.toLowerCase() === role); + } + + const summary = `${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}`; + + if (jsonMode) { + return { outroMessage: summary, stdout: toJsonStdout(workspaces) }; + } + + for (const workspace of workspaces) { + log.message( + ` ${theme.styles.bold(workspace.name)} ${theme.styles.dim(`[${workspaceTag(workspace)}]`)}\n ${theme.styles.dim(workspace.id)}`, + ); + } + + return { outroMessage: summary }; +} + +export function getWorkspaceListCommand(): Command { + return new Base44Command("list", { requireAppContext: false }) + .description("List the workspaces you belong to") + .option( + "--role ", + "Only workspaces where your role matches (owner, admin, editor, viewer)", + ) + .action(listWorkspacesAction); +} diff --git a/packages/cli/src/cli/commands/workspace/move.ts b/packages/cli/src/cli/commands/workspace/move.ts new file mode 100644 index 000000000..f9760f605 --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/move.ts @@ -0,0 +1,170 @@ +import type { Option as PromptOption } from "@clack/prompts"; +import { confirm, isCancel, select } from "@clack/prompts"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { + Base44Command, + onPromptCancel, + theme, + toJsonStdout, +} from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { + getApp, + getAppContext, + listWorkspaces, + moveAppToWorkspace, +} from "@/core/index.js"; + +interface MoveOptions { + disconnectIntegrations?: boolean; + yes?: boolean; +} + +interface TargetSelection { + targetWorkspaceId: string; + fromName: string; + toName: string; +} + +/** + * Interactive target selection: pick from every workspace you belong to except + * the app's current one. No role filtering — the server authorizes the move and + * returns a clear reason if it's not allowed (matches the web builder). Only + * runs in interactive mode, so the workspace/app fetch happens only when needed. + */ +async function promptForTargetWorkspace( + ctx: CLIContext, + appId: string, +): Promise { + const { workspaces, currentWorkspaceId } = await ctx.runTask( + "Fetching workspaces...", + async () => { + const [workspaces, app] = await Promise.all([ + listWorkspaces(), + getApp(appId), + ]); + return { workspaces, currentWorkspaceId: app.organizationId }; + }, + { errorMessage: "Failed to fetch workspaces" }, + ); + + const destinations = workspaces.filter((w) => w.id !== currentWorkspaceId); + if (destinations.length === 0) { + throw new InvalidInputError( + "No other workspaces available to move this app into.", + ); + } + + const options: PromptOption[] = destinations.map((w) => ({ + value: w.id, + label: `${w.name} (${w.userRole ?? "member"})`, + })); + const selected = await select({ + message: "Move the app to which workspace?", + options, + }); + if (isCancel(selected)) { + onPromptCancel(); + } + const targetWorkspaceId = selected as string; + + const nameOf = (id: string | undefined): string => + workspaces.find((w) => w.id === id)?.name ?? id ?? "unknown workspace"; + return { + targetWorkspaceId, + fromName: nameOf(currentWorkspaceId), + toName: nameOf(targetWorkspaceId), + }; +} + +async function moveAction( + ctx: CLIContext, + target: string | undefined, + options: MoveOptions, +): Promise { + const { runTask, isNonInteractive, jsonMode } = ctx; + const isInteractive = !isNonInteractive && !jsonMode; + const { id: appId } = getAppContext(); + + let targetWorkspaceId: string; + let toLabel: string; + + if (target) { + // Explicit target: don't pre-validate — the server authorizes the move and + // surfaces any block reason (e.g. "Only workspace admins and owners can + // move apps out of this workspace"). + targetWorkspaceId = target; + toLabel = target; + if (isInteractive && !options.yes) { + const proceed = await confirm({ + message: `Move this app to workspace ${theme.styles.bold(target)}?`, + }); + if (isCancel(proceed)) { + onPromptCancel(); + } + if (!proceed) { + return { outroMessage: "Move cancelled" }; + } + } + } else if (isInteractive) { + const picked = await promptForTargetWorkspace(ctx, appId); + targetWorkspaceId = picked.targetWorkspaceId; + toLabel = picked.toName; + if (!options.yes) { + const proceed = await confirm({ + message: `Move this app from ${theme.styles.bold(picked.fromName)} to ${theme.styles.bold(picked.toName)}?`, + }); + if (isCancel(proceed)) { + onPromptCancel(); + } + if (!proceed) { + return { outroMessage: "Move cancelled" }; + } + } + } else { + throw new InvalidInputError( + "A target workspace ID is required in non-interactive mode.", + { + hints: [ + { message: "Usage: base44 workspace move " }, + { message: "Run 'base44 workspace list' to see your workspaces" }, + ], + }, + ); + } + + const result = await runTask( + "Moving app to workspace...", + () => + moveAppToWorkspace(appId, targetWorkspaceId, { + disconnectIntegrations: options.disconnectIntegrations, + }), + { errorMessage: "Failed to move app" }, + ); + + if (jsonMode) { + return { outroMessage: "App moved", stdout: toJsonStdout(result) }; + } + + return { outroMessage: `App moved to ${theme.styles.bold(toLabel)}` }; +} + +export function getWorkspaceMoveCommand(): Command { + return new Base44Command("move") + .description("Move the current app to another workspace") + .argument("[workspace-id]", "Target workspace (organization) ID") + .option( + "--disconnect-integrations", + "Disconnect the app's OAuth integrations as part of the move", + ) + .option("-y, --yes", "Skip the confirmation prompt") + .addHelpText( + "after", + ` +Examples: + $ base44 workspace move 507f1f77bcf86cd799439011 Move the linked app to the given workspace + $ base44 workspace move --app-id Move a specific app`, + ) + .action(moveAction); +} diff --git a/packages/cli/src/cli/commands/workspace/shared.ts b/packages/cli/src/cli/commands/workspace/shared.ts new file mode 100644 index 000000000..b25339c73 --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/shared.ts @@ -0,0 +1,7 @@ +import type { WorkspaceListEntry } from "@/core/index.js"; + +/** Short human-readable role/personal tag for a workspace, e.g. "personal, owner". */ +export function workspaceTag(workspace: WorkspaceListEntry): string { + const role = workspace.userRole ?? "member"; + return workspace.isPersonal ? `personal, ${role}` : role; +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 8f121d30b..49d07be43 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -18,6 +18,7 @@ import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; +import { getWorkspaceCommand } from "@/cli/commands/workspace/index.js"; import { Base44Command } from "@/cli/utils/index.js"; import { BASE44_APP_ID_ENV_VAR } from "@/core/consts.js"; import packageJson from "../../package.json"; @@ -72,6 +73,9 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getLinkCommand()); program.addCommand(getEjectCommand()); + // Register workspace commands + program.addCommand(getWorkspaceCommand()); + // Register entities commands program.addCommand(getEntitiesPushCommand()); diff --git a/packages/cli/src/cli/utils/index.ts b/packages/cli/src/cli/utils/index.ts index 6b04c08c5..3cfa787aa 100644 --- a/packages/cli/src/cli/utils/index.ts +++ b/packages/cli/src/cli/utils/index.ts @@ -1,6 +1,7 @@ export * from "@base44-cli/logger"; export * from "./banner.js"; export * from "./command/index.js"; +export * from "./json.js"; export * from "./prompts.js"; export * from "./runTask.js"; export * from "./secret-input.js"; diff --git a/packages/cli/src/cli/utils/json.ts b/packages/cli/src/cli/utils/json.ts new file mode 100644 index 000000000..554d88d5b --- /dev/null +++ b/packages/cli/src/cli/utils/json.ts @@ -0,0 +1,4 @@ +/** Serialize a value as pretty JSON for stdout (the `--json` contract). */ +export function toJsonStdout(result: unknown): string { + return `${JSON.stringify(result, null, 2)}\n`; +} diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 723c3e77c..b6b6250d7 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -7,3 +7,4 @@ export * from "./project/index.js"; export * from "./resources/index.js"; export * from "./site/index.js"; export * from "./utils/index.js"; +export * from "./workspace/index.js"; diff --git a/packages/cli/src/core/project/api.ts b/packages/cli/src/core/project/api.ts index 150c2c7bb..48ba5b551 100644 --- a/packages/cli/src/core/project/api.ts +++ b/packages/cli/src/core/project/api.ts @@ -5,8 +5,13 @@ import { extract } from "tar"; import { base44Client, getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; -import type { ProjectsResponse, Visibility } from "@/core/project/schema.js"; +import type { + AppDetail, + ProjectsResponse, + Visibility, +} from "@/core/project/schema.js"; import { + AppDetailSchema, CreateProjectResponseSchema, ProjectsResponseSchema, } from "@/core/project/schema.js"; @@ -19,7 +24,11 @@ const PUBLIC_SETTINGS: Record = { workspace: "workspace_with_login", }; -export async function createProject(projectName: string, description?: string) { +export async function createProject( + projectName: string, + description?: string, + organizationId?: string, +) { let response: KyResponse; try { response = await base44Client.post("api/apps", { @@ -28,6 +37,9 @@ export async function createProject(projectName: string, description?: string) { user_description: description ?? `Backend for '${projectName}'`, is_managed_source_code: false, public_settings: PUBLIC_SETTINGS.public, + // Omit unless targeting a specific workspace; the server otherwise + // defaults the app to the caller's personal workspace. + ...(organizationId ? { organization_id: organizationId } : {}), }, }); } catch (error) { @@ -92,6 +104,31 @@ export async function listProjects(): Promise { return result.data; } +/** + * Fetch a single app's details, including the workspace (`organizationId`) it + * currently belongs to. + */ +export async function getApp(appId: string): Promise { + let response: KyResponse; + try { + response = await base44Client.get(`api/apps/${appId}`, { + searchParams: { fields: "id,name,organization_id" }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching app"); + } + + const result = AppDetailSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data; +} + export async function downloadProject(projectId: string, projectPath: string) { let response: KyResponse; try { diff --git a/packages/cli/src/core/project/create.ts b/packages/cli/src/core/project/create.ts index 8e098b546..b90502660 100644 --- a/packages/cli/src/core/project/create.ts +++ b/packages/cli/src/core/project/create.ts @@ -10,6 +10,8 @@ interface CreateProjectOptions { description?: string; path: string; template: Template; + /** Workspace to create the app in. Omit for the caller's personal workspace. */ + organizationId?: string; } interface CreateProjectResult { @@ -37,12 +39,18 @@ async function assertProjectNotExists(dirPath: string): Promise { export async function createProjectFiles( options: CreateProjectOptions, ): Promise { - const { name, description, path: basePath, template } = options; + const { + name, + description, + path: basePath, + template, + organizationId, + } = options; await assertProjectNotExists(basePath); // Create the project via API to get the app ID - const { projectId } = await createProject(name, description); + const { projectId } = await createProject(name, description, organizationId); // Render the template to the destination path await renderTemplate(template, basePath, { name, description, projectId }); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 0fb46f3b3..451ed9091 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -68,6 +68,20 @@ export const CreateProjectResponseSchema = z.looseObject({ id: z.string(), }); +export const AppDetailSchema = z + .looseObject({ + id: z.string(), + name: z.string().optional(), + organization_id: z.string().nullish(), + }) + .transform((data) => ({ + id: data.id, + name: data.name, + organizationId: data.organization_id ?? undefined, + })); + +export type AppDetail = z.infer; + export const ProjectSchema = z .object({ id: z.string(), diff --git a/packages/cli/src/core/workspace/api.ts b/packages/cli/src/core/workspace/api.ts new file mode 100644 index 000000000..c52089dab --- /dev/null +++ b/packages/cli/src/core/workspace/api.ts @@ -0,0 +1,91 @@ +import type { KyResponse } from "ky"; +import { base44Client } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { MoveAppResult, Workspace } from "@/core/workspace/schema.js"; +import { + MoveAppResponseSchema, + WorkspaceListResponseSchema, +} from "@/core/workspace/schema.js"; + +export interface WorkspaceListEntry extends Workspace { + /** + * The user's personal workspace. The server returns it first (the builder + * auto-selects `workspaces[0]`), so the first entry is the personal one. + */ + isPersonal: boolean; +} + +/** + * List every workspace the current user belongs to. The first entry is the + * user's personal workspace (flagged via {@link WorkspaceListEntry.isPersonal}). + */ +export async function listWorkspaces(): Promise { + let response: KyResponse; + try { + response = await base44Client.get("api/workspace/workspaces"); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing workspaces"); + } + + const result = WorkspaceListResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data.workspaces.map((workspace, index) => ({ + ...workspace, + isPersonal: index === 0, + })); +} + +/** + * Fetch a single workspace the current user belongs to, by ID. Returns + * `undefined` when the user is not a member of a workspace with that ID. + * Backed by {@link listWorkspaces} — there is no per-workspace GET the CLI + * principal is authorized for, and reusing the list keeps the shape identical. + */ +export async function getWorkspace( + id: string, +): Promise { + const workspaces = await listWorkspaces(); + return workspaces.find((w) => w.id === id); +} + +/** + * Move an existing app to another workspace. The caller must be an editor of + * both the source (per its transfer policy) and the target workspace; the + * server enforces this and returns a 403 otherwise. + */ +export async function moveAppToWorkspace( + appId: string, + targetWorkspaceId: string, + options: { disconnectIntegrations?: boolean } = {}, +): Promise { + let response: KyResponse; + try { + response = await base44Client.post( + `api/apps/${appId}/metadata/move-to-workspace`, + { + json: { + target_workspace_id: targetWorkspaceId, + disconnect_integrations: options.disconnectIntegrations ?? false, + }, + }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "moving app to workspace"); + } + + const result = MoveAppResponseSchema.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/workspace/index.ts b/packages/cli/src/core/workspace/index.ts new file mode 100644 index 000000000..4ac144047 --- /dev/null +++ b/packages/cli/src/core/workspace/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/workspace/schema.ts b/packages/cli/src/core/workspace/schema.ts new file mode 100644 index 000000000..55285b721 --- /dev/null +++ b/packages/cli/src/core/workspace/schema.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +/** + * A workspace (a.k.a. organization) the current user belongs to. + * + * The server returns snake_case; we transform to camelCase. Only the fields the + * CLI needs are kept — the workspaces endpoint returns a much larger payload. + */ +export const WorkspaceSchema = z + .object({ + id: z.string(), + name: z.string(), + user_role: z.string().nullish(), + subscription_tier: z.string().nullish(), + is_enterprise: z.boolean().nullish(), + }) + .transform((data) => ({ + id: data.id, + name: data.name, + userRole: data.user_role ?? undefined, + subscriptionTier: data.subscription_tier ?? undefined, + isEnterprise: data.is_enterprise ?? undefined, + })); + +export type Workspace = z.infer; + +export const WorkspaceListResponseSchema = z.object({ + workspaces: z.array(WorkspaceSchema), +}); + +/** Response from POST /api/apps/{id}/metadata/move-to-workspace. */ +export const MoveAppResponseSchema = z + .looseObject({ + success: z.boolean().optional(), + message: z.string().optional(), + app_id: z.string().optional(), + new_workspace_id: z.string().optional(), + }) + .transform((data) => ({ + success: data.success ?? true, + message: data.message, + appId: data.app_id, + newWorkspaceId: data.new_workspace_id, + })); + +export type MoveAppResult = z.infer; diff --git a/packages/cli/tests/cli/create.spec.ts b/packages/cli/tests/cli/create.spec.ts index af646f991..0da624d8b 100644 --- a/packages/cli/tests/cli/create.spec.ts +++ b/packages/cli/tests/cli/create.spec.ts @@ -109,4 +109,36 @@ describe("create command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Project created successfully"); }); + + // ─── WORKSPACE TARGETING ────────────────────────────────────── + + it("creates the app in the workspace passed via --workspace", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "Personal", user_role: "owner" }, + { id: "ws-acme", name: "Acme Inc", user_role: "admin" }, + ]); + // Reflect the received organization_id back in the created app id so the + // test can prove it was forwarded in the POST body. + t.api.mockRoute("POST", "/api/apps", (req, res) => { + res.status(200).json({ + id: `app-in-${req.body.organization_id ?? "personal"}`, + name: req.body.name, + }); + }); + + const projectPath = join(t.getTempDir(), "ws-app"); + const result = await t.run( + "create", + "WS App", + "--path", + projectPath, + "--workspace", + "ws-acme", + "--no-skills", + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("app-in-ws-acme"); + }); }); diff --git a/packages/cli/tests/cli/link.spec.ts b/packages/cli/tests/cli/link.spec.ts index 1abd360de..7af9d9a76 100644 --- a/packages/cli/tests/cli/link.spec.ts +++ b/packages/cli/tests/cli/link.spec.ts @@ -156,6 +156,33 @@ describe("link command", () => { t.expectResult(result).toContain("new-created-app-id"); }); + it("creates the linked app in the workspace passed via --workspace", async () => { + await t.givenLoggedInWithProject(fixture("no-app-config")); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "Personal", user_role: "owner" }, + { id: "ws-acme", name: "Acme Inc", user_role: "admin" }, + ]); + t.api.mockRoute("POST", "/api/apps", (req, res) => { + res.status(200).json({ + id: `app-in-${req.body.organization_id ?? "personal"}`, + name: req.body.name, + }); + }); + + const result = await t.run( + "link", + "--create", + "--name", + "Workspace App", + "--workspace", + "ws-acme", + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Project linked"); + t.expectResult(result).toContain("app-in-ws-acme"); + }); + it("links project with --description flag", async () => { await t.givenLoggedInWithProject(fixture("no-app-config")); t.api.mockCreateApp({ id: "app-with-desc", name: "App With Description" }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 2cabf4f7d..a6fd52655 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -204,6 +204,21 @@ interface ListProjectsResponse { is_managed_source_code?: boolean; } +interface WorkspaceResponse { + id: string; + name: string; + user_role?: string | null; + subscription_tier?: string | null; + is_enterprise?: boolean | null; +} + +interface MoveAppResponse { + success?: boolean; + message?: string; + app_id?: string; + new_workspace_id?: string; +} + interface ErrorResponse { status: number; body?: unknown; @@ -525,6 +540,32 @@ export class TestAPIServer { return this.addRoute("GET", "/api/apps", response); } + /** Mock GET /api/workspace/workspaces - List the user's workspaces */ + mockListWorkspaces(workspaces: WorkspaceResponse[]): this { + return this.addRoute("GET", "/api/workspace/workspaces", { workspaces }); + } + + mockListWorkspacesError(error: ErrorResponse): this { + return this.addErrorRoute("GET", "/api/workspace/workspaces", error); + } + + /** Mock POST /api/apps/{appId}/metadata/move-to-workspace - Move an app */ + mockMoveApp(response: MoveAppResponse): this { + return this.addRoute( + "POST", + `/api/apps/${this.appId}/metadata/move-to-workspace`, + response, + ); + } + + mockMoveAppError(error: ErrorResponse): this { + return this.addErrorRoute( + "POST", + `/api/apps/${this.appId}/metadata/move-to-workspace`, + error, + ); + } + mockProjectEject(tarContent: Uint8Array = new Uint8Array()): this { this.pendingRoutes.push({ method: "GET", diff --git a/packages/cli/tests/cli/workspace_get.spec.ts b/packages/cli/tests/cli/workspace_get.spec.ts new file mode 100644 index 000000000..c14ae2f1f --- /dev/null +++ b/packages/cli/tests/cli/workspace_get.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +const WORKSPACES = [ + { id: "ws-personal", name: "My Workspace", user_role: "owner" }, + { + id: "ws-acme", + name: "Acme Inc", + user_role: "admin", + subscription_tier: "enterprise", + }, +]; + +describe("workspace get command", () => { + const t = setupCLITests(); + + it("shows details for a workspace by id", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces(WORKSPACES); + + const result = await t.run("workspace", "get", "ws-acme"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Acme Inc"); + t.expectResult(result).toContain("ws-acme"); + t.expectResult(result).toContain("enterprise"); + }); + + it("emits the workspace as JSON with --json", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces(WORKSPACES); + + const result = await t.run("workspace", "get", "ws-personal", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + id: "ws-personal", + name: "My Workspace", + userRole: "owner", + isPersonal: true, + }); + }); + + it("fails when the workspace id is unknown", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces(WORKSPACES); + + const result = await t.run("workspace", "get", "ws-nope"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("not found"); + }); +}); diff --git a/packages/cli/tests/cli/workspace_list.spec.ts b/packages/cli/tests/cli/workspace_list.spec.ts new file mode 100644 index 000000000..d228bbb47 --- /dev/null +++ b/packages/cli/tests/cli/workspace_list.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +describe("workspace list command", () => { + const t = setupCLITests(); + + it("lists the workspaces the user belongs to", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "My Workspace", user_role: "owner" }, + { id: "ws-acme", name: "Acme Inc", user_role: "admin" }, + ]); + + const result = await t.run("workspace", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("My Workspace"); + t.expectResult(result).toContain("Acme Inc"); + t.expectResult(result).toContain("ws-acme"); + t.expectResult(result).toContain("2 workspaces"); + }); + + it("emits the exact workspaces array with --json (personal flagged first)", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "My Workspace", user_role: "owner" }, + { id: "ws-acme", name: "Acme Inc", user_role: "editor" }, + ]); + + const result = await t.run("workspace", "list", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual([ + { + id: "ws-personal", + name: "My Workspace", + userRole: "owner", + isPersonal: true, + }, + { + id: "ws-acme", + name: "Acme Inc", + userRole: "editor", + isPersonal: false, + }, + ]); + }); + + it("filters by exact role with --role", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "My Workspace", user_role: "owner" }, + { id: "ws-acme", name: "Acme Inc", user_role: "admin" }, + { id: "ws-globex", name: "Globex", user_role: "admin" }, + ]); + + const result = await t.run( + "workspace", + "list", + "--role", + "admin", + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual([ + { + id: "ws-acme", + name: "Acme Inc", + userRole: "admin", + isPersonal: false, + }, + { + id: "ws-globex", + name: "Globex", + userRole: "admin", + isPersonal: false, + }, + ]); + }); + + it("fails when the server returns an error", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspacesError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("workspace", "list"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Server error"); + }); +}); diff --git a/packages/cli/tests/cli/workspace_move.spec.ts b/packages/cli/tests/cli/workspace_move.spec.ts new file mode 100644 index 000000000..92ca773a8 --- /dev/null +++ b/packages/cli/tests/cli/workspace_move.spec.ts @@ -0,0 +1,68 @@ +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("workspace move command", () => { + const t = setupCLITests(); + + it("moves the linked app to the target workspace", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockMoveApp({ + success: true, + app_id: "test-app-id", + new_workspace_id: "ws-acme", + }); + + const result = await t.run("workspace", "move", "ws-acme"); + + t.expectResult(result).toSucceed(); + expect(stripAnsi(result.stdout).trim()).toBe("App moved to ws-acme"); + }); + + it("emits the move result as JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockMoveApp({ + success: true, + app_id: "test-app-id", + new_workspace_id: "ws-acme", + }); + + const result = await t.run("workspace", "move", "ws-acme", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + success: true, + appId: "test-app-id", + newWorkspaceId: "ws-acme", + }); + }); + + it("requires a target workspace id in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("workspace", "move"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "A target workspace ID is required in non-interactive mode", + ); + }); + + it("surfaces the server's block reason instead of validating client-side", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockMoveAppError({ + status: 403, + body: { + detail: + "Only workspace admins and owners can move apps out of this workspace", + }, + }); + + const result = await t.run("workspace", "move", "ws-acme"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Only workspace admins and owners can move apps out of this workspace", + ); + }); +});