From 348e78338f857c75d3eb4e081dd524782486a325 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:23:48 +0000 Subject: [PATCH 1/6] Add workspace support to the CLI (create in a workspace, list, move) Apps created via the CLI previously always landed in the caller's personal workspace, and there was no way to target or move between workspaces. The backend already supports both; this wires the CLI surface up to it. - `create` / `link --create`: new `-w, --workspace ` flag (with `--org` alias) forwards `organization_id` to `POST /api/apps`. Interactive mode prompts for a workspace when the user belongs to more than one. - `workspace list`: lists the user's workspaces (personal first) with roles; supports `--json`. - `workspace move [id]`: moves the current/`--app-id` app to another workspace via the metadata move-to-workspace endpoint, with a confirmation prompt, `--yes`, and `--disconnect-integrations`. - New `core/workspace` module (listWorkspaces, moveAppToWorkspace) plus a `getApp` helper to read an app's current workspace; Zod-validated responses. - Tests for list, move, and create/link `--workspace` (validation, permission, JSON output) plus core schema unit tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- .../cli/src/cli/commands/project/create.ts | 67 ++++--- packages/cli/src/cli/commands/project/link.ts | 18 +- .../cli/commands/project/workspace-select.ts | 94 +++++++++ .../cli/src/cli/commands/workspace/index.ts | 10 + .../cli/src/cli/commands/workspace/list.ts | 40 ++++ .../cli/src/cli/commands/workspace/move.ts | 178 ++++++++++++++++++ .../cli/src/cli/commands/workspace/shared.ts | 12 ++ packages/cli/src/cli/program.ts | 4 + packages/cli/src/core/index.ts | 1 + packages/cli/src/core/project/api.ts | 41 +++- packages/cli/src/core/project/create.ts | 12 +- packages/cli/src/core/project/schema.ts | 14 ++ packages/cli/src/core/workspace/api.ts | 78 ++++++++ packages/cli/src/core/workspace/index.ts | 2 + packages/cli/src/core/workspace/schema.ts | 60 ++++++ packages/cli/tests/cli/create.spec.ts | 73 +++++++ packages/cli/tests/cli/link.spec.ts | 27 +++ .../cli/tests/cli/testkit/TestAPIServer.ts | 50 +++++ packages/cli/tests/cli/workspace_list.spec.ts | 55 ++++++ packages/cli/tests/cli/workspace_move.spec.ts | 97 ++++++++++ packages/cli/tests/core/workspace.spec.ts | 74 ++++++++ 21 files changed, 981 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/cli/commands/project/workspace-select.ts create mode 100644 packages/cli/src/cli/commands/workspace/index.ts create mode 100644 packages/cli/src/cli/commands/workspace/list.ts create mode 100644 packages/cli/src/cli/commands/workspace/move.ts create mode 100644 packages/cli/src/cli/commands/workspace/shared.ts create mode 100644 packages/cli/src/core/workspace/api.ts create mode 100644 packages/cli/src/core/workspace/index.ts create mode 100644 packages/cli/src/core/workspace/schema.ts create mode 100644 packages/cli/tests/cli/workspace_list.spec.ts create mode 100644 packages/cli/tests/cli/workspace_move.spec.ts create mode 100644 packages/cli/tests/core/workspace.spec.ts 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..395f04812 --- /dev/null +++ b/packages/cli/src/cli/commands/project/workspace-select.ts @@ -0,0 +1,94 @@ +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 { InvalidInputError } from "@/core/errors.js"; +import { + canCreateAppsInWorkspace, + 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 workspaceHints(workspaces: WorkspaceListEntry[]) { + return [ + { message: "Run 'base44 workspace list' to see available workspaces" }, + ...workspaces + .filter((w) => canCreateAppsInWorkspace(w.userRole)) + .map((w) => ({ message: `${w.name} — ${w.id}` })), + ]; +} + +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: validate membership + create permission, return it. + * - interactive with more than one eligible workspace: prompt to pick. + * - otherwise: return `undefined` so the server defaults to the personal + * workspace (no extra API call, no prompt for the common single-workspace case). + */ +export async function resolveWorkspaceId( + ctx: CLIContext, + flagWorkspaceId: string | undefined, + isInteractive: boolean, +): Promise { + if (flagWorkspaceId) { + const workspaces = await fetchWorkspaces(ctx); + const match = workspaces.find((w) => w.id === flagWorkspaceId); + if (!match) { + throw new InvalidInputError( + `Workspace "${flagWorkspaceId}" not found, or you are not a member of it.`, + { hints: workspaceHints(workspaces) }, + ); + } + if (!canCreateAppsInWorkspace(match.userRole)) { + throw new InvalidInputError( + `You don't have permission to create apps in workspace "${match.name}" (your role: ${match.userRole ?? "unknown"}).`, + ); + } + return match.id; + } + + if (!isInteractive) { + return undefined; + } + + const workspaces = await fetchWorkspaces(ctx); + const eligible = workspaces.filter((w) => + canCreateAppsInWorkspace(w.userRole), + ); + if (eligible.length <= 1) { + // Only the personal workspace (or none surfaced) — nothing to choose. + return undefined; + } + + const options: PromptOption[] = eligible.map((w) => ({ + value: w.id, + label: workspaceLabel(w), + })); + + const selected = await select({ + message: "Which workspace should this app belong to?", + options, + initialValue: eligible[0].id, + }); + + if (isCancel(selected)) { + onPromptCancel(); + } + + return selected as string; +} 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..632f2435f --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getWorkspaceListCommand } from "./list.js"; +import { getWorkspaceMoveCommand } from "./move.js"; + +export function getWorkspaceCommand(): Command { + return new Command("workspace") + .description("List workspaces and move apps between them") + .addCommand(getWorkspaceListCommand()) + .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..8b4bf2bf1 --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/list.ts @@ -0,0 +1,40 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { listWorkspaces } from "@/core/index.js"; +import { toJsonStdout, workspaceTag } from "./shared.js"; + +async function listWorkspacesAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const workspaces = await runTask( + "Fetching workspaces...", + () => listWorkspaces(), + { errorMessage: "Failed to fetch workspaces" }, + ); + + if (jsonMode) { + return { + outroMessage: `${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}`, + 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: `${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}`, + }; +} + +export function getWorkspaceListCommand(): Command { + return new Base44Command("list", { requireAppContext: false }) + .description("List the workspaces you belong to") + .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..9cb876a5e --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/move.ts @@ -0,0 +1,178 @@ +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 } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { + canCreateAppsInWorkspace, + getApp, + getAppContext, + listWorkspaces, + moveAppToWorkspace, + type WorkspaceListEntry, +} from "@/core/index.js"; +import { toJsonStdout } from "./shared.js"; + +interface MoveOptions { + disconnectIntegrations?: boolean; + yes?: boolean; +} + +function workspaceName( + workspaces: WorkspaceListEntry[], + id: string | undefined, +): string { + if (!id) return "unknown workspace"; + return workspaces.find((w) => w.id === id)?.name ?? id; +} + +/** + * Resolve the target workspace for a move: validate an explicit id, prompt when + * interactive, or fail fast in non-interactive mode without one. + */ +async function resolveTargetWorkspace( + target: string | undefined, + workspaces: WorkspaceListEntry[], + currentWorkspaceId: string | undefined, + isInteractive: boolean, +): Promise { + const eligible = workspaces.filter( + (w) => canCreateAppsInWorkspace(w.userRole) && w.id !== currentWorkspaceId, + ); + + if (target) { + const match = workspaces.find((w) => w.id === target); + if (!match) { + throw new InvalidInputError( + `Workspace "${target}" not found, or you are not a member of it.`, + { + hints: [ + { message: "Run 'base44 workspace list' to see your workspaces" }, + ], + }, + ); + } + if (target === currentWorkspaceId) { + throw new InvalidInputError("The app is already in that workspace."); + } + if (!canCreateAppsInWorkspace(match.userRole)) { + throw new InvalidInputError( + `You don't have permission to move apps into workspace "${match.name}" (your role: ${match.userRole ?? "unknown"}).`, + ); + } + return match.id; + } + + if (!isInteractive) { + 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" }, + ], + }, + ); + } + + if (eligible.length === 0) { + throw new InvalidInputError( + "No other workspaces available to move this app into.", + ); + } + + const options: PromptOption[] = eligible.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(); + } + return selected as string; +} + +async function moveAction( + ctx: CLIContext, + target: string | undefined, + options: MoveOptions, +): Promise { + const { runTask, isNonInteractive, jsonMode } = ctx; + const isInteractive = !isNonInteractive && !jsonMode; + const { id: appId } = getAppContext(); + + const { workspaces, currentWorkspaceId } = await runTask( + "Fetching workspaces...", + async () => { + const [workspaces, app] = await Promise.all([ + listWorkspaces(), + getApp(appId), + ]); + return { workspaces, currentWorkspaceId: app.organizationId }; + }, + { errorMessage: "Failed to fetch workspaces" }, + ); + + const targetWorkspaceId = await resolveTargetWorkspace( + target, + workspaces, + currentWorkspaceId, + isInteractive, + ); + + if (isInteractive && !options.yes) { + const proceed = await confirm({ + message: `Move this app from ${theme.styles.bold( + workspaceName(workspaces, currentWorkspaceId), + )} to ${theme.styles.bold(workspaceName(workspaces, targetWorkspaceId))}?`, + }); + if (isCancel(proceed)) { + onPromptCancel(); + } + if (!proceed) { + return { outroMessage: "Move cancelled" }; + } + } + + const result = await runTask( + "Moving app to workspace...", + () => + moveAppToWorkspace(appId, targetWorkspaceId, { + disconnectIntegrations: options.disconnectIntegrations, + }), + { errorMessage: "Failed to move app" }, + ); + + const targetName = workspaceName(workspaces, targetWorkspaceId); + if (jsonMode) { + return { + outroMessage: `App moved to ${targetName}`, + stdout: toJsonStdout(result), + }; + } + + return { outroMessage: `App moved to ${theme.styles.bold(targetName)}` }; +} + +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..d2e925da0 --- /dev/null +++ b/packages/cli/src/cli/commands/workspace/shared.ts @@ -0,0 +1,12 @@ +import type { WorkspaceListEntry } from "@/core/index.js"; + +/** Serialize a value as pretty JSON for stdout (the `--json` contract). */ +export function toJsonStdout(result: unknown): string { + return `${JSON.stringify(result, null, 2)}\n`; +} + +/** 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/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..123c9ea7d 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. Used to show the source workspace before a move. + */ +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..2ae96efa1 --- /dev/null +++ b/packages/cli/src/core/workspace/api.ts @@ -0,0 +1,78 @@ +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, + })); +} + +/** + * 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..ea8a5a597 --- /dev/null +++ b/packages/cli/src/core/workspace/schema.ts @@ -0,0 +1,60 @@ +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; + +/** + * Workspace roles that permit creating and moving apps. The server ultimately + * enforces this; the CLI uses it only to filter interactive pickers so users + * aren't offered a target they'll get a 403 on. + */ +export const APP_EDITOR_ROLES = ["owner", "admin", "editor"] as const; + +export function canCreateAppsInWorkspace(role: string | undefined): boolean { + return ( + role !== undefined && + (APP_EDITOR_ROLES as readonly string[]).includes(role.toLowerCase()) + ); +} diff --git a/packages/cli/tests/cli/create.spec.ts b/packages/cli/tests/cli/create.spec.ts index af646f991..d263ce7c4 100644 --- a/packages/cli/tests/cli/create.spec.ts +++ b/packages/cli/tests/cli/create.spec.ts @@ -109,4 +109,77 @@ 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"); + }); + + it("rejects --workspace for a workspace the user is not a member of", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "Personal", user_role: "owner" }, + ]); + + const result = await t.run( + "create", + "WS App", + "--path", + join(t.getTempDir(), "ws-missing"), + "--workspace", + "ws-nope", + "--no-skills", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("not found"); + }); + + it("rejects --workspace when the user lacks create permission", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListWorkspaces([ + { id: "ws-personal", name: "Personal", user_role: "owner" }, + { id: "ws-view", name: "View Only", user_role: "viewer" }, + ]); + + const result = await t.run( + "create", + "WS App", + "--path", + join(t.getTempDir(), "ws-viewer"), + "--workspace", + "ws-view", + "--no-skills", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("permission"); + }); }); 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..ee64a3ea2 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,41 @@ export class TestAPIServer { return this.addRoute("GET", "/api/apps", response); } + /** Mock GET /api/apps/{appId} - Fetch a single app's details */ + mockGetApp(response: { + id: string; + name?: string; + organization_id?: string | null; + }): this { + return this.addRoute("GET", `/api/apps/${this.appId}`, 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_list.spec.ts b/packages/cli/tests/cli/workspace_list.spec.ts new file mode 100644 index 000000000..fa1c634e3 --- /dev/null +++ b/packages/cli/tests/cli/workspace_list.spec.ts @@ -0,0 +1,55 @@ +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 a JSON array with --json (personal workspace 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(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatchObject({ + id: "ws-personal", + userRole: "owner", + isPersonal: true, + }); + expect(parsed[1]).toMatchObject({ id: "ws-acme", 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..0a262357c --- /dev/null +++ b/packages/cli/tests/cli/workspace_move.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { fixture, 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" }, +]; + +describe("workspace move command", () => { + const t = setupCLITests(); + + it("moves the linked app to a target workspace", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockListWorkspaces(WORKSPACES); + t.api.mockGetApp({ + id: "test-app-id", + name: "My App", + organization_id: "ws-personal", + }); + t.api.mockMoveApp({ + success: true, + message: "App successfully moved to workspace", + app_id: "test-app-id", + new_workspace_id: "ws-acme", + }); + + const result = await t.run("workspace", "move", "ws-acme"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Acme Inc"); + }); + + it("emits the move result as JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockListWorkspaces(WORKSPACES); + t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); + 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(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toMatchObject({ success: true, newWorkspaceId: "ws-acme" }); + }); + + it("requires a target workspace id in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockListWorkspaces(WORKSPACES); + t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); + + const result = await t.run("workspace", "move"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("target workspace ID is required"); + }); + + it("fails when the target workspace is unknown", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockListWorkspaces(WORKSPACES); + t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); + + const result = await t.run("workspace", "move", "ws-nope"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("not found"); + }); + + it("fails when the app is already in the target workspace", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockListWorkspaces(WORKSPACES); + t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-acme" }); + + const result = await t.run("workspace", "move", "ws-acme"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("already in that workspace"); + }); + + it("surfaces a permission error from the server", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockListWorkspaces(WORKSPACES); + t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); + t.api.mockMoveAppError({ + status: 403, + body: { detail: "You don't have permission to move this app" }, + }); + + const result = await t.run("workspace", "move", "ws-acme"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("permission"); + }); +}); diff --git a/packages/cli/tests/core/workspace.spec.ts b/packages/cli/tests/core/workspace.spec.ts new file mode 100644 index 000000000..4a033487e --- /dev/null +++ b/packages/cli/tests/core/workspace.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + canCreateAppsInWorkspace, + MoveAppResponseSchema, + WorkspaceListResponseSchema, +} from "@/core/workspace/index.js"; + +describe("workspace schema", () => { + it("transforms the workspaces list response to camelCase", () => { + const parsed = WorkspaceListResponseSchema.parse({ + workspaces: [ + { + id: "ws-1", + name: "Personal", + user_role: "owner", + subscription_tier: "free", + is_enterprise: false, + }, + ], + }); + + expect(parsed.workspaces[0]).toEqual({ + id: "ws-1", + name: "Personal", + userRole: "owner", + subscriptionTier: "free", + isEnterprise: false, + }); + }); + + it("defaults optional workspace fields to undefined", () => { + const parsed = WorkspaceListResponseSchema.parse({ + workspaces: [{ id: "ws-1", name: "Personal" }], + }); + + expect(parsed.workspaces[0]).toEqual({ + id: "ws-1", + name: "Personal", + userRole: undefined, + subscriptionTier: undefined, + isEnterprise: undefined, + }); + }); + + it("transforms the move response to camelCase", () => { + const parsed = MoveAppResponseSchema.parse({ + success: true, + message: "moved", + app_id: "app-1", + new_workspace_id: "ws-2", + }); + + expect(parsed).toEqual({ + success: true, + message: "moved", + appId: "app-1", + newWorkspaceId: "ws-2", + }); + }); +}); + +describe("canCreateAppsInWorkspace", () => { + it("allows editor-capable roles (case-insensitive)", () => { + for (const role of ["owner", "admin", "editor", "OWNER", "Admin"]) { + expect(canCreateAppsInWorkspace(role)).toBe(true); + } + }); + + it("denies viewer, guest, unknown, and missing roles", () => { + for (const role of ["viewer", "guest", "member", undefined]) { + expect(canCreateAppsInWorkspace(role)).toBe(false); + } + }); +}); From d159c96315e7d94487316945a6d3afbbceb2da3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:56:39 +0000 Subject: [PATCH 2/6] Make APP_EDITOR_ROLES module-private to satisfy knip It's only used inside schema.ts by canCreateAppsInWorkspace; exporting it tripped the knip unused-exports check in CI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- packages/cli/src/core/workspace/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/core/workspace/schema.ts b/packages/cli/src/core/workspace/schema.ts index ea8a5a597..0bd97844f 100644 --- a/packages/cli/src/core/workspace/schema.ts +++ b/packages/cli/src/core/workspace/schema.ts @@ -50,7 +50,7 @@ export type MoveAppResult = z.infer; * enforces this; the CLI uses it only to filter interactive pickers so users * aren't offered a target they'll get a 403 on. */ -export const APP_EDITOR_ROLES = ["owner", "admin", "editor"] as const; +const APP_EDITOR_ROLES = ["owner", "admin", "editor"] as const; export function canCreateAppsInWorkspace(role: string | undefined): boolean { return ( From 7c42023ea6905bb1921f6c63fdc090c39334a199 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:58:51 +0000 Subject: [PATCH 3/6] Add `workspace get` command and `list` filters - `base44 workspace get `: show a single workspace's details by ID (name, role, tier) without scanning the full list. Backed by the existing listWorkspaces endpoint via a new core getWorkspace() helper. - `base44 workspace list --can-create`: only workspaces you can create/move apps into (owner/admin/editor). - `base44 workspace list --role `: exact-role filter. Both support --json. Adds CLI specs for get + the new filters. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- .../cli/src/cli/commands/workspace/get.ts | 52 +++++++++++++++++++ .../cli/src/cli/commands/workspace/index.ts | 4 +- .../cli/src/cli/commands/workspace/list.ts | 44 ++++++++++++---- packages/cli/src/core/workspace/api.ts | 13 +++++ packages/cli/tests/cli/workspace_get.spec.ts | 48 +++++++++++++++++ packages/cli/tests/cli/workspace_list.spec.ts | 36 +++++++++++++ 6 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/cli/commands/workspace/get.ts create mode 100644 packages/cli/tests/cli/workspace_get.spec.ts 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..ab77312eb --- /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 } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { getWorkspace } from "@/core/index.js"; +import { toJsonStdout, 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 index 632f2435f..d688a8884 100644 --- a/packages/cli/src/cli/commands/workspace/index.ts +++ b/packages/cli/src/cli/commands/workspace/index.ts @@ -1,10 +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 and move apps between them") + .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 index 8b4bf2bf1..5dc1a5049 100644 --- a/packages/cli/src/cli/commands/workspace/list.ts +++ b/packages/cli/src/cli/commands/workspace/list.ts @@ -1,23 +1,39 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, theme } from "@/cli/utils/index.js"; -import { listWorkspaces } from "@/core/index.js"; +import { canCreateAppsInWorkspace, listWorkspaces } from "@/core/index.js"; import { toJsonStdout, workspaceTag } from "./shared.js"; -async function listWorkspacesAction({ - log, - runTask, - jsonMode, -}: CLIContext): Promise { - const workspaces = await runTask( +interface ListOptions { + canCreate?: boolean; + role?: string; +} + +function pluralize(n: number): string { + return `${n} workspace${n !== 1 ? "s" : ""}`; +} + +async function listWorkspacesAction( + { log, runTask, jsonMode }: CLIContext, + options: ListOptions, +): Promise { + let workspaces = await runTask( "Fetching workspaces...", () => listWorkspaces(), { errorMessage: "Failed to fetch workspaces" }, ); + if (options.canCreate) { + workspaces = workspaces.filter((w) => canCreateAppsInWorkspace(w.userRole)); + } + if (options.role) { + const role = options.role.toLowerCase(); + workspaces = workspaces.filter((w) => w.userRole?.toLowerCase() === role); + } + if (jsonMode) { return { - outroMessage: `${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}`, + outroMessage: pluralize(workspaces.length), stdout: toJsonStdout(workspaces), }; } @@ -28,13 +44,19 @@ async function listWorkspacesAction({ ); } - return { - outroMessage: `${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}`, - }; + return { outroMessage: pluralize(workspaces.length) }; } export function getWorkspaceListCommand(): Command { return new Base44Command("list", { requireAppContext: false }) .description("List the workspaces you belong to") + .option( + "--can-create", + "Only workspaces you can create or move apps into (owner/admin/editor)", + ) + .option( + "--role ", + "Only workspaces where your role matches (owner, admin, editor, viewer)", + ) .action(listWorkspacesAction); } diff --git a/packages/cli/src/core/workspace/api.ts b/packages/cli/src/core/workspace/api.ts index 2ae96efa1..c52089dab 100644 --- a/packages/cli/src/core/workspace/api.ts +++ b/packages/cli/src/core/workspace/api.ts @@ -41,6 +41,19 @@ export async function listWorkspaces(): Promise { })); } +/** + * 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 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..e7779dbf9 --- /dev/null +++ b/packages/cli/tests/cli/workspace_get.spec.ts @@ -0,0 +1,48 @@ +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(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toMatchObject({ + id: "ws-personal", + 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 index fa1c634e3..83fde4ec7 100644 --- a/packages/cli/tests/cli/workspace_list.spec.ts +++ b/packages/cli/tests/cli/workspace_list.spec.ts @@ -40,6 +40,42 @@ describe("workspace list command", () => { expect(parsed[1]).toMatchObject({ id: "ws-acme", isPersonal: false }); }); + it("filters to workspaces the user can create apps in with --can-create", 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" }, + { id: "ws-view", name: "View Only", user_role: "viewer" }, + ]); + + const result = await t.run("workspace", "list", "--can-create", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.map((w: { id: string }) => w.id)).toEqual([ + "ws-personal", + "ws-acme", + ]); + }); + + 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(); + const parsed = JSON.parse(result.stdout); + expect(parsed.map((w: { id: string }) => w.id)).toEqual([ + "ws-acme", + "ws-globex", + ]); + }); + it("fails when the server returns an error", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); t.api.mockListWorkspacesError({ From 6590a3eed091cd0f3438ffe98c345e2b6a498d5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:59:50 +0000 Subject: [PATCH 4/6] Format workspace get/list specs (biome) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- packages/cli/tests/cli/workspace_get.spec.ts | 7 ++++++- packages/cli/tests/cli/workspace_list.spec.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/cli/workspace_get.spec.ts b/packages/cli/tests/cli/workspace_get.spec.ts index e7779dbf9..56aeacde7 100644 --- a/packages/cli/tests/cli/workspace_get.spec.ts +++ b/packages/cli/tests/cli/workspace_get.spec.ts @@ -3,7 +3,12 @@ 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" }, + { + id: "ws-acme", + name: "Acme Inc", + user_role: "admin", + subscription_tier: "enterprise", + }, ]; describe("workspace get command", () => { diff --git a/packages/cli/tests/cli/workspace_list.spec.ts b/packages/cli/tests/cli/workspace_list.spec.ts index 83fde4ec7..f9c4b8581 100644 --- a/packages/cli/tests/cli/workspace_list.spec.ts +++ b/packages/cli/tests/cli/workspace_list.spec.ts @@ -66,7 +66,13 @@ describe("workspace list command", () => { { id: "ws-globex", name: "Globex", user_role: "admin" }, ]); - const result = await t.run("workspace", "list", "--role", "admin", "--json"); + const result = await t.run( + "workspace", + "list", + "--role", + "admin", + "--json", + ); t.expectResult(result).toSucceed(); const parsed = JSON.parse(result.stdout); From d4f80dff090d018e5c56df63653105a7849397bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:47:30 +0000 Subject: [PATCH 5/6] Address review: server-authoritative workspace UX, shared util, fewer unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, match the web builder's move UX (useMoveToWorkspaceEligibility) and stop replicating server permission logic client-side: - Remove client-side role validation/filtering: drop canCreateAppsInWorkspace and the `workspace list --can-create` filter (kept the pure `--role` data filter). create/link pickers now list all your workspaces and pass an explicit --workspace straight through; the server authorizes and returns a clear error. - `workspace move`: fetch workspaces + current app only in interactive mode (for the picker + confirm). Destination list is every workspace except the current one, no role filter. An explicit target is not pre-validated — the server's block reason is surfaced (e.g. "Only workspace admins and owners can move apps out of this workspace"). - Consolidate toJsonStdout into cli/utils (it duplicated sandbox/shared.ts); workspace/shared.ts keeps only workspaceTag. - Remove the tests/core/workspace.spec.ts unit test; rely on product-facing CLI specs. Switch the workspace specs' deterministic (json/success) assertions to exact toEqual/toBe. - Tidy the getApp doc comment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- .../cli/commands/project/workspace-select.ts | 50 ++--- .../cli/src/cli/commands/sandbox/shared.ts | 11 +- .../cli/src/cli/commands/workspace/get.ts | 4 +- .../cli/src/cli/commands/workspace/list.ts | 14 +- .../cli/src/cli/commands/workspace/move.ts | 176 +++++++++--------- .../cli/src/cli/commands/workspace/shared.ts | 5 - packages/cli/src/cli/utils/index.ts | 1 + packages/cli/src/cli/utils/json.ts | 4 + packages/cli/src/core/project/api.ts | 2 +- packages/cli/src/core/workspace/schema.ts | 14 -- packages/cli/tests/cli/create.spec.ts | 41 ---- .../cli/tests/cli/testkit/TestAPIServer.ts | 9 - packages/cli/tests/cli/workspace_get.spec.ts | 4 +- packages/cli/tests/cli/workspace_list.spec.ts | 57 +++--- packages/cli/tests/cli/workspace_move.spec.ts | 67 ++----- packages/cli/tests/core/workspace.spec.ts | 74 -------- 16 files changed, 158 insertions(+), 375 deletions(-) create mode 100644 packages/cli/src/cli/utils/json.ts delete mode 100644 packages/cli/tests/core/workspace.spec.ts diff --git a/packages/cli/src/cli/commands/project/workspace-select.ts b/packages/cli/src/cli/commands/project/workspace-select.ts index 395f04812..2997f4058 100644 --- a/packages/cli/src/cli/commands/project/workspace-select.ts +++ b/packages/cli/src/cli/commands/project/workspace-select.ts @@ -2,12 +2,7 @@ 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 { InvalidInputError } from "@/core/errors.js"; -import { - canCreateAppsInWorkspace, - listWorkspaces, - type WorkspaceListEntry, -} from "@/core/index.js"; +import { listWorkspaces, type WorkspaceListEntry } from "@/core/index.js"; function fetchWorkspaces(ctx: CLIContext): Promise { return ctx.runTask("Fetching workspaces...", () => listWorkspaces(), { @@ -16,15 +11,6 @@ function fetchWorkspaces(ctx: CLIContext): Promise { }); } -function workspaceHints(workspaces: WorkspaceListEntry[]) { - return [ - { message: "Run 'base44 workspace list' to see available workspaces" }, - ...workspaces - .filter((w) => canCreateAppsInWorkspace(w.userRole)) - .map((w) => ({ message: `${w.name} — ${w.id}` })), - ]; -} - function workspaceLabel(workspace: WorkspaceListEntry): string { const suffix = workspace.isPersonal ? "personal" @@ -35,10 +21,12 @@ function workspaceLabel(workspace: WorkspaceListEntry): string { /** * Resolve the workspace a new app should belong to. * - * - `--workspace ` set: validate membership + create permission, return it. - * - interactive with more than one eligible workspace: prompt to pick. + * - `--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, no prompt for the common single-workspace case). + * workspace (no extra API call in the common single-workspace case). */ export async function resolveWorkspaceId( ctx: CLIContext, @@ -46,20 +34,7 @@ export async function resolveWorkspaceId( isInteractive: boolean, ): Promise { if (flagWorkspaceId) { - const workspaces = await fetchWorkspaces(ctx); - const match = workspaces.find((w) => w.id === flagWorkspaceId); - if (!match) { - throw new InvalidInputError( - `Workspace "${flagWorkspaceId}" not found, or you are not a member of it.`, - { hints: workspaceHints(workspaces) }, - ); - } - if (!canCreateAppsInWorkspace(match.userRole)) { - throw new InvalidInputError( - `You don't have permission to create apps in workspace "${match.name}" (your role: ${match.userRole ?? "unknown"}).`, - ); - } - return match.id; + return flagWorkspaceId; } if (!isInteractive) { @@ -67,15 +42,12 @@ export async function resolveWorkspaceId( } const workspaces = await fetchWorkspaces(ctx); - const eligible = workspaces.filter((w) => - canCreateAppsInWorkspace(w.userRole), - ); - if (eligible.length <= 1) { - // Only the personal workspace (or none surfaced) — nothing to choose. + if (workspaces.length <= 1) { + // Only the personal workspace — nothing to choose. return undefined; } - const options: PromptOption[] = eligible.map((w) => ({ + const options: PromptOption[] = workspaces.map((w) => ({ value: w.id, label: workspaceLabel(w), })); @@ -83,7 +55,7 @@ export async function resolveWorkspaceId( const selected = await select({ message: "Which workspace should this app belong to?", options, - initialValue: eligible[0].id, + initialValue: workspaces[0].id, }); if (isCancel(selected)) { 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 index ab77312eb..8a551bc24 100644 --- a/packages/cli/src/cli/commands/workspace/get.ts +++ b/packages/cli/src/cli/commands/workspace/get.ts @@ -1,9 +1,9 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { Base44Command, theme, toJsonStdout } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { getWorkspace } from "@/core/index.js"; -import { toJsonStdout, workspaceTag } from "./shared.js"; +import { workspaceTag } from "./shared.js"; async function getWorkspaceAction( { runTask, log, jsonMode }: CLIContext, diff --git a/packages/cli/src/cli/commands/workspace/list.ts b/packages/cli/src/cli/commands/workspace/list.ts index 5dc1a5049..45bb58f32 100644 --- a/packages/cli/src/cli/commands/workspace/list.ts +++ b/packages/cli/src/cli/commands/workspace/list.ts @@ -1,11 +1,10 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; -import { canCreateAppsInWorkspace, listWorkspaces } from "@/core/index.js"; -import { toJsonStdout, workspaceTag } from "./shared.js"; +import { Base44Command, theme, toJsonStdout } from "@/cli/utils/index.js"; +import { listWorkspaces } from "@/core/index.js"; +import { workspaceTag } from "./shared.js"; interface ListOptions { - canCreate?: boolean; role?: string; } @@ -23,9 +22,6 @@ async function listWorkspacesAction( { errorMessage: "Failed to fetch workspaces" }, ); - if (options.canCreate) { - workspaces = workspaces.filter((w) => canCreateAppsInWorkspace(w.userRole)); - } if (options.role) { const role = options.role.toLowerCase(); workspaces = workspaces.filter((w) => w.userRole?.toLowerCase() === role); @@ -50,10 +46,6 @@ async function listWorkspacesAction( export function getWorkspaceListCommand(): Command { return new Base44Command("list", { requireAppContext: false }) .description("List the workspaces you belong to") - .option( - "--can-create", - "Only workspaces you can create or move apps into (owner/admin/editor)", - ) .option( "--role ", "Only workspaces where your role matches (owner, admin, editor, viewer)", diff --git a/packages/cli/src/cli/commands/workspace/move.ts b/packages/cli/src/cli/commands/workspace/move.ts index 9cb876a5e..f9760f605 100644 --- a/packages/cli/src/cli/commands/workspace/move.ts +++ b/packages/cli/src/cli/commands/workspace/move.ts @@ -2,87 +2,61 @@ 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 } from "@/cli/utils/index.js"; +import { + Base44Command, + onPromptCancel, + theme, + toJsonStdout, +} from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { - canCreateAppsInWorkspace, getApp, getAppContext, listWorkspaces, moveAppToWorkspace, - type WorkspaceListEntry, } from "@/core/index.js"; -import { toJsonStdout } from "./shared.js"; interface MoveOptions { disconnectIntegrations?: boolean; yes?: boolean; } -function workspaceName( - workspaces: WorkspaceListEntry[], - id: string | undefined, -): string { - if (!id) return "unknown workspace"; - return workspaces.find((w) => w.id === id)?.name ?? id; +interface TargetSelection { + targetWorkspaceId: string; + fromName: string; + toName: string; } /** - * Resolve the target workspace for a move: validate an explicit id, prompt when - * interactive, or fail fast in non-interactive mode without one. + * 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 resolveTargetWorkspace( - target: string | undefined, - workspaces: WorkspaceListEntry[], - currentWorkspaceId: string | undefined, - isInteractive: boolean, -): Promise { - const eligible = workspaces.filter( - (w) => canCreateAppsInWorkspace(w.userRole) && w.id !== currentWorkspaceId, +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" }, ); - if (target) { - const match = workspaces.find((w) => w.id === target); - if (!match) { - throw new InvalidInputError( - `Workspace "${target}" not found, or you are not a member of it.`, - { - hints: [ - { message: "Run 'base44 workspace list' to see your workspaces" }, - ], - }, - ); - } - if (target === currentWorkspaceId) { - throw new InvalidInputError("The app is already in that workspace."); - } - if (!canCreateAppsInWorkspace(match.userRole)) { - throw new InvalidInputError( - `You don't have permission to move apps into workspace "${match.name}" (your role: ${match.userRole ?? "unknown"}).`, - ); - } - return match.id; - } - - if (!isInteractive) { - 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" }, - ], - }, - ); - } - - if (eligible.length === 0) { + 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[] = eligible.map((w) => ({ + const options: PromptOption[] = destinations.map((w) => ({ value: w.id, label: `${w.name} (${w.userRole ?? "member"})`, })); @@ -93,7 +67,15 @@ async function resolveTargetWorkspace( if (isCancel(selected)) { onPromptCancel(); } - return selected as string; + 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( @@ -105,37 +87,51 @@ async function moveAction( const isInteractive = !isNonInteractive && !jsonMode; const { id: appId } = getAppContext(); - const { workspaces, currentWorkspaceId } = await runTask( - "Fetching workspaces...", - async () => { - const [workspaces, app] = await Promise.all([ - listWorkspaces(), - getApp(appId), - ]); - return { workspaces, currentWorkspaceId: app.organizationId }; - }, - { errorMessage: "Failed to fetch workspaces" }, - ); - - const targetWorkspaceId = await resolveTargetWorkspace( - target, - workspaces, - currentWorkspaceId, - isInteractive, - ); + let targetWorkspaceId: string; + let toLabel: string; - if (isInteractive && !options.yes) { - const proceed = await confirm({ - message: `Move this app from ${theme.styles.bold( - workspaceName(workspaces, currentWorkspaceId), - )} to ${theme.styles.bold(workspaceName(workspaces, targetWorkspaceId))}?`, - }); - if (isCancel(proceed)) { - onPromptCancel(); + 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" }; + } } - 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( @@ -147,15 +143,11 @@ async function moveAction( { errorMessage: "Failed to move app" }, ); - const targetName = workspaceName(workspaces, targetWorkspaceId); if (jsonMode) { - return { - outroMessage: `App moved to ${targetName}`, - stdout: toJsonStdout(result), - }; + return { outroMessage: "App moved", stdout: toJsonStdout(result) }; } - return { outroMessage: `App moved to ${theme.styles.bold(targetName)}` }; + return { outroMessage: `App moved to ${theme.styles.bold(toLabel)}` }; } export function getWorkspaceMoveCommand(): Command { diff --git a/packages/cli/src/cli/commands/workspace/shared.ts b/packages/cli/src/cli/commands/workspace/shared.ts index d2e925da0..b25339c73 100644 --- a/packages/cli/src/cli/commands/workspace/shared.ts +++ b/packages/cli/src/cli/commands/workspace/shared.ts @@ -1,10 +1,5 @@ import type { WorkspaceListEntry } from "@/core/index.js"; -/** Serialize a value as pretty JSON for stdout (the `--json` contract). */ -export function toJsonStdout(result: unknown): string { - return `${JSON.stringify(result, null, 2)}\n`; -} - /** Short human-readable role/personal tag for a workspace, e.g. "personal, owner". */ export function workspaceTag(workspace: WorkspaceListEntry): string { const role = workspace.userRole ?? "member"; 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/project/api.ts b/packages/cli/src/core/project/api.ts index 123c9ea7d..48ba5b551 100644 --- a/packages/cli/src/core/project/api.ts +++ b/packages/cli/src/core/project/api.ts @@ -106,7 +106,7 @@ export async function listProjects(): Promise { /** * Fetch a single app's details, including the workspace (`organizationId`) it - * currently belongs to. Used to show the source workspace before a move. + * currently belongs to. */ export async function getApp(appId: string): Promise { let response: KyResponse; diff --git a/packages/cli/src/core/workspace/schema.ts b/packages/cli/src/core/workspace/schema.ts index 0bd97844f..55285b721 100644 --- a/packages/cli/src/core/workspace/schema.ts +++ b/packages/cli/src/core/workspace/schema.ts @@ -44,17 +44,3 @@ export const MoveAppResponseSchema = z })); export type MoveAppResult = z.infer; - -/** - * Workspace roles that permit creating and moving apps. The server ultimately - * enforces this; the CLI uses it only to filter interactive pickers so users - * aren't offered a target they'll get a 403 on. - */ -const APP_EDITOR_ROLES = ["owner", "admin", "editor"] as const; - -export function canCreateAppsInWorkspace(role: string | undefined): boolean { - return ( - role !== undefined && - (APP_EDITOR_ROLES as readonly string[]).includes(role.toLowerCase()) - ); -} diff --git a/packages/cli/tests/cli/create.spec.ts b/packages/cli/tests/cli/create.spec.ts index d263ce7c4..0da624d8b 100644 --- a/packages/cli/tests/cli/create.spec.ts +++ b/packages/cli/tests/cli/create.spec.ts @@ -141,45 +141,4 @@ describe("create command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("app-in-ws-acme"); }); - - it("rejects --workspace for a workspace the user is not a member of", async () => { - await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - t.api.mockListWorkspaces([ - { id: "ws-personal", name: "Personal", user_role: "owner" }, - ]); - - const result = await t.run( - "create", - "WS App", - "--path", - join(t.getTempDir(), "ws-missing"), - "--workspace", - "ws-nope", - "--no-skills", - ); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("not found"); - }); - - it("rejects --workspace when the user lacks create permission", async () => { - await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - t.api.mockListWorkspaces([ - { id: "ws-personal", name: "Personal", user_role: "owner" }, - { id: "ws-view", name: "View Only", user_role: "viewer" }, - ]); - - const result = await t.run( - "create", - "WS App", - "--path", - join(t.getTempDir(), "ws-viewer"), - "--workspace", - "ws-view", - "--no-skills", - ); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("permission"); - }); }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index ee64a3ea2..a6fd52655 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -540,15 +540,6 @@ export class TestAPIServer { return this.addRoute("GET", "/api/apps", response); } - /** Mock GET /api/apps/{appId} - Fetch a single app's details */ - mockGetApp(response: { - id: string; - name?: string; - organization_id?: string | null; - }): this { - return this.addRoute("GET", `/api/apps/${this.appId}`, response); - } - /** Mock GET /api/workspace/workspaces - List the user's workspaces */ mockListWorkspaces(workspaces: WorkspaceResponse[]): this { return this.addRoute("GET", "/api/workspace/workspaces", { workspaces }); diff --git a/packages/cli/tests/cli/workspace_get.spec.ts b/packages/cli/tests/cli/workspace_get.spec.ts index 56aeacde7..c14ae2f1f 100644 --- a/packages/cli/tests/cli/workspace_get.spec.ts +++ b/packages/cli/tests/cli/workspace_get.spec.ts @@ -33,9 +33,9 @@ describe("workspace get command", () => { const result = await t.run("workspace", "get", "ws-personal", "--json"); t.expectResult(result).toSucceed(); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ + expect(JSON.parse(result.stdout)).toEqual({ id: "ws-personal", + name: "My Workspace", userRole: "owner", isPersonal: true, }); diff --git a/packages/cli/tests/cli/workspace_list.spec.ts b/packages/cli/tests/cli/workspace_list.spec.ts index f9c4b8581..d228bbb47 100644 --- a/packages/cli/tests/cli/workspace_list.spec.ts +++ b/packages/cli/tests/cli/workspace_list.spec.ts @@ -20,7 +20,7 @@ describe("workspace list command", () => { t.expectResult(result).toContain("2 workspaces"); }); - it("emits a JSON array with --json (personal workspace flagged first)", async () => { + 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" }, @@ -30,31 +30,19 @@ describe("workspace list command", () => { const result = await t.run("workspace", "list", "--json"); t.expectResult(result).toSucceed(); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveLength(2); - expect(parsed[0]).toMatchObject({ - id: "ws-personal", - userRole: "owner", - isPersonal: true, - }); - expect(parsed[1]).toMatchObject({ id: "ws-acme", isPersonal: false }); - }); - - it("filters to workspaces the user can create apps in with --can-create", 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" }, - { id: "ws-view", name: "View Only", user_role: "viewer" }, - ]); - - const result = await t.run("workspace", "list", "--can-create", "--json"); - - t.expectResult(result).toSucceed(); - const parsed = JSON.parse(result.stdout); - expect(parsed.map((w: { id: string }) => w.id)).toEqual([ - "ws-personal", - "ws-acme", + 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, + }, ]); }); @@ -75,10 +63,19 @@ describe("workspace list command", () => { ); t.expectResult(result).toSucceed(); - const parsed = JSON.parse(result.stdout); - expect(parsed.map((w: { id: string }) => w.id)).toEqual([ - "ws-acme", - "ws-globex", + 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, + }, ]); }); diff --git a/packages/cli/tests/cli/workspace_move.spec.ts b/packages/cli/tests/cli/workspace_move.spec.ts index 0a262357c..92ca773a8 100644 --- a/packages/cli/tests/cli/workspace_move.spec.ts +++ b/packages/cli/tests/cli/workspace_move.spec.ts @@ -1,25 +1,14 @@ +import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; import { fixture, 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" }, -]; - describe("workspace move command", () => { const t = setupCLITests(); - it("moves the linked app to a target workspace", async () => { + it("moves the linked app to the target workspace", async () => { await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockListWorkspaces(WORKSPACES); - t.api.mockGetApp({ - id: "test-app-id", - name: "My App", - organization_id: "ws-personal", - }); t.api.mockMoveApp({ success: true, - message: "App successfully moved to workspace", app_id: "test-app-id", new_workspace_id: "ws-acme", }); @@ -27,13 +16,11 @@ describe("workspace move command", () => { const result = await t.run("workspace", "move", "ws-acme"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Acme Inc"); + 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.mockListWorkspaces(WORKSPACES); - t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); t.api.mockMoveApp({ success: true, app_id: "test-app-id", @@ -43,55 +30,39 @@ describe("workspace move command", () => { const result = await t.run("workspace", "move", "ws-acme", "--json"); t.expectResult(result).toSucceed(); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ success: true, newWorkspaceId: "ws-acme" }); + 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")); - t.api.mockListWorkspaces(WORKSPACES); - t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); const result = await t.run("workspace", "move"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("target workspace ID is required"); - }); - - it("fails when the target workspace is unknown", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockListWorkspaces(WORKSPACES); - t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); - - const result = await t.run("workspace", "move", "ws-nope"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("not found"); - }); - - it("fails when the app is already in the target workspace", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockListWorkspaces(WORKSPACES); - t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-acme" }); - - const result = await t.run("workspace", "move", "ws-acme"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("already in that workspace"); + t.expectResult(result).toContain( + "A target workspace ID is required in non-interactive mode", + ); }); - it("surfaces a permission error from the server", async () => { + it("surfaces the server's block reason instead of validating client-side", async () => { await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockListWorkspaces(WORKSPACES); - t.api.mockGetApp({ id: "test-app-id", organization_id: "ws-personal" }); t.api.mockMoveAppError({ status: 403, - body: { detail: "You don't have permission to move this app" }, + 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("permission"); + t.expectResult(result).toContain( + "Only workspace admins and owners can move apps out of this workspace", + ); }); }); diff --git a/packages/cli/tests/core/workspace.spec.ts b/packages/cli/tests/core/workspace.spec.ts deleted file mode 100644 index 4a033487e..000000000 --- a/packages/cli/tests/core/workspace.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - canCreateAppsInWorkspace, - MoveAppResponseSchema, - WorkspaceListResponseSchema, -} from "@/core/workspace/index.js"; - -describe("workspace schema", () => { - it("transforms the workspaces list response to camelCase", () => { - const parsed = WorkspaceListResponseSchema.parse({ - workspaces: [ - { - id: "ws-1", - name: "Personal", - user_role: "owner", - subscription_tier: "free", - is_enterprise: false, - }, - ], - }); - - expect(parsed.workspaces[0]).toEqual({ - id: "ws-1", - name: "Personal", - userRole: "owner", - subscriptionTier: "free", - isEnterprise: false, - }); - }); - - it("defaults optional workspace fields to undefined", () => { - const parsed = WorkspaceListResponseSchema.parse({ - workspaces: [{ id: "ws-1", name: "Personal" }], - }); - - expect(parsed.workspaces[0]).toEqual({ - id: "ws-1", - name: "Personal", - userRole: undefined, - subscriptionTier: undefined, - isEnterprise: undefined, - }); - }); - - it("transforms the move response to camelCase", () => { - const parsed = MoveAppResponseSchema.parse({ - success: true, - message: "moved", - app_id: "app-1", - new_workspace_id: "ws-2", - }); - - expect(parsed).toEqual({ - success: true, - message: "moved", - appId: "app-1", - newWorkspaceId: "ws-2", - }); - }); -}); - -describe("canCreateAppsInWorkspace", () => { - it("allows editor-capable roles (case-insensitive)", () => { - for (const role of ["owner", "admin", "editor", "OWNER", "Admin"]) { - expect(canCreateAppsInWorkspace(role)).toBe(true); - } - }); - - it("denies viewer, guest, unknown, and missing roles", () => { - for (const role of ["viewer", "guest", "member", undefined]) { - expect(canCreateAppsInWorkspace(role)).toBe(false); - } - }); -}); From 674129a7da89c8473a1c966f0d0dfea286336dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:06:34 +0000 Subject: [PATCH 6/6] Inline the workspace-count message (drop one-off pluralize helper) Matches the existing convention (functions/list, delete, pull, deploy all inline the `${n} thing${n !== 1 ? "s" : ""}` pattern); there is no shared pluralization util in the codebase and it's not worth adding for one call site. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- packages/cli/src/cli/commands/workspace/list.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/commands/workspace/list.ts b/packages/cli/src/cli/commands/workspace/list.ts index 45bb58f32..2fe178a6a 100644 --- a/packages/cli/src/cli/commands/workspace/list.ts +++ b/packages/cli/src/cli/commands/workspace/list.ts @@ -8,10 +8,6 @@ interface ListOptions { role?: string; } -function pluralize(n: number): string { - return `${n} workspace${n !== 1 ? "s" : ""}`; -} - async function listWorkspacesAction( { log, runTask, jsonMode }: CLIContext, options: ListOptions, @@ -27,11 +23,10 @@ async function listWorkspacesAction( workspaces = workspaces.filter((w) => w.userRole?.toLowerCase() === role); } + const summary = `${workspaces.length} workspace${workspaces.length !== 1 ? "s" : ""}`; + if (jsonMode) { - return { - outroMessage: pluralize(workspaces.length), - stdout: toJsonStdout(workspaces), - }; + return { outroMessage: summary, stdout: toJsonStdout(workspaces) }; } for (const workspace of workspaces) { @@ -40,7 +35,7 @@ async function listWorkspacesAction( ); } - return { outroMessage: pluralize(workspaces.length) }; + return { outroMessage: summary }; } export function getWorkspaceListCommand(): Command {