Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Global `--branch-id <id>` targets sandbox commands at a specific app branch. Other commands reject it explicitly; omitting it continues to target main.
- App visibility: `base44 visibility <public|private|workspace>` sets it on the server directly (accepts `--app-id` to target any app). Also configurable via `"visibility"` in `config.jsonc`, which `base44 deploy` applies. New projects scaffold `"visibility": "public"`.
- `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id.
- `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt.
Expand Down
13 changes: 13 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

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

## Branch targeting

`--branch-id <id>` is global, but currently supported only by sandbox commands.
For example: `base44 --branch-id <id> sandbox read <path> --app-id <app-id>`.
Other commands (including `functions pull`, `functions list`, and `entities push`)
reject it before authentication or command execution, rather than silently targeting main.
There is no `entities pull` command; branch source files can be read through `sandbox read`.

To add support, set `supportsBranch: true` on the command and consume
`ctx.branchId`. First verify that every backend operation honors that scope;
accepting the query parameter alone is not proof of branch isolation.
Omitting the flag preserves existing main-app behavior.

## Command File Template

```typescript
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/cli/commands/sandbox/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,23 @@ interface CheckpointOptions {
}

async function checkpointAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
options: CheckpointOptions,
): Promise<RunCommandResult> {
const { id: appId } = getAppContext();

const result = await runTask("Creating checkpoint", () =>
createCheckpoint(appId, { name: options.name }),
createCheckpoint(appId, {
name: options.name,
branch_id: branchId,
}),
);

return { outroMessage: "Created checkpoint", stdout: toJsonStdout(result) };
}

export function getSandboxCheckpointCommand(): Command {
return new Base44Command("checkpoint")
return new Base44Command("checkpoint", { supportsBranch: true })
.description("Create a restore-point checkpoint of an app's remote sandbox")
.option(
"--name <name>",
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/cli/commands/sandbox/edit-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function parseEdits(raw: string): EditSpec[] {
}

async function editFileAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
path: string,
options: EditFileOptions,
): Promise<RunCommandResult> {
Expand All @@ -52,7 +52,13 @@ async function editFileAction(

const result = await runTask(
options.dryRun ? "Previewing edit" : "Editing file",
() => editFile(appId, { path, edits, dry_run: options.dryRun }),
() =>
editFile(appId, {
path,
edits,
dry_run: options.dryRun,
branch_id: branchId,
}),
);

return {
Expand All @@ -62,7 +68,7 @@ async function editFileAction(
}

export function getSandboxEditFileCommand(): Command {
return new Base44Command("edit")
return new Base44Command("edit", { supportsBranch: true })
.description("Apply exact old→new string edits to a file in the sandbox")
.argument("<path>", "File path relative to the app root")
.option(
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/cli/commands/sandbox/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface GrepOptions {
}

async function grepAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
pattern: string,
options: GrepOptions,
): Promise<RunCommandResult> {
Expand All @@ -29,14 +29,15 @@ async function grepAction(
case_sensitive: options.caseSensitive,
glob: options.glob,
max_results: maxResults,
branch_id: branchId,
}),
);

return { outroMessage: "Searched files", stdout: toJsonStdout(result) };
}

export function getSandboxGrepCommand(): Command {
return new Base44Command("grep")
return new Base44Command("grep", { supportsBranch: true })
.description("Search files for a pattern in an app's remote sandbox")
.argument("<pattern>", "Search pattern")
.option("--path <path>", "Subtree to search, relative to the app root")
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/cli/commands/sandbox/list-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface ListDirectoryOptions {
}

async function listDirectoryAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
path: string | undefined,
options: ListDirectoryOptions,
): Promise<RunCommandResult> {
Expand All @@ -22,6 +22,7 @@ async function listDirectoryAction(
const result = await runTask("Listing directory", () =>
listDirectory(appId, {
path,
branch_id: branchId,
recursive: options.recursive,
max_depth: maxDepth,
include_hidden: options.includeHidden,
Expand All @@ -32,7 +33,7 @@ async function listDirectoryAction(
}

export function getSandboxListDirectoryCommand(): Command {
return new Base44Command("ls")
return new Base44Command("ls", { supportsBranch: true })
.description("List directory entries in an app's remote sandbox")
.argument(
"[path]",
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/cli/commands/sandbox/read-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface ReadFileOptions {
}

async function readFileAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
paths: string[],
options: ReadFileOptions,
): Promise<RunCommandResult> {
Expand All @@ -20,14 +20,14 @@ async function readFileAction(
const limit = parsePositiveInt(options.limit, "--limit");

const result = await runTask("Reading file", () =>
readFile(appId, { paths, offset, limit }),
readFile(appId, { paths, offset, limit, branch_id: branchId }),
);

return { outroMessage: "Read file", stdout: toJsonStdout(result) };
}

export function getSandboxReadFileCommand(): Command {
return new Base44Command("read")
return new Base44Command("read", { supportsBranch: true })
.description("Read file contents from an app's remote sandbox")
.argument("<paths...>", "One or more file paths relative to the app root")
.option("--offset <n>", "1-based start line")
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/cli/commands/sandbox/run-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface RunCommandOptions {
}

async function runCommandAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
commandParts: string[],
options: RunCommandOptions,
): Promise<RunCommandResult> {
Expand All @@ -20,7 +20,12 @@ async function runCommandAction(
const command = commandParts.join(" ");

const result = await runTask("Running command", () =>
runCommand(appId, { command, cwd: options.cwd, timeout_ms: timeoutMs }),
runCommand(appId, {
command,
cwd: options.cwd,
timeout_ms: timeoutMs,
branch_id: branchId,
}),
);

// The HTTP call succeeded, so the CLI exits 0 regardless of the remote
Expand All @@ -29,7 +34,7 @@ async function runCommandAction(
}

export function getSandboxRunCommandCommand(): Command {
return new Base44Command("run")
return new Base44Command("run", { supportsBranch: true })
.description("Run a shell command in an app's remote sandbox")
.argument("<command...>", "Shell command to execute (quote to keep as one)")
.option("--cwd <path>", "Working directory relative to the app root")
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/cli/commands/sandbox/write-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ interface WriteFileOptions {
}

async function writeFileAction(
{ runTask }: CLIContext,
{ runTask, branchId }: CLIContext,
path: string,
options: WriteFileOptions,
): Promise<RunCommandResult> {
const { id: appId } = getAppContext();
const content = await resolveFlagOrStdin(options.content, "--content");

const result = await runTask("Writing file", () =>
writeFile(appId, { path, content, overwrite: options.overwrite }),
writeFile(appId, {
path,
content,
overwrite: options.overwrite,
branch_id: branchId,
}),
);

return { outroMessage: "Wrote file", stdout: toJsonStdout(result) };
}

export function getSandboxWriteFileCommand(): Command {
return new Base44Command("write")
return new Base44Command("write", { supportsBranch: true })
.description("Create or overwrite a file in an app's remote sandbox")
.argument("<path>", "File path relative to the app root")
.option("--content <content>", "File content (if omitted, read from stdin)")
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function createProgram(context: CLIContext): Command {
"Base44 CLI - Unified interface for managing Base44 applications",
)
.version(packageJson.version)
.option("--branch-id <id>", "Target an app branch (sandbox commands only)")
.addOption(
new Option("--app-id <id>", "Base44 app ID to use").env(
BASE44_APP_ID_ENV_VAR,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RunTaskFn } from "./utils/runTask.js";
export type Distribution = "npm" | "binary";

export interface CLIContext {
branchId?: string;
errorReporter: ErrorReporter;
isNonInteractive: boolean;
/**
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/cli/utils/command/Base44Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
formatPlainUpgradeMessage,
startUpgradeCheck,
} from "@/cli/utils/upgradeNotification.js";
import { ApiError, isCLIError } from "@/core/errors.js";
import { ApiError, InvalidInputError, isCLIError } from "@/core/errors.js";

/**
* Write a command result to stdout as a single JSON document (the `--json`
Expand Down Expand Up @@ -67,6 +67,7 @@ function writeJsonError(error: unknown): void {
}

interface Base44CommandOptions {
supportsBranch?: boolean;
/**
* Require user authentication before running this command.
* If the user is not logged in, they will be prompted to login.
Expand Down Expand Up @@ -130,6 +131,7 @@ export class Base44Command extends Command {
requireAuth: options?.requireAuth ?? true,
requireAppContext: options?.requireAppContext ?? true,
fullBanner: options?.fullBanner ?? false,
supportsBranch: options?.supportsBranch ?? false,
};
}

Expand Down Expand Up @@ -172,6 +174,15 @@ export class Base44Command extends Command {
const upgradeCheckPromise = startUpgradeCheck();

try {
const { branchId } = this.optsWithGlobals<{ branchId?: string }>();
if (branchId !== undefined && !this._commandOptions.supportsBranch) {
throw new InvalidInputError(
`--branch-id is not supported by this command. Use sandbox commands to read or edit branch files; no app changes were made.`,
);
}
if (branchId !== undefined && !branchId.trim()) {
throw new InvalidInputError("--branch-id must not be empty.");
}
if (this._commandOptions.requireAuth) {
await ensureAuth(this.context);
}
Expand All @@ -180,7 +191,7 @@ export class Base44Command extends Command {
await ensureAppContext(this.context, { appId });
}

const result = ((await fn(this.context, ...args)) ??
const result = ((await fn({ ...this.context, branchId }, ...args)) ??
{}) as RunCommandResult;

if (!quiet) {
Expand Down
18 changes: 11 additions & 7 deletions packages/cli/src/core/resources/sandbox/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@ import { z } from "zod";
// Sent to the backend as-is (snake_case). The `app_id` is carried in the URL
// path by getSandboxClient(), so it is never part of these payloads.

export interface ListDirectoryParams {
export interface SandboxScopeParams {
branch_id?: string;
}

export interface ListDirectoryParams extends SandboxScopeParams {
path?: string;
recursive?: boolean;
max_depth?: number;
include_hidden?: boolean;
}

export interface ReadFileParams {
export interface ReadFileParams extends SandboxScopeParams {
paths: string[];
offset?: number;
limit?: number;
}

export interface WriteFileParams {
export interface WriteFileParams extends SandboxScopeParams {
path: string;
content: string;
overwrite?: boolean;
Expand All @@ -29,13 +33,13 @@ export interface EditSpec {
replace_all?: boolean;
}

export interface EditFileParams {
export interface EditFileParams extends SandboxScopeParams {
path: string;
edits: EditSpec[];
dry_run?: boolean;
}

export interface GrepParams {
export interface GrepParams extends SandboxScopeParams {
pattern: string;
path?: string;
is_regex?: boolean;
Expand All @@ -44,13 +48,13 @@ export interface GrepParams {
max_results?: number;
}

export interface RunCommandParams {
export interface RunCommandParams extends SandboxScopeParams {
command: string;
cwd?: string;
timeout_ms?: number;
}

export interface CreateCheckpointParams {
export interface CreateCheckpointParams extends SandboxScopeParams {
name?: string;
}

Expand Down
Loading