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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 46 additions & 21 deletions packages/cli/src/cli/commands/project/create.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -25,13 +25,16 @@ import {
getTemplateById,
printProjectSummary,
} from "./scaffold-shared.js";
import { resolveWorkspaceId } from "./workspace-select.js";

interface CreateOptions {
name?: string;
path?: string;
template?: string;
deploy?: boolean;
skills?: boolean;
workspace?: string;
org?: string;
}

function validateCreateOptions(command: Command): void {
Expand Down Expand Up @@ -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,
Expand All @@ -130,6 +134,7 @@ async function createNonInteractive(
projectPath: options.path!,
deploy: options.deploy,
skills: options.skills,
workspaceId: options.workspace ?? options.org,
isInteractive: false,
},
ctx,
Expand All @@ -144,6 +149,7 @@ async function executeCreate(
projectPath,
deploy,
skills,
workspaceId: flagWorkspaceId,
isInteractive,
}: {
template: Template;
Expand All @@ -152,6 +158,7 @@ async function executeCreate(
projectPath: string;
deploy?: boolean;
skills?: boolean;
workspaceId?: string;
isInteractive: boolean;
},
ctx: CLIContext,
Expand All @@ -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 () => {
Expand All @@ -168,6 +181,7 @@ async function executeCreate(
description: description?.trim(),
path: resolvedPath,
template,
organizationId,
});
},
{
Expand Down Expand Up @@ -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>", "Path where to create the project")
.option(
"-t, --template <id>",
"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>", "Path where to create the project")
.option(
"-t, --template <id>",
"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 <id>",
"Workspace (organization) ID to create the app in (defaults to your personal workspace)",
)
// Hidden alias for --workspace.
.addOption(
new CommanderOption("--org <id>", "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)
);
}
18 changes: 17 additions & 1 deletion packages/cli/src/cli/commands/project/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -282,6 +291,13 @@ export function getLinkCommand(): Command {
"Project name (required when --create is used)",
)
.option("-d, --description <description>", "Project description")
.option(
"-w, --workspace <id>",
"Workspace (organization) ID to create the app in when using --create (defaults to your personal workspace)",
)
.addOption(
new CommanderOption("--org <id>", "Alias for --workspace").hideHelp(),
)
// TODO: Remove legacy --project-id aliases once docs and Base44 CLI skills use --app-id.
.addOption(
new CommanderOption(
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/cli/commands/project/workspace-select.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceListEntry[]> {
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 <id>` 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<string | undefined> {
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<string>[] = 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;
}
11 changes: 4 additions & 7 deletions packages/cli/src/cli/commands/sandbox/shared.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/cli/commands/workspace/get.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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-id>", "Workspace (organization) ID")
.action(getWorkspaceAction);
}
12 changes: 12 additions & 0 deletions packages/cli/src/cli/commands/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -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());
}
49 changes: 49 additions & 0 deletions packages/cli/src/cli/commands/workspace/list.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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 <role>",
"Only workspaces where your role matches (owner, admin, editor, viewer)",
)
.action(listWorkspacesAction);
}
Loading
Loading