From e9c5d36e1e7bb4d1a10b9a7c46a3e97577daca52 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Thu, 10 Sep 2026 11:12:35 +0300 Subject: [PATCH 1/2] feat(sandbox): support app branches --- CHANGELOG.md | 1 + .../src/cli/commands/sandbox/checkpoint.ts | 9 +- .../cli/src/cli/commands/sandbox/edit-file.ts | 12 ++- packages/cli/src/cli/commands/sandbox/grep.ts | 5 +- .../cli/commands/sandbox/list-directory.ts | 5 +- .../cli/src/cli/commands/sandbox/read-file.ts | 6 +- .../src/cli/commands/sandbox/run-command.ts | 11 ++- .../cli/src/cli/commands/sandbox/shared.ts | 4 + .../src/cli/commands/sandbox/write-file.ts | 11 ++- .../cli/src/core/resources/sandbox/schema.ts | 18 ++-- packages/cli/tests/cli/sandbox.spec.ts | 83 ++++++++++++++++++- 11 files changed, 145 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 836bd87c9..17c64c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Sandbox commands accept `--branch-id ` to read, edit, run commands, and create checkpoints on a specific app branch. Omitting it continues to target main. - App visibility: `base44 visibility ` 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. diff --git a/packages/cli/src/cli/commands/sandbox/checkpoint.ts b/packages/cli/src/cli/commands/sandbox/checkpoint.ts index eeb3c1d75..dbd993b59 100644 --- a/packages/cli/src/cli/commands/sandbox/checkpoint.ts +++ b/packages/cli/src/cli/commands/sandbox/checkpoint.ts @@ -3,9 +3,10 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { createCheckpoint } from "@/core/resources/sandbox/api.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { toJsonStdout } from "./shared.js"; -interface CheckpointOptions { +interface CheckpointOptions extends SandboxBranchOptions { name?: string; } @@ -16,7 +17,10 @@ async function checkpointAction( const { id: appId } = getAppContext(); const result = await runTask("Creating checkpoint", () => - createCheckpoint(appId, { name: options.name }), + createCheckpoint(appId, { + name: options.name, + branch_id: options.branchId, + }), ); return { outroMessage: "Created checkpoint", stdout: toJsonStdout(result) }; @@ -29,6 +33,7 @@ export function getSandboxCheckpointCommand(): Command { "--name ", "Optional message/title for the checkpoint (defaults to an auto-generated title)", ) + .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/commands/sandbox/edit-file.ts b/packages/cli/src/cli/commands/sandbox/edit-file.ts index ed6a5fbb1..881145ad2 100644 --- a/packages/cli/src/cli/commands/sandbox/edit-file.ts +++ b/packages/cli/src/cli/commands/sandbox/edit-file.ts @@ -6,9 +6,10 @@ import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/index.js"; import { editFile } from "@/core/resources/sandbox/api.js"; import type { EditSpec } from "@/core/resources/sandbox/schema.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { resolveFlagOrStdin, toJsonStdout } from "./shared.js"; -interface EditFileOptions { +interface EditFileOptions extends SandboxBranchOptions { editsJson?: string; dryRun?: boolean; } @@ -52,7 +53,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: options.branchId, + }), ); return { @@ -70,6 +77,7 @@ export function getSandboxEditFileCommand(): Command { "JSON array of edits (if omitted, read from stdin)", ) .option("--dry-run", "Return the unified diff without writing") + .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/commands/sandbox/grep.ts b/packages/cli/src/cli/commands/sandbox/grep.ts index 7dd8cc46c..df499b02f 100644 --- a/packages/cli/src/cli/commands/sandbox/grep.ts +++ b/packages/cli/src/cli/commands/sandbox/grep.ts @@ -3,9 +3,10 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { grep } from "@/core/resources/sandbox/api.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface GrepOptions { +interface GrepOptions extends SandboxBranchOptions { path?: string; regex?: boolean; caseSensitive?: boolean; @@ -29,6 +30,7 @@ async function grepAction( case_sensitive: options.caseSensitive, glob: options.glob, max_results: maxResults, + branch_id: options.branchId, }), ); @@ -44,5 +46,6 @@ export function getSandboxGrepCommand(): Command { .option("--case-sensitive", "Case-sensitive match") .option("--glob ", 'File glob filter, e.g. "*.tsx"') .option("--max-results ", "Maximum number of match lines to return") + .option("--branch-id ", "Operate on a specific app branch") .action(grepAction); } diff --git a/packages/cli/src/cli/commands/sandbox/list-directory.ts b/packages/cli/src/cli/commands/sandbox/list-directory.ts index a35d311c3..025c13bda 100644 --- a/packages/cli/src/cli/commands/sandbox/list-directory.ts +++ b/packages/cli/src/cli/commands/sandbox/list-directory.ts @@ -3,9 +3,10 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { listDirectory } from "@/core/resources/sandbox/api.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface ListDirectoryOptions { +interface ListDirectoryOptions extends SandboxBranchOptions { recursive?: boolean; maxDepth?: string; includeHidden?: boolean; @@ -22,6 +23,7 @@ async function listDirectoryAction( const result = await runTask("Listing directory", () => listDirectory(appId, { path, + branch_id: options.branchId, recursive: options.recursive, max_depth: maxDepth, include_hidden: options.includeHidden, @@ -41,5 +43,6 @@ export function getSandboxListDirectoryCommand(): Command { .option("--recursive", "List nested entries") .option("--max-depth ", "Max depth when recursive (1-10, default 3)") .option("--include-hidden", "Include dotfiles") + .option("--branch-id ", "Operate on a specific app branch") .action(listDirectoryAction); } diff --git a/packages/cli/src/cli/commands/sandbox/read-file.ts b/packages/cli/src/cli/commands/sandbox/read-file.ts index 8e0e54b86..c6f21f48c 100644 --- a/packages/cli/src/cli/commands/sandbox/read-file.ts +++ b/packages/cli/src/cli/commands/sandbox/read-file.ts @@ -3,9 +3,10 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { readFile } from "@/core/resources/sandbox/api.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface ReadFileOptions { +interface ReadFileOptions extends SandboxBranchOptions { offset?: string; limit?: string; } @@ -20,7 +21,7 @@ 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: options.branchId }), ); return { outroMessage: "Read file", stdout: toJsonStdout(result) }; @@ -32,5 +33,6 @@ export function getSandboxReadFileCommand(): Command { .argument("", "One or more file paths relative to the app root") .option("--offset ", "1-based start line") .option("--limit ", "Max lines to return from offset") + .option("--branch-id ", "Operate on a specific app branch") .action(readFileAction); } diff --git a/packages/cli/src/cli/commands/sandbox/run-command.ts b/packages/cli/src/cli/commands/sandbox/run-command.ts index bdb2f9db7..b50584437 100644 --- a/packages/cli/src/cli/commands/sandbox/run-command.ts +++ b/packages/cli/src/cli/commands/sandbox/run-command.ts @@ -3,9 +3,10 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { runCommand } from "@/core/resources/sandbox/api.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface RunCommandOptions { +interface RunCommandOptions extends SandboxBranchOptions { cwd?: string; timeoutMs?: string; } @@ -20,7 +21,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: options.branchId, + }), ); // The HTTP call succeeded, so the CLI exits 0 regardless of the remote @@ -37,6 +43,7 @@ export function getSandboxRunCommandCommand(): Command { "--timeout-ms ", "Timeout in milliseconds (default 120000, max 600000)", ) + .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/commands/sandbox/shared.ts b/packages/cli/src/cli/commands/sandbox/shared.ts index baaaf2dd3..bafa8499c 100644 --- a/packages/cli/src/cli/commands/sandbox/shared.ts +++ b/packages/cli/src/cli/commands/sandbox/shared.ts @@ -5,6 +5,10 @@ import { InvalidInputError } from "@/core/errors.js"; // one implementation of the `--json` serializer. export { toJsonStdout } from "@/cli/utils/index.js"; +export interface SandboxBranchOptions { + branchId?: string; +} + /** * Resolve a payload that may come from a flag or piped stdin. * Returns the flag value when set, otherwise reads stdin (without trimming, so diff --git a/packages/cli/src/cli/commands/sandbox/write-file.ts b/packages/cli/src/cli/commands/sandbox/write-file.ts index 2a8b3127e..a50690274 100644 --- a/packages/cli/src/cli/commands/sandbox/write-file.ts +++ b/packages/cli/src/cli/commands/sandbox/write-file.ts @@ -3,9 +3,10 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { writeFile } from "@/core/resources/sandbox/api.js"; +import type { SandboxBranchOptions } from "./shared.js"; import { resolveFlagOrStdin, toJsonStdout } from "./shared.js"; -interface WriteFileOptions { +interface WriteFileOptions extends SandboxBranchOptions { content?: string; overwrite?: boolean; } @@ -19,7 +20,12 @@ async function writeFileAction( 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: options.branchId, + }), ); return { outroMessage: "Wrote file", stdout: toJsonStdout(result) }; @@ -31,6 +37,7 @@ export function getSandboxWriteFileCommand(): Command { .argument("", "File path relative to the app root") .option("--content ", "File content (if omitted, read from stdin)") .option("--overwrite", "Overwrite the file if it already exists") + .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/core/resources/sandbox/schema.ts b/packages/cli/src/core/resources/sandbox/schema.ts index 01052bddc..007871e87 100644 --- a/packages/cli/src/core/resources/sandbox/schema.ts +++ b/packages/cli/src/core/resources/sandbox/schema.ts @@ -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; @@ -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; @@ -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; } diff --git a/packages/cli/tests/cli/sandbox.spec.ts b/packages/cli/tests/cli/sandbox.spec.ts index 18c20dfae..66f0dd0e4 100644 --- a/packages/cli/tests/cli/sandbox.spec.ts +++ b/packages/cli/tests/cli/sandbox.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { setupCLITests } from "./testkit/index.js"; const APP_ID = "test-app-id"; +const BRANCH_ID = "branch-123"; const base = `/api/apps/${APP_ID}/sandbox-bridge`; describe("sandbox commands", () => { @@ -10,7 +11,8 @@ describe("sandbox commands", () => { it("ls prints the JSON result", async () => { // Given await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - t.api.mockRoute("POST", `${base}/list_directory`, (_req, res) => { + t.api.mockRoute("POST", `${base}/list_directory`, (req, res) => { + expect(req.body.branch_id).toBeUndefined(); res.status(200).json({ entries: [{ name: "src", path: "src", type: "directory" }], truncated: false, @@ -25,6 +27,85 @@ describe("sandbox commands", () => { t.expectResult(result).toContain('"type": "directory"'); }); + it.each([ + { + command: ["ls"], + endpoint: "list_directory", + response: { entries: [], truncated: false }, + }, + { + command: ["read", "notes.txt"], + endpoint: "read_file", + response: { files: [] }, + }, + { + command: ["write", "notes.txt", "--content", "hello"], + endpoint: "write_file", + response: { + path: "notes.txt", + bytes_written: 5, + created: true, + overwritten: false, + }, + }, + { + command: [ + "edit", + "notes.txt", + "--edits-json", + '[{"old_text":"a","new_text":"b"}]', + ], + endpoint: "edit_file", + response: { path: "notes.txt", diff: "", applied: true }, + }, + { + command: ["grep", "hello"], + endpoint: "grep", + response: { matches: [], truncated: false, returned_matches: 0 }, + }, + { + command: ["run", "pwd"], + endpoint: "run_command", + response: { + stdout: "/app", + stderr: "", + exit_code: 0, + truncated: false, + duration_ms: 1, + }, + }, + { + command: ["checkpoint"], + endpoint: "create_checkpoint", + response: { + checkpoint_id: "cp_123", + name: null, + git_commit_hash: "abc123", + }, + }, + ])("$command.0 forwards --branch-id", async ({ + command, + endpoint, + response, + }) => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockRoute("POST", `${base}/${endpoint}`, (req, res) => { + expect(req.body.branch_id).toBe(BRANCH_ID); + res.status(200).json(response); + }); + + const result = await t.run( + "sandbox", + ...command, + "--branch-id", + BRANCH_ID, + "--app-id", + APP_ID, + ); + + t.expectResult(result).toSucceed(); + }); + it("--json writes a pure JSON document to stdout (status on stderr)", async () => { // Given await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); From 1f7e0377a6dc8841f90740f005938910d138cd7a Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Thu, 10 Sep 2026 18:44:45 +0300 Subject: [PATCH 2/2] Make branch targeting global and reject unsupported commands --- CHANGELOG.md | 2 +- docs/commands.md | 13 ++++++ .../src/cli/commands/sandbox/checkpoint.ts | 10 ++--- .../cli/src/cli/commands/sandbox/edit-file.ts | 10 ++--- packages/cli/src/cli/commands/sandbox/grep.ts | 10 ++--- .../cli/commands/sandbox/list-directory.ts | 10 ++--- .../cli/src/cli/commands/sandbox/read-file.ts | 10 ++--- .../src/cli/commands/sandbox/run-command.ts | 10 ++--- .../cli/src/cli/commands/sandbox/shared.ts | 4 -- .../src/cli/commands/sandbox/write-file.ts | 10 ++--- packages/cli/src/cli/program.ts | 1 + packages/cli/src/cli/types.ts | 1 + .../src/cli/utils/command/Base44Command.ts | 15 ++++++- packages/cli/tests/cli/sandbox.spec.ts | 40 +++++++++++++++++++ 14 files changed, 97 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c64c61d..3e58bb52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Sandbox commands accept `--branch-id ` to read, edit, run commands, and create checkpoints on a specific app branch. Omitting it continues to target main. +- Global `--branch-id ` targets sandbox commands at a specific app branch. Other commands reject it explicitly; omitting it continues to target main. - App visibility: `base44 visibility ` 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. diff --git a/docs/commands.md b/docs/commands.md index 1ca0ef6db..bfa7abf0d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,6 +4,19 @@ Commands live in `src/cli/commands//`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`. +## Branch targeting + +`--branch-id ` is global, but currently supported only by sandbox commands. +For example: `base44 --branch-id sandbox read --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 diff --git a/packages/cli/src/cli/commands/sandbox/checkpoint.ts b/packages/cli/src/cli/commands/sandbox/checkpoint.ts index dbd993b59..c31c00c78 100644 --- a/packages/cli/src/cli/commands/sandbox/checkpoint.ts +++ b/packages/cli/src/cli/commands/sandbox/checkpoint.ts @@ -3,15 +3,14 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { createCheckpoint } from "@/core/resources/sandbox/api.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { toJsonStdout } from "./shared.js"; -interface CheckpointOptions extends SandboxBranchOptions { +interface CheckpointOptions { name?: string; } async function checkpointAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, options: CheckpointOptions, ): Promise { const { id: appId } = getAppContext(); @@ -19,7 +18,7 @@ async function checkpointAction( const result = await runTask("Creating checkpoint", () => createCheckpoint(appId, { name: options.name, - branch_id: options.branchId, + branch_id: branchId, }), ); @@ -27,13 +26,12 @@ async function checkpointAction( } 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 ", "Optional message/title for the checkpoint (defaults to an auto-generated title)", ) - .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/commands/sandbox/edit-file.ts b/packages/cli/src/cli/commands/sandbox/edit-file.ts index 881145ad2..61c4f1c12 100644 --- a/packages/cli/src/cli/commands/sandbox/edit-file.ts +++ b/packages/cli/src/cli/commands/sandbox/edit-file.ts @@ -6,10 +6,9 @@ import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/index.js"; import { editFile } from "@/core/resources/sandbox/api.js"; import type { EditSpec } from "@/core/resources/sandbox/schema.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { resolveFlagOrStdin, toJsonStdout } from "./shared.js"; -interface EditFileOptions extends SandboxBranchOptions { +interface EditFileOptions { editsJson?: string; dryRun?: boolean; } @@ -43,7 +42,7 @@ function parseEdits(raw: string): EditSpec[] { } async function editFileAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, path: string, options: EditFileOptions, ): Promise { @@ -58,7 +57,7 @@ async function editFileAction( path, edits, dry_run: options.dryRun, - branch_id: options.branchId, + branch_id: branchId, }), ); @@ -69,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("", "File path relative to the app root") .option( @@ -77,7 +76,6 @@ export function getSandboxEditFileCommand(): Command { "JSON array of edits (if omitted, read from stdin)", ) .option("--dry-run", "Return the unified diff without writing") - .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/commands/sandbox/grep.ts b/packages/cli/src/cli/commands/sandbox/grep.ts index df499b02f..229e90dbb 100644 --- a/packages/cli/src/cli/commands/sandbox/grep.ts +++ b/packages/cli/src/cli/commands/sandbox/grep.ts @@ -3,10 +3,9 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { grep } from "@/core/resources/sandbox/api.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface GrepOptions extends SandboxBranchOptions { +interface GrepOptions { path?: string; regex?: boolean; caseSensitive?: boolean; @@ -15,7 +14,7 @@ interface GrepOptions extends SandboxBranchOptions { } async function grepAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, pattern: string, options: GrepOptions, ): Promise { @@ -30,7 +29,7 @@ async function grepAction( case_sensitive: options.caseSensitive, glob: options.glob, max_results: maxResults, - branch_id: options.branchId, + branch_id: branchId, }), ); @@ -38,7 +37,7 @@ async function grepAction( } 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("", "Search pattern") .option("--path ", "Subtree to search, relative to the app root") @@ -46,6 +45,5 @@ export function getSandboxGrepCommand(): Command { .option("--case-sensitive", "Case-sensitive match") .option("--glob ", 'File glob filter, e.g. "*.tsx"') .option("--max-results ", "Maximum number of match lines to return") - .option("--branch-id ", "Operate on a specific app branch") .action(grepAction); } diff --git a/packages/cli/src/cli/commands/sandbox/list-directory.ts b/packages/cli/src/cli/commands/sandbox/list-directory.ts index 025c13bda..43036da07 100644 --- a/packages/cli/src/cli/commands/sandbox/list-directory.ts +++ b/packages/cli/src/cli/commands/sandbox/list-directory.ts @@ -3,17 +3,16 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { listDirectory } from "@/core/resources/sandbox/api.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface ListDirectoryOptions extends SandboxBranchOptions { +interface ListDirectoryOptions { recursive?: boolean; maxDepth?: string; includeHidden?: boolean; } async function listDirectoryAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, path: string | undefined, options: ListDirectoryOptions, ): Promise { @@ -23,7 +22,7 @@ async function listDirectoryAction( const result = await runTask("Listing directory", () => listDirectory(appId, { path, - branch_id: options.branchId, + branch_id: branchId, recursive: options.recursive, max_depth: maxDepth, include_hidden: options.includeHidden, @@ -34,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]", @@ -43,6 +42,5 @@ export function getSandboxListDirectoryCommand(): Command { .option("--recursive", "List nested entries") .option("--max-depth ", "Max depth when recursive (1-10, default 3)") .option("--include-hidden", "Include dotfiles") - .option("--branch-id ", "Operate on a specific app branch") .action(listDirectoryAction); } diff --git a/packages/cli/src/cli/commands/sandbox/read-file.ts b/packages/cli/src/cli/commands/sandbox/read-file.ts index c6f21f48c..0fb786741 100644 --- a/packages/cli/src/cli/commands/sandbox/read-file.ts +++ b/packages/cli/src/cli/commands/sandbox/read-file.ts @@ -3,16 +3,15 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { readFile } from "@/core/resources/sandbox/api.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface ReadFileOptions extends SandboxBranchOptions { +interface ReadFileOptions { offset?: string; limit?: string; } async function readFileAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, paths: string[], options: ReadFileOptions, ): Promise { @@ -21,18 +20,17 @@ async function readFileAction( const limit = parsePositiveInt(options.limit, "--limit"); const result = await runTask("Reading file", () => - readFile(appId, { paths, offset, limit, branch_id: options.branchId }), + 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("", "One or more file paths relative to the app root") .option("--offset ", "1-based start line") .option("--limit ", "Max lines to return from offset") - .option("--branch-id ", "Operate on a specific app branch") .action(readFileAction); } diff --git a/packages/cli/src/cli/commands/sandbox/run-command.ts b/packages/cli/src/cli/commands/sandbox/run-command.ts index b50584437..2d13489aa 100644 --- a/packages/cli/src/cli/commands/sandbox/run-command.ts +++ b/packages/cli/src/cli/commands/sandbox/run-command.ts @@ -3,16 +3,15 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { runCommand } from "@/core/resources/sandbox/api.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { parsePositiveInt, toJsonStdout } from "./shared.js"; -interface RunCommandOptions extends SandboxBranchOptions { +interface RunCommandOptions { cwd?: string; timeoutMs?: string; } async function runCommandAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, commandParts: string[], options: RunCommandOptions, ): Promise { @@ -25,7 +24,7 @@ async function runCommandAction( command, cwd: options.cwd, timeout_ms: timeoutMs, - branch_id: options.branchId, + branch_id: branchId, }), ); @@ -35,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("", "Shell command to execute (quote to keep as one)") .option("--cwd ", "Working directory relative to the app root") @@ -43,7 +42,6 @@ export function getSandboxRunCommandCommand(): Command { "--timeout-ms ", "Timeout in milliseconds (default 120000, max 600000)", ) - .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/commands/sandbox/shared.ts b/packages/cli/src/cli/commands/sandbox/shared.ts index bafa8499c..baaaf2dd3 100644 --- a/packages/cli/src/cli/commands/sandbox/shared.ts +++ b/packages/cli/src/cli/commands/sandbox/shared.ts @@ -5,10 +5,6 @@ import { InvalidInputError } from "@/core/errors.js"; // one implementation of the `--json` serializer. export { toJsonStdout } from "@/cli/utils/index.js"; -export interface SandboxBranchOptions { - branchId?: string; -} - /** * Resolve a payload that may come from a flag or piped stdin. * Returns the flag value when set, otherwise reads stdin (without trimming, so diff --git a/packages/cli/src/cli/commands/sandbox/write-file.ts b/packages/cli/src/cli/commands/sandbox/write-file.ts index a50690274..afd3cf08d 100644 --- a/packages/cli/src/cli/commands/sandbox/write-file.ts +++ b/packages/cli/src/cli/commands/sandbox/write-file.ts @@ -3,16 +3,15 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { writeFile } from "@/core/resources/sandbox/api.js"; -import type { SandboxBranchOptions } from "./shared.js"; import { resolveFlagOrStdin, toJsonStdout } from "./shared.js"; -interface WriteFileOptions extends SandboxBranchOptions { +interface WriteFileOptions { content?: string; overwrite?: boolean; } async function writeFileAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, path: string, options: WriteFileOptions, ): Promise { @@ -24,7 +23,7 @@ async function writeFileAction( path, content, overwrite: options.overwrite, - branch_id: options.branchId, + branch_id: branchId, }), ); @@ -32,12 +31,11 @@ async function writeFileAction( } 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("", "File path relative to the app root") .option("--content ", "File content (if omitted, read from stdin)") .option("--overwrite", "Overwrite the file if it already exists") - .option("--branch-id ", "Operate on a specific app branch") .addHelpText( "after", ` diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index fe8bc8f6a..2a2df69d3 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -39,6 +39,7 @@ export function createProgram(context: CLIContext): Command { "Base44 CLI - Unified interface for managing Base44 applications", ) .version(packageJson.version) + .option("--branch-id ", "Target an app branch (sandbox commands only)") .addOption( new Option("--app-id ", "Base44 app ID to use").env( BASE44_APP_ID_ENV_VAR, diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index efb23a242..89b472e7f 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -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; /** diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 903dc2b37..f1a446c76 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -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` @@ -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. @@ -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, }; } @@ -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); } @@ -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) { diff --git a/packages/cli/tests/cli/sandbox.spec.ts b/packages/cli/tests/cli/sandbox.spec.ts index 66f0dd0e4..3be1c9b36 100644 --- a/packages/cli/tests/cli/sandbox.spec.ts +++ b/packages/cli/tests/cli/sandbox.spec.ts @@ -8,6 +8,46 @@ const base = `/api/apps/${APP_ID}/sandbox-bridge`; describe("sandbox commands", () => { const t = setupCLITests(); + it("accepts --branch-id before the subcommand", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockRoute("POST", `${base}/list_directory`, (req, res) => { + expect(req.body.branch_id).toBe(BRANCH_ID); + res.json({ entries: [], truncated: false }); + }); + + const result = await t.run( + "--branch-id", + BRANCH_ID, + "sandbox", + "ls", + "--app-id", + APP_ID, + ); + t.expectResult(result).toSucceed(); + }); + + it.each([ + ["functions", "pull"], + ["functions", "list"], + ["entities", "push"], + ["deploy"], + ["login"], + ])("rejects branch scope for %s %s before authentication", async (...command) => { + const result = await t.run(...command, "--branch-id", BRANCH_ID, "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain( + "--branch-id is not supported by this command", + ); + }); + + it("rejects an empty branch instead of falling back to main", async () => { + const result = await t.run("sandbox", "ls", "--branch-id", " ", "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toBe( + "--branch-id must not be empty.", + ); + }); + it("ls prints the JSON result", async () => { // Given await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });