From e9c5d36e1e7bb4d1a10b9a7c46a3e97577daca52 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Thu, 10 Sep 2026 11:12:35 +0300 Subject: [PATCH 1/5] 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/5] 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" }); From 3c24cffef51f085403f5b02fb28b2e8d35c55153 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Mon, 14 Sep 2026 10:00:10 +0300 Subject: [PATCH 3/5] Resolve sandbox branch names alongside explicit branch IDs --- CHANGELOG.md | 2 +- docs/commands.md | 5 +- packages/cli/src/cli/program.ts | 4 + .../src/cli/utils/command/Base44Command.ts | 29 +++++- packages/cli/src/core/resources/branch/api.ts | 45 +++++++++ packages/cli/tests/cli/branch_scope.spec.ts | 94 +++++++++++++++++++ 6 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/core/resources/branch/api.ts create mode 100644 packages/cli/tests/cli/branch_scope.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e58bb52d..da1cf121a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Global `--branch-id ` targets sandbox commands at a specific app branch. Other commands reject it explicitly; omitting it continues to target main. +- Global `--branch ` or `--branch-id ` targets sandbox commands at a specific app branch. Names resolve within the selected app; missing or ambiguous names fail. Other commands reject branch flags explicitly; omitting them or using `--branch main` targets 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 bfa7abf0d..a3a5b6c5d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,7 +6,10 @@ Commands live in `src/cli/commands//`. They use a **factory pattern** ## Branch targeting -`--branch-id ` is global, but currently supported only by sandbox commands. +`--branch ` and `--branch-id ` are global, but currently supported only by sandbox commands. +Use one per command: `--branch feature/checkout` resolves an exact name within the +selected app; `--branch-id` uses the ID directly without a lookup. Missing or ambiguous +names fail. `--branch main` explicitly targets main. No checkout state is saved. 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. diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 2a2df69d3..0ee9f7f55 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -40,6 +40,10 @@ export function createProgram(context: CLIContext): Command { ) .version(packageJson.version) .option("--branch-id ", "Target an app branch (sandbox commands only)") + .option( + "--branch ", + "Target an app branch by exact name (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/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index f1a446c76..90613cea2 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -16,6 +16,7 @@ import { startUpgradeCheck, } from "@/cli/utils/upgradeNotification.js"; import { ApiError, InvalidInputError, isCLIError } from "@/core/errors.js"; +import { resolveBranchName } from "@/core/resources/branch/api.js"; /** * Write a command result to stdout as a single JSON document (the `--json` @@ -174,12 +175,26 @@ export class Base44Command extends Command { const upgradeCheckPromise = startUpgradeCheck(); try { - const { branchId } = this.optsWithGlobals<{ branchId?: string }>(); - if (branchId !== undefined && !this._commandOptions.supportsBranch) { + const { branch, branchId } = this.optsWithGlobals<{ + branch?: string; + branchId?: string; + }>(); + if (branch !== undefined && branchId !== undefined) { 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.`, + "Use either --branch or --branch-id, not both.", ); } + if ( + (branch !== undefined || branchId !== undefined) && + !this._commandOptions.supportsBranch + ) { + throw new InvalidInputError( + `${branch !== undefined ? "--branch" : "--branch-id"} is not supported by this command. Use sandbox commands to read or edit branch files; no app changes were made.`, + ); + } + if (branch !== undefined && !branch.trim()) { + throw new InvalidInputError("--branch must not be empty."); + } if (branchId !== undefined && !branchId.trim()) { throw new InvalidInputError("--branch-id must not be empty."); } @@ -191,8 +206,12 @@ export class Base44Command extends Command { await ensureAppContext(this.context, { appId }); } - const result = ((await fn({ ...this.context, branchId }, ...args)) ?? - {}) as RunCommandResult; + const resolvedBranchId = + branch !== undefined ? await resolveBranchName(branch) : branchId; + const result = ((await fn( + { ...this.context, branchId: resolvedBranchId }, + ...args, + )) ?? {}) as RunCommandResult; if (!quiet) { await showCommandEnd( diff --git a/packages/cli/src/core/resources/branch/api.ts b/packages/cli/src/core/resources/branch/api.ts new file mode 100644 index 000000000..67acf5566 --- /dev/null +++ b/packages/cli/src/core/resources/branch/api.ts @@ -0,0 +1,45 @@ +import type { KyResponse } from "ky"; +import { z } from "zod"; +import { getAppClient } from "@/core/clients/index.js"; +import { + ApiError, + InvalidInputError, + SchemaValidationError, +} from "@/core/errors.js"; + +const BranchesSchema = z.array( + z.object({ + id: z.string().min(1), + branch_name: z.string(), + }), +); + +export async function resolveBranchName( + name: string, +): Promise { + if (name === "main") return undefined; + + let response: KyResponse; + try { + response = await getAppClient().get("branches", { timeout: 30_000 }); + } catch (error) { + throw await ApiError.fromHttpError(error, "resolving branch name"); + } + const result = BranchesSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid branches response from server", + result.error, + ); + } + const matches = result.data.filter((branch) => branch.branch_name === name); + if (matches.length === 0) { + throw new InvalidInputError(`Branch "${name}" was not found in this app.`); + } + if (matches.length > 1) { + throw new InvalidInputError( + `Branch name "${name}" is ambiguous. Use --branch-id.`, + ); + } + return matches[0].id; +} diff --git a/packages/cli/tests/cli/branch_scope.spec.ts b/packages/cli/tests/cli/branch_scope.spec.ts new file mode 100644 index 000000000..ccd5b0374 --- /dev/null +++ b/packages/cli/tests/cli/branch_scope.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +describe("branch name targeting", () => { + const t = setupCLITests(); + const appId = "test-app-id"; + const branch = { id: "branch-123", branch_name: "feature/checkout" }; + + it.each([ + "feature/checkout", + "main", + ])("resolves %s to the expected sandbox", async (name) => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + let lookups = 0; + t.api.mockRoute("GET", `/api/apps/${appId}/branches`, (_req, res) => { + lookups++; + res.json([branch]); + }); + t.api.mockRoute( + "POST", + `/api/apps/${appId}/sandbox-bridge/list_directory`, + (req, res) => { + expect(req.body.branch_id).toBe( + name === "main" ? undefined : branch.id, + ); + res.json({ entries: [], truncated: false }); + }, + ); + const result = await t.run( + "--branch", + name, + "sandbox", + "ls", + "--app-id", + appId, + ); + t.expectResult(result).toSucceed(); + expect(lookups).toBe(name === "main" ? 0 : 1); + }); + + it.each([ + { response: [], error: "was not found" }, + { response: [branch, { ...branch, id: "other" }], error: "ambiguous" }, + { + response: [{ branch_name: branch.branch_name }], + error: "Invalid branches response", + }, + ])("rejects an unresolved name: $error", async ({ response, error }) => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + let writes = 0; + t.api.mockRoute("GET", `/api/apps/${appId}/branches`, (_req, res) => + res.json(response), + ); + t.api.mockRoute( + "POST", + `/api/apps/${appId}/sandbox-bridge/write_file`, + (_req, res) => { + writes++; + res.json({}); + }, + ); + const result = await t.run( + "sandbox", + "write", + "test.txt", + "--content", + "test", + "--branch", + branch.branch_name, + "--app-id", + appId, + "--json", + ); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain(error); + expect(writes).toBe(0); + }); + + it.each([ + { + args: ["sandbox", "ls", "--branch", "main", "--branch-id", "id"], + error: "not both", + }, + { args: ["sandbox", "ls", "--branch", ""], error: "must not be empty" }, + { args: ["functions", "pull", "--branch", "main"], error: "not supported" }, + ])("validates flags before authentication: $error", async ({ + args, + error, + }) => { + const result = await t.run(...args, "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain(error); + }); +}); From 61f8a7cf8eaa4d576e36a8a91585da733a1747b6 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Mon, 14 Sep 2026 10:11:36 +0300 Subject: [PATCH 4/5] Use branch names as the only public CLI selector --- CHANGELOG.md | 2 +- docs/commands.md | 10 +++---- packages/cli/src/cli/program.ts | 1 - .../src/cli/utils/command/Base44Command.ts | 20 +++---------- packages/cli/src/core/resources/branch/api.ts | 2 +- packages/cli/tests/cli/branch_scope.spec.ts | 4 --- packages/cli/tests/cli/sandbox.spec.ts | 28 +++++++++++-------- 7 files changed, 27 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da1cf121a..508bfca3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Global `--branch ` or `--branch-id ` targets sandbox commands at a specific app branch. Names resolve within the selected app; missing or ambiguous names fail. Other commands reject branch flags explicitly; omitting them or using `--branch main` targets main. +- Global `--branch ` targets sandbox commands at a specific app branch. Names resolve within the selected app; missing or ambiguous names fail. Other commands reject the flag explicitly; omitting it or using `--branch main` targets 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 a3a5b6c5d..71c539955 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,11 +6,11 @@ Commands live in `src/cli/commands//`. They use a **factory pattern** ## Branch targeting -`--branch ` and `--branch-id ` are global, but currently supported only by sandbox commands. -Use one per command: `--branch feature/checkout` resolves an exact name within the -selected app; `--branch-id` uses the ID directly without a lookup. Missing or ambiguous -names fail. `--branch main` explicitly targets main. No checkout state is saved. -For example: `base44 --branch-id sandbox read --app-id `. +`--branch ` is global, but currently supported only by sandbox commands. +`--branch feature/checkout` resolves an exact name within the selected app. +Missing or ambiguous names fail. `--branch main` explicitly targets main. +No checkout state is saved. IDs are internal; the CLI accepts branch names only. +For example: `base44 --branch feature/checkout 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`. diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 0ee9f7f55..8821deaa3 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -39,7 +39,6 @@ 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)") .option( "--branch ", "Target an app branch by exact name (sandbox commands only)", diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 90613cea2..5c99e93b6 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -175,29 +175,17 @@ export class Base44Command extends Command { const upgradeCheckPromise = startUpgradeCheck(); try { - const { branch, branchId } = this.optsWithGlobals<{ + const { branch } = this.optsWithGlobals<{ branch?: string; - branchId?: string; }>(); - if (branch !== undefined && branchId !== undefined) { + if (branch !== undefined && !this._commandOptions.supportsBranch) { throw new InvalidInputError( - "Use either --branch or --branch-id, not both.", - ); - } - if ( - (branch !== undefined || branchId !== undefined) && - !this._commandOptions.supportsBranch - ) { - throw new InvalidInputError( - `${branch !== undefined ? "--branch" : "--branch-id"} is not supported by this command. Use sandbox commands to read or edit branch files; no app changes were made.`, + "--branch is not supported by this command. Use sandbox commands to read or edit branch files; no app changes were made.", ); } if (branch !== undefined && !branch.trim()) { throw new InvalidInputError("--branch must not be empty."); } - if (branchId !== undefined && !branchId.trim()) { - throw new InvalidInputError("--branch-id must not be empty."); - } if (this._commandOptions.requireAuth) { await ensureAuth(this.context); } @@ -207,7 +195,7 @@ export class Base44Command extends Command { } const resolvedBranchId = - branch !== undefined ? await resolveBranchName(branch) : branchId; + branch !== undefined ? await resolveBranchName(branch) : undefined; const result = ((await fn( { ...this.context, branchId: resolvedBranchId }, ...args, diff --git a/packages/cli/src/core/resources/branch/api.ts b/packages/cli/src/core/resources/branch/api.ts index 67acf5566..edb00a583 100644 --- a/packages/cli/src/core/resources/branch/api.ts +++ b/packages/cli/src/core/resources/branch/api.ts @@ -38,7 +38,7 @@ export async function resolveBranchName( } if (matches.length > 1) { throw new InvalidInputError( - `Branch name "${name}" is ambiguous. Use --branch-id.`, + `Branch name "${name}" is ambiguous. Give the branches unique names before retrying.`, ); } return matches[0].id; diff --git a/packages/cli/tests/cli/branch_scope.spec.ts b/packages/cli/tests/cli/branch_scope.spec.ts index ccd5b0374..3a46ab6cb 100644 --- a/packages/cli/tests/cli/branch_scope.spec.ts +++ b/packages/cli/tests/cli/branch_scope.spec.ts @@ -77,10 +77,6 @@ describe("branch name targeting", () => { }); it.each([ - { - args: ["sandbox", "ls", "--branch", "main", "--branch-id", "id"], - error: "not both", - }, { args: ["sandbox", "ls", "--branch", ""], error: "must not be empty" }, { args: ["functions", "pull", "--branch", "main"], error: "not supported" }, ])("validates flags before authentication: $error", async ({ diff --git a/packages/cli/tests/cli/sandbox.spec.ts b/packages/cli/tests/cli/sandbox.spec.ts index 3be1c9b36..dc823296d 100644 --- a/packages/cli/tests/cli/sandbox.spec.ts +++ b/packages/cli/tests/cli/sandbox.spec.ts @@ -8,16 +8,19 @@ const base = `/api/apps/${APP_ID}/sandbox-bridge`; describe("sandbox commands", () => { const t = setupCLITests(); - it("accepts --branch-id before the subcommand", async () => { + it("accepts --branch before the subcommand", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockRoute("GET", `/api/apps/${APP_ID}/branches`, (_req, res) => { + res.json([{ id: BRANCH_ID, branch_name: "checkout" }]); + }); 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, + "--branch", + "checkout", "sandbox", "ls", "--app-id", @@ -33,19 +36,17 @@ describe("sandbox commands", () => { ["deploy"], ["login"], ])("rejects branch scope for %s %s before authentication", async (...command) => { - const result = await t.run(...command, "--branch-id", BRANCH_ID, "--json"); + const result = await t.run(...command, "--branch", "checkout", "--json"); t.expectResult(result).toFail(); expect(JSON.parse(result.stdout).error).toContain( - "--branch-id is not supported by this command", + "--branch 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"); + const result = await t.run("sandbox", "ls", "--branch", " ", "--json"); t.expectResult(result).toFail(); - expect(JSON.parse(result.stdout).error).toBe( - "--branch-id must not be empty.", - ); + expect(JSON.parse(result.stdout).error).toBe("--branch must not be empty."); }); it("ls prints the JSON result", async () => { @@ -123,12 +124,15 @@ describe("sandbox commands", () => { git_commit_hash: "abc123", }, }, - ])("$command.0 forwards --branch-id", async ({ + ])("$command.0 forwards the resolved branch ID", async ({ command, endpoint, response, }) => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockRoute("GET", `/api/apps/${APP_ID}/branches`, (_req, res) => { + res.json([{ id: BRANCH_ID, branch_name: "checkout" }]); + }); t.api.mockRoute("POST", `${base}/${endpoint}`, (req, res) => { expect(req.body.branch_id).toBe(BRANCH_ID); res.status(200).json(response); @@ -137,8 +141,8 @@ describe("sandbox commands", () => { const result = await t.run( "sandbox", ...command, - "--branch-id", - BRANCH_ID, + "--branch", + "checkout", "--app-id", APP_ID, ); From 9647452c672a83483a78b66d3e16915958c85997 Mon Sep 17 00:00:00 2001 From: Eliran Elnasi Date: Mon, 14 Sep 2026 10:18:53 +0300 Subject: [PATCH 5/5] Expose branch discovery and fix unused scope export --- CHANGELOG.md | 2 + docs/commands.md | 4 ++ .../cli/src/cli/commands/branches/index.ts | 33 +++++++++++++ packages/cli/src/cli/program.ts | 2 + packages/cli/src/core/resources/branch/api.ts | 19 +++++--- .../cli/src/core/resources/sandbox/schema.ts | 2 +- packages/cli/tests/cli/branch_scope.spec.ts | 6 ++- packages/cli/tests/cli/branches_list.spec.ts | 47 +++++++++++++++++++ packages/cli/tests/cli/sandbox.spec.ts | 4 +- 9 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/cli/commands/branches/index.ts create mode 100644 packages/cli/tests/cli/branches_list.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 508bfca3f..cb7f38e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- `base44 branches list --app-id --json` lists main and active branch names for agents working outside Builder. + - Global `--branch ` targets sandbox commands at a specific app branch. Names resolve within the selected app; missing or ambiguous names fail. Other commands reject the flag explicitly; omitting it or using `--branch main` targets 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. diff --git a/docs/commands.md b/docs/commands.md index 71c539955..ddfce2723 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,6 +6,10 @@ Commands live in `src/cli/commands//`. They use a **factory pattern** ## Branch targeting +Discover names with `base44 branches list --app-id --json`. +The result contains `branches` with `name` and `status`, including main and active +feature branches. Choose the branch matching the user's request before editing. + `--branch ` is global, but currently supported only by sandbox commands. `--branch feature/checkout` resolves an exact name within the selected app. Missing or ambiguous names fail. `--branch main` explicitly targets main. diff --git a/packages/cli/src/cli/commands/branches/index.ts b/packages/cli/src/cli/commands/branches/index.ts new file mode 100644 index 000000000..0bf132c0d --- /dev/null +++ b/packages/cli/src/cli/commands/branches/index.ts @@ -0,0 +1,33 @@ +import { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { listBranches } from "@/core/resources/branch/api.js"; + +async function listBranchesAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const remote = await runTask("Fetching branches", () => listBranches()); + const branches = [ + { name: "main", status: "active" }, + ...remote.map((branch) => ({ + name: branch.branch_name, + status: branch.status, + })), + ]; + if (jsonMode) return { stdout: `${JSON.stringify({ branches })}\n` }; + for (const branch of branches) + log.message(`${branch.name} (${branch.status})`); + return { outroMessage: `${branches.length} branches` }; +} + +export function getBranchesCommand(): Command { + return new Command("branches") + .description("Discover an app's branches") + .addCommand( + new Base44Command("list") + .description("List main and active branch names for use with --branch") + .action(listBranchesAction), + ); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 8821deaa3..86ac1ed3d 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -5,6 +5,7 @@ import { getAuthCommand } from "@/cli/commands/auth/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; +import { getBranchesCommand } from "@/cli/commands/branches/index.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; @@ -107,6 +108,7 @@ export function createProgram(context: CLIContext): Command { // Register sandbox (remote development) commands program.addCommand(getSandboxCommand()); + program.addCommand(getBranchesCommand()); // Register auth config commands program.addCommand(getAuthCommand()); diff --git a/packages/cli/src/core/resources/branch/api.ts b/packages/cli/src/core/resources/branch/api.ts index edb00a583..81c2acdc3 100644 --- a/packages/cli/src/core/resources/branch/api.ts +++ b/packages/cli/src/core/resources/branch/api.ts @@ -11,19 +11,16 @@ const BranchesSchema = z.array( z.object({ id: z.string().min(1), branch_name: z.string(), + status: z.enum(["active", "merged", "deleted"]), }), ); -export async function resolveBranchName( - name: string, -): Promise { - if (name === "main") return undefined; - +export async function listBranches() { let response: KyResponse; try { response = await getAppClient().get("branches", { timeout: 30_000 }); } catch (error) { - throw await ApiError.fromHttpError(error, "resolving branch name"); + throw await ApiError.fromHttpError(error, "listing branches"); } const result = BranchesSchema.safeParse(await response.json()); if (!result.success) { @@ -32,7 +29,15 @@ export async function resolveBranchName( result.error, ); } - const matches = result.data.filter((branch) => branch.branch_name === name); + return result.data.filter((branch) => branch.status === "active"); +} + +export async function resolveBranchName( + name: string, +): Promise { + if (name === "main") return undefined; + const branches = await listBranches(); + const matches = branches.filter((branch) => branch.branch_name === name); if (matches.length === 0) { throw new InvalidInputError(`Branch "${name}" was not found in this app.`); } diff --git a/packages/cli/src/core/resources/sandbox/schema.ts b/packages/cli/src/core/resources/sandbox/schema.ts index 007871e87..b22f4f350 100644 --- a/packages/cli/src/core/resources/sandbox/schema.ts +++ b/packages/cli/src/core/resources/sandbox/schema.ts @@ -4,7 +4,7 @@ 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 SandboxScopeParams { +interface SandboxScopeParams { branch_id?: string; } diff --git a/packages/cli/tests/cli/branch_scope.spec.ts b/packages/cli/tests/cli/branch_scope.spec.ts index 3a46ab6cb..e1ea0ef25 100644 --- a/packages/cli/tests/cli/branch_scope.spec.ts +++ b/packages/cli/tests/cli/branch_scope.spec.ts @@ -4,7 +4,11 @@ import { setupCLITests } from "./testkit/index.js"; describe("branch name targeting", () => { const t = setupCLITests(); const appId = "test-app-id"; - const branch = { id: "branch-123", branch_name: "feature/checkout" }; + const branch = { + id: "branch-123", + branch_name: "feature/checkout", + status: "active", + }; it.each([ "feature/checkout", diff --git a/packages/cli/tests/cli/branches_list.spec.ts b/packages/cli/tests/cli/branches_list.spec.ts new file mode 100644 index 000000000..12ac0d9ef --- /dev/null +++ b/packages/cli/tests/cli/branches_list.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +describe("branches list", () => { + const t = setupCLITests(); + + it.each([ + [], + [{ id: "b1", branch_name: "feature/checkout", status: "active" }], + ])("returns usable names including main without needing a project", async (...rows) => { + const branches = rows.flat(); + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockRoute("GET", "/api/apps/test-app-id/branches", (_req, res) => + res.json(branches), + ); + const result = await t.run( + "branches", + "list", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + branches: [ + { name: "main", status: "active" }, + ...branches.map((b) => ({ name: b.branch_name, status: b.status })), + ], + }); + }); + + it("does not invent a main-only result when access fails", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockRoute("GET", "/api/apps/test-app-id/branches", (_req, res) => + res.status(403).json({ detail: "Denied" }), + ); + const result = await t.run( + "branches", + "list", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).not.toHaveProperty("branches"); + }); +}); diff --git a/packages/cli/tests/cli/sandbox.spec.ts b/packages/cli/tests/cli/sandbox.spec.ts index dc823296d..df96962f2 100644 --- a/packages/cli/tests/cli/sandbox.spec.ts +++ b/packages/cli/tests/cli/sandbox.spec.ts @@ -11,7 +11,7 @@ describe("sandbox commands", () => { it("accepts --branch before the subcommand", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); t.api.mockRoute("GET", `/api/apps/${APP_ID}/branches`, (_req, res) => { - res.json([{ id: BRANCH_ID, branch_name: "checkout" }]); + res.json([{ id: BRANCH_ID, branch_name: "checkout", status: "active" }]); }); t.api.mockRoute("POST", `${base}/list_directory`, (req, res) => { expect(req.body.branch_id).toBe(BRANCH_ID); @@ -131,7 +131,7 @@ describe("sandbox commands", () => { }) => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); t.api.mockRoute("GET", `/api/apps/${APP_ID}/branches`, (_req, res) => { - res.json([{ id: BRANCH_ID, branch_name: "checkout" }]); + res.json([{ id: BRANCH_ID, branch_name: "checkout", status: "active" }]); }); t.api.mockRoute("POST", `${base}/${endpoint}`, (req, res) => { expect(req.body.branch_id).toBe(BRANCH_ID);