diff --git a/CHANGELOG.md b/CHANGELOG.md index 836bd87c9..cb7f38e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 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. - `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..ddfce2723 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,6 +4,26 @@ Commands live in `src/cli/commands//`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`. +## 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. +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`. + +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/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/commands/sandbox/checkpoint.ts b/packages/cli/src/cli/commands/sandbox/checkpoint.ts index eeb3c1d75..c31c00c78 100644 --- a/packages/cli/src/cli/commands/sandbox/checkpoint.ts +++ b/packages/cli/src/cli/commands/sandbox/checkpoint.ts @@ -10,20 +10,23 @@ interface CheckpointOptions { } async function checkpointAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, options: CheckpointOptions, ): Promise { const { id: appId } = getAppContext(); const result = await runTask("Creating checkpoint", () => - createCheckpoint(appId, { name: options.name }), + createCheckpoint(appId, { + name: options.name, + branch_id: branchId, + }), ); return { outroMessage: "Created checkpoint", stdout: toJsonStdout(result) }; } export function getSandboxCheckpointCommand(): Command { - return new Base44Command("checkpoint") + return new Base44Command("checkpoint", { supportsBranch: true }) .description("Create a restore-point checkpoint of an app's remote sandbox") .option( "--name ", diff --git a/packages/cli/src/cli/commands/sandbox/edit-file.ts b/packages/cli/src/cli/commands/sandbox/edit-file.ts index ed6a5fbb1..61c4f1c12 100644 --- a/packages/cli/src/cli/commands/sandbox/edit-file.ts +++ b/packages/cli/src/cli/commands/sandbox/edit-file.ts @@ -42,7 +42,7 @@ function parseEdits(raw: string): EditSpec[] { } async function editFileAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, path: string, options: EditFileOptions, ): Promise { @@ -52,7 +52,13 @@ async function editFileAction( const result = await runTask( options.dryRun ? "Previewing edit" : "Editing file", - () => editFile(appId, { path, edits, dry_run: options.dryRun }), + () => + editFile(appId, { + path, + edits, + dry_run: options.dryRun, + branch_id: branchId, + }), ); return { @@ -62,7 +68,7 @@ async function editFileAction( } export function getSandboxEditFileCommand(): Command { - return new Base44Command("edit") + return new Base44Command("edit", { supportsBranch: true }) .description("Apply exact old→new string edits to a file in the sandbox") .argument("", "File path relative to the app root") .option( diff --git a/packages/cli/src/cli/commands/sandbox/grep.ts b/packages/cli/src/cli/commands/sandbox/grep.ts index 7dd8cc46c..229e90dbb 100644 --- a/packages/cli/src/cli/commands/sandbox/grep.ts +++ b/packages/cli/src/cli/commands/sandbox/grep.ts @@ -14,7 +14,7 @@ interface GrepOptions { } async function grepAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, pattern: string, options: GrepOptions, ): Promise { @@ -29,6 +29,7 @@ async function grepAction( case_sensitive: options.caseSensitive, glob: options.glob, max_results: maxResults, + branch_id: branchId, }), ); @@ -36,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") diff --git a/packages/cli/src/cli/commands/sandbox/list-directory.ts b/packages/cli/src/cli/commands/sandbox/list-directory.ts index a35d311c3..43036da07 100644 --- a/packages/cli/src/cli/commands/sandbox/list-directory.ts +++ b/packages/cli/src/cli/commands/sandbox/list-directory.ts @@ -12,7 +12,7 @@ interface ListDirectoryOptions { } async function listDirectoryAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, path: string | undefined, options: ListDirectoryOptions, ): Promise { @@ -22,6 +22,7 @@ async function listDirectoryAction( const result = await runTask("Listing directory", () => listDirectory(appId, { path, + branch_id: branchId, recursive: options.recursive, max_depth: maxDepth, include_hidden: options.includeHidden, @@ -32,7 +33,7 @@ async function listDirectoryAction( } export function getSandboxListDirectoryCommand(): Command { - return new Base44Command("ls") + return new Base44Command("ls", { supportsBranch: true }) .description("List directory entries in an app's remote sandbox") .argument( "[path]", diff --git a/packages/cli/src/cli/commands/sandbox/read-file.ts b/packages/cli/src/cli/commands/sandbox/read-file.ts index 8e0e54b86..0fb786741 100644 --- a/packages/cli/src/cli/commands/sandbox/read-file.ts +++ b/packages/cli/src/cli/commands/sandbox/read-file.ts @@ -11,7 +11,7 @@ interface ReadFileOptions { } async function readFileAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, paths: string[], options: ReadFileOptions, ): Promise { @@ -20,14 +20,14 @@ async function readFileAction( const limit = parsePositiveInt(options.limit, "--limit"); const result = await runTask("Reading file", () => - readFile(appId, { paths, offset, limit }), + readFile(appId, { paths, offset, limit, branch_id: branchId }), ); return { outroMessage: "Read file", stdout: toJsonStdout(result) }; } export function getSandboxReadFileCommand(): Command { - return new Base44Command("read") + return new Base44Command("read", { supportsBranch: true }) .description("Read file contents from an app's remote sandbox") .argument("", "One or more file paths relative to the app root") .option("--offset ", "1-based start line") diff --git a/packages/cli/src/cli/commands/sandbox/run-command.ts b/packages/cli/src/cli/commands/sandbox/run-command.ts index bdb2f9db7..2d13489aa 100644 --- a/packages/cli/src/cli/commands/sandbox/run-command.ts +++ b/packages/cli/src/cli/commands/sandbox/run-command.ts @@ -11,7 +11,7 @@ interface RunCommandOptions { } async function runCommandAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, commandParts: string[], options: RunCommandOptions, ): Promise { @@ -20,7 +20,12 @@ async function runCommandAction( const command = commandParts.join(" "); const result = await runTask("Running command", () => - runCommand(appId, { command, cwd: options.cwd, timeout_ms: timeoutMs }), + runCommand(appId, { + command, + cwd: options.cwd, + timeout_ms: timeoutMs, + branch_id: branchId, + }), ); // The HTTP call succeeded, so the CLI exits 0 regardless of the remote @@ -29,7 +34,7 @@ async function runCommandAction( } export function getSandboxRunCommandCommand(): Command { - return new Base44Command("run") + return new Base44Command("run", { supportsBranch: true }) .description("Run a shell command in an app's remote sandbox") .argument("", "Shell command to execute (quote to keep as one)") .option("--cwd ", "Working directory relative to the app root") diff --git a/packages/cli/src/cli/commands/sandbox/write-file.ts b/packages/cli/src/cli/commands/sandbox/write-file.ts index 2a8b3127e..afd3cf08d 100644 --- a/packages/cli/src/cli/commands/sandbox/write-file.ts +++ b/packages/cli/src/cli/commands/sandbox/write-file.ts @@ -11,7 +11,7 @@ interface WriteFileOptions { } async function writeFileAction( - { runTask }: CLIContext, + { runTask, branchId }: CLIContext, path: string, options: WriteFileOptions, ): Promise { @@ -19,14 +19,19 @@ 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: branchId, + }), ); return { outroMessage: "Wrote file", stdout: toJsonStdout(result) }; } export function getSandboxWriteFileCommand(): Command { - return new Base44Command("write") + return new Base44Command("write", { supportsBranch: true }) .description("Create or overwrite a file in an app's remote sandbox") .argument("", "File path relative to the app root") .option("--content ", "File content (if omitted, read from stdin)") diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index fe8bc8f6a..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"; @@ -39,6 +40,10 @@ export function createProgram(context: CLIContext): Command { "Base44 CLI - Unified interface for managing Base44 applications", ) .version(packageJson.version) + .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, @@ -103,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/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..5c99e93b6 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -15,7 +15,8 @@ import { formatPlainUpgradeMessage, startUpgradeCheck, } from "@/cli/utils/upgradeNotification.js"; -import { ApiError, isCLIError } from "@/core/errors.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` @@ -67,6 +68,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 +132,7 @@ export class Base44Command extends Command { requireAuth: options?.requireAuth ?? true, requireAppContext: options?.requireAppContext ?? true, fullBanner: options?.fullBanner ?? false, + supportsBranch: options?.supportsBranch ?? false, }; } @@ -172,6 +175,17 @@ export class Base44Command extends Command { const upgradeCheckPromise = startUpgradeCheck(); try { + const { branch } = this.optsWithGlobals<{ + branch?: string; + }>(); + if (branch !== undefined && !this._commandOptions.supportsBranch) { + throw new InvalidInputError( + "--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 (this._commandOptions.requireAuth) { await ensureAuth(this.context); } @@ -180,8 +194,12 @@ export class Base44Command extends Command { await ensureAppContext(this.context, { appId }); } - const result = ((await fn(this.context, ...args)) ?? - {}) as RunCommandResult; + const resolvedBranchId = + branch !== undefined ? await resolveBranchName(branch) : undefined; + 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..81c2acdc3 --- /dev/null +++ b/packages/cli/src/core/resources/branch/api.ts @@ -0,0 +1,50 @@ +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(), + status: z.enum(["active", "merged", "deleted"]), + }), +); + +export async function listBranches() { + let response: KyResponse; + try { + response = await getAppClient().get("branches", { timeout: 30_000 }); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing branches"); + } + const result = BranchesSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid branches response from server", + result.error, + ); + } + 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.`); + } + if (matches.length > 1) { + throw new InvalidInputError( + `Branch name "${name}" is ambiguous. Give the branches unique names before retrying.`, + ); + } + return matches[0].id; +} diff --git a/packages/cli/src/core/resources/sandbox/schema.ts b/packages/cli/src/core/resources/sandbox/schema.ts index 01052bddc..b22f4f350 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 { +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/branch_scope.spec.ts b/packages/cli/tests/cli/branch_scope.spec.ts new file mode 100644 index 000000000..e1ea0ef25 --- /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", + status: "active", + }; + + 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", ""], 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); + }); +}); 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 18c20dfae..df96962f2 100644 --- a/packages/cli/tests/cli/sandbox.spec.ts +++ b/packages/cli/tests/cli/sandbox.spec.ts @@ -2,15 +2,58 @@ 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", () => { const t = setupCLITests(); + 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", status: "active" }]); + }); + 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", + "checkout", + "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", "checkout", "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain( + "--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", " ", "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toBe("--branch must not be empty."); + }); + 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 +68,88 @@ 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 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", status: "active" }]); + }); + 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", + "checkout", + "--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" });