From 26d67c2ca39ac830bebb86a3ef3bd48f0cd9bb59 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Thu, 18 Jun 2026 15:25:40 +0200 Subject: [PATCH 1/3] add sandbox PoC --- .github/workflows/sandbox-agent.yml | 37 ++++ packages/cli/README.md | 128 ++++++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/sandbox/create.ts | 190 ++++++++++++++++++ packages/cli/src/commands/sandbox/delete.ts | 70 +++++++ packages/cli/src/commands/sandbox/exec.ts | 59 ++++++ packages/cli/src/commands/sandbox/index.ts | 16 ++ packages/cli/src/commands/sandbox/list.ts | 33 +++ packages/cli/src/commands/sandbox/ssh-exec.ts | 29 +++ packages/cli/src/commands/sandbox/ssh.ts | 48 +++++ packages/cli/src/commands/sandbox/url/add.ts | 103 ++++++++++ .../cli/src/commands/sandbox/url/delete.ts | 66 ++++++ .../cli/src/commands/sandbox/url/index.ts | 10 + packages/cli/src/commands/sandbox/url/list.ts | 66 ++++++ packages/cli/src/config/index.ts | 21 +- packages/cli/src/config/schema.ts | 8 + sandbox/Dockerfile | 51 +++++ sandbox/entrypoint.sh | 5 + 18 files changed, 940 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sandbox-agent.yml create mode 100644 packages/cli/src/commands/sandbox/create.ts create mode 100644 packages/cli/src/commands/sandbox/delete.ts create mode 100644 packages/cli/src/commands/sandbox/exec.ts create mode 100644 packages/cli/src/commands/sandbox/index.ts create mode 100644 packages/cli/src/commands/sandbox/list.ts create mode 100644 packages/cli/src/commands/sandbox/ssh-exec.ts create mode 100644 packages/cli/src/commands/sandbox/ssh.ts create mode 100644 packages/cli/src/commands/sandbox/url/add.ts create mode 100644 packages/cli/src/commands/sandbox/url/delete.ts create mode 100644 packages/cli/src/commands/sandbox/url/index.ts create mode 100644 packages/cli/src/commands/sandbox/url/list.ts create mode 100644 sandbox/Dockerfile create mode 100644 sandbox/entrypoint.sh diff --git a/.github/workflows/sandbox-agent.yml b/.github/workflows/sandbox-agent.yml new file mode 100644 index 0000000..a570f56 --- /dev/null +++ b/.github/workflows/sandbox-agent.yml @@ -0,0 +1,37 @@ +name: Sandbox Agent + +on: + push: + branches: [main] + paths: + - sandbox/** + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./sandbox + push: true + tags: ghcr.io/bunnyway/sandbox-agent:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/packages/cli/README.md b/packages/cli/README.md index bb07432..0fbd93c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -787,6 +787,134 @@ Open the Edge Scripts documentation in your browser. bunny scripts docs ``` +### `bunny sandbox` + +Manage on-demand cloud sandbox environments backed by Bunny Magic Containers. Each sandbox is a fully isolated Ubuntu container with Node.js, Bun, Python, and Claude Code pre-installed. A 10 GB persistent volume is mounted at `/workplace`, your default working directory. + +Sandbox credentials (app ID, hostname, SSH endpoint, agent token) are stored in `~/.config/bunnynet.json` so you can reconnect without re-creating. + +#### `bunny sandbox create` + +Create and start a new sandbox. Waits for the container's SSH port to become reachable before returning. + +```bash +# Create a sandbox with the default name "sandbox" +bunny sandbox create + +# Create a named sandbox +bunny sandbox create my-sandbox + +# Create in a specific region +bunny sandbox create my-sandbox --region NY +``` + +| Flag | Description | Default | +| ---------- | ---------------------------------------------------- | ------- | +| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` | + +Once ready, the output shows the app ID, public HTTPS hostname, and SSH address. + +#### `bunny sandbox list` + +List all sandboxes saved in your local config. + +```bash +bunny sandbox list +bunny sandbox ls # alias +``` + +Columns: Name, App ID, Hostname, SSH. + +#### `bunny sandbox delete` + +Delete a sandbox and permanently destroy the underlying Magic Containers app. + +```bash +bunny sandbox delete my-sandbox + +# Skip the confirmation prompt +bunny sandbox delete my-sandbox --force +bunny sandbox rm my-sandbox -f # alias +``` + +| Flag | Alias | Description | Default | +| --------- | ----- | ------------------------- | ------- | +| `--force` | `-f` | Skip confirmation prompt | `false` | + +#### `bunny sandbox exec` + +Run a shell command inside a sandbox over SSH. Defaults to `/workplace` as the working directory. + +```bash +# Run a command +bunny sandbox exec my-sandbox ls -la + +# Run in a different directory +bunny sandbox exec my-sandbox --cwd /tmp env + +# Pipe-friendly: exit code is propagated +bunny sandbox exec my-sandbox -- cat /etc/os-release +``` + +| Flag | Description | Default | +| ------- | ---------------------------------------- | ------------ | +| `--cwd` | Working directory inside the sandbox | `/workplace` | + +#### `bunny sandbox ssh` + +Open a full interactive SSH session. Drops you into a bash shell at `/workplace`. Type `exit` or press Ctrl-D to close. + +```bash +bunny sandbox ssh my-sandbox +``` + +#### `bunny sandbox url` + +Manage public CDN endpoints for ports running inside a sandbox. Useful for exposing a dev server or API to the internet. + +##### `bunny sandbox url add` + +Expose a container port as a public HTTPS endpoint. Waits until the URL is provisioned and prints it. + +```bash +# Expose port 3000 (endpoint named "port-3000") +bunny sandbox url add my-sandbox 3000 + +# Custom endpoint name +bunny sandbox url add my-sandbox 8080 --label my-api +``` + +| Flag | Description | Default | +| --------- | ---------------------------------------------- | ---------------- | +| `--label` | Display name for the endpoint | `port-` | + +##### `bunny sandbox url list` + +List all user-created endpoints for a sandbox (built-in `api` and `ssh` endpoints are hidden). + +```bash +bunny sandbox url list my-sandbox +bunny sandbox url ls my-sandbox # alias +``` + +Columns: ID, Name, Type, Port, URL. + +##### `bunny sandbox url delete` + +Delete a public endpoint by name. + +```bash +bunny sandbox url delete my-sandbox port-3000 + +# Skip confirmation +bunny sandbox url delete my-sandbox my-api --force +bunny sandbox url rm my-sandbox my-api -f # alias +``` + +| Flag | Alias | Description | Default | +| --------- | ----- | ------------------------ | ------- | +| `--force` | `-f` | Skip confirmation prompt | `false` | + ### `bunny api` Make a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 784bbdf..a22b9a8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,6 +12,7 @@ import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; +import { sandboxNamespace } from "./commands/sandbox/index.ts"; import { scriptsNamespace } from "./commands/scripts/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; @@ -35,6 +36,7 @@ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, dnsNamespace, + sandboxNamespace, ]; let instance = yargs(hideBin(process.argv)) diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts new file mode 100644 index 0000000..9bece7b --- /dev/null +++ b/packages/cli/src/commands/sandbox/create.ts @@ -0,0 +1,190 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { randomBytes } from "node:crypto"; +import { resolveConfig, setSandbox } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { WORKPLACE } from "./ssh-exec.ts"; + +const IMAGE_REGISTRY_ID = "1156"; +const IMAGE_NAMESPACE = "bunnyway"; +const IMAGE_NAME = "sandbox-agent"; +const IMAGE_TAG = "latest"; +const DEFAULT_REGION = "AMS"; +const POLL_INTERVAL_MS = 3000; +const STARTUP_TIMEOUT_MS = 120_000; + +type App = Record & { + id?: string; + status?: string; + containerTemplates?: Array<{ endpoints?: Array & { type?: string; publicHost?: string }> }>; +}; + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +function extractAnycastHost(app: App): string | null { + for (const ct of app.containerTemplates ?? []) { + for (const ep of ct.endpoints ?? []) { + if (ep.type === "anycast") { + return (ep.publicHost as string | undefined) ?? null; + } + } + } + return null; +} + +async function probeSsh(host: string, port: number): Promise { + try { + const socket = await Bun.connect({ hostname: host, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + socket.end(); + return true; + } catch { + return false; + } +} + +async function waitUntilActive( + client: ReturnType, + appId: string, +): Promise<{ sshHost: string }> { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + + // Phase 1: poll API until the anycast SSH endpoint is assigned + let sshHost: string | null = null; + await Bun.sleep(3000); + while (Date.now() < deadline) { + const { data, error } = await client.GET("/apps/{appId}", { + params: { path: { appId } }, + }); + if (error) throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); + const app = data as App; + const status = (app as Record).status as string | undefined; + if (status === "failing" || status === "suspended") { + throw new UserError(`Sandbox entered terminal state: ${status}`); + } + sshHost = extractAnycastHost(app); + if (sshHost) break; + await Bun.sleep(POLL_INTERVAL_MS); + } + + if (!sshHost) { + throw new UserError(`Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`); + } + + // Phase 2: probe SSH port until the container accepts connections + const [sshIp, sshPortStr] = (sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"]) as [string, string]; + const sshPort = Number(sshPortStr); + while (Date.now() < deadline) { + if (await probeSsh(sshIp, sshPort)) return { sshHost }; + await Bun.sleep(POLL_INTERVAL_MS); + } + + throw new UserError(`Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`); +} + +interface CreateArgs { + name: string; + region: string; +} + +export const sandboxCreateCommand = defineCommand({ + command: "create [name]", + describe: "Create and start a new sandbox.", + examples: [ + ["$0 sandbox create", "Create a sandbox with a generated name"], + ["$0 sandbox create my-sandbox", "Create a sandbox named my-sandbox"], + ["$0 sandbox create my-sandbox --region NY", "Create a sandbox in New York"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + default: "sandbox", + describe: "Name for the sandbox", + }) + .option("region", { + type: "string", + default: DEFAULT_REGION, + describe: "Region ID to deploy the sandbox in (e.g. AMS, NY, LA)", + }), + + handler: async ({ profile, verbose, apiKey, name, region }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + const agentToken = generateToken(); + + const spin = spinner("Creating sandbox..."); + spin.start(); + + const { data: app, error: createError } = await (client as any).POST("/apps", { + body: { + name, + runtimeType: "shared", + autoScaling: { min: 1, max: 1 }, + regionSettings: { + allowedRegionIds: [region], + requiredRegionIds: [region], + }, + volumes: [ + { name: "workplace", size: 10 }, + ], + containerTemplates: [ + { + name: "agent", + imageRegistryId: IMAGE_REGISTRY_ID, + imageNamespace: IMAGE_NAMESPACE, + imageName: IMAGE_NAME, + imageTag: IMAGE_TAG, + imagePullPolicy: "ifNotPresent", + environmentVariables: [{ name: "AGENT_TOKEN", value: agentToken }], + volumeMounts: [ + { name: "workplace", mountPath: WORKPLACE }, + ], + endpoints: [ + { + displayName: "ssh", + anycast: { + type: "ipv4", + portMappings: [{ containerPort: 8023, exposedPort: 8023, protocols: ["Tcp"] }], + }, + }, + ], + }, + ], + }, + }); + + if (createError || !app) { + spin.stop(); + throw new UserError(`Failed to create sandbox: ${JSON.stringify(createError)}`); + } + + const appId = (app as Record).id as string; + spin.text = "Waiting for sandbox to become active..."; + + let sshHost: string; + try { + ({ sshHost } = await waitUntilActive(client, appId)); + } catch (err) { + spin.stop(); + // best-effort cleanup + await client.DELETE("/apps/{appId}", { params: { path: { appId } } }).catch(() => {}); + throw err; + } + + spin.stop(); + + const record = { app_id: appId, agent_token: agentToken, ssh_host: sshHost }; + setSandbox(name, record); + + logger.log(`Sandbox "${name}" is ready.`); + logger.log(` App ID: ${appId}`); + logger.log(` SSH: ${sshHost}`); + logger.log(`\nRun commands with: bunny sandbox exec ${name} `); + }, +}); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts new file mode 100644 index 0000000..adc6934 --- /dev/null +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -0,0 +1,70 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { deleteSandbox, getSandbox, resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { confirm, spinner } from "../../core/ui.ts"; + +interface DeleteArgs { + name: string; + force: boolean; +} + +export const sandboxDeleteCommand = defineCommand({ + command: "delete ", + aliases: ["rm"], + describe: "Delete a sandbox and its MC app.", + examples: [ + ["$0 sandbox delete my-sandbox", "Delete a sandbox"], + ["$0 sandbox delete my-sandbox --force", "Delete without confirmation"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ name, force, profile, verbose, apiKey }) => { + const record = getSandbox(name); + if (!record) { + throw new UserError(`No sandbox named "${name}" found.`); + } + + if (!force) { + const ok = await confirm(`Delete sandbox "${name}" (app ${record.app_id})?`); + if (!ok) { + logger.info("Aborted."); + return; + } + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Deleting sandbox..."); + spin.start(); + + const { error } = await client.DELETE("/apps/{appId}", { + params: { path: { appId: record.app_id } }, + }); + + spin.stop(); + + if (error) { + throw new UserError(`Failed to delete app: ${JSON.stringify(error)}`); + } + + deleteSandbox(name); + logger.log(`Sandbox "${name}" deleted.`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts new file mode 100644 index 0000000..3b7a810 --- /dev/null +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -0,0 +1,59 @@ +import { getSandbox } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { WORKPLACE, sshArgs } from "./ssh-exec.ts"; + +interface ExecArgs { + name: string; + command: string[]; + cwd: string; +} + +export const sandboxExecCommand = defineCommand({ + command: "exec ", + describe: "Run a shell command inside a sandbox via SSH.", + examples: [ + ["$0 sandbox exec my-sandbox uname -a", "Run a command"], + ["$0 sandbox exec my-sandbox --cwd /tmp ls -la", "Run with a working directory"], + ], + + builder: (yargs) => + yargs + .parserConfiguration({ "unknown-options-as-args": true }) + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("command", { + type: "string", + array: true, + demandOption: true, + describe: "Command to execute", + }) + .option("cwd", { + type: "string", + default: WORKPLACE, + describe: "Working directory inside the sandbox", + }), + + handler: async ({ name, command, cwd }) => { + const record = getSandbox(name); + if (!record) { + throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + } + if (!record.ssh_host) { + throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + } + + const remoteCmd = `cd ${cwd} && ${command.join(" ")}`; + + const proc = Bun.spawn(sshArgs(record, remoteCmd), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + process.exitCode = await proc.exited; + }, +}); diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts new file mode 100644 index 0000000..913fa10 --- /dev/null +++ b/packages/cli/src/commands/sandbox/index.ts @@ -0,0 +1,16 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { sandboxCreateCommand } from "./create.ts"; +import { sandboxDeleteCommand } from "./delete.ts"; +import { sandboxExecCommand } from "./exec.ts"; +import { sandboxListCommand } from "./list.ts"; +import { sandboxSshCommand } from "./ssh.ts"; +import { sandboxUrlNamespace } from "./url/index.ts"; + +export const sandboxNamespace = defineNamespace("sandbox", "Manage sandboxes.", [ + sandboxCreateCommand, + sandboxListCommand, + sandboxDeleteCommand, + sandboxExecCommand, + sandboxSshCommand, + sandboxUrlNamespace, +]); diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts new file mode 100644 index 0000000..d41e573 --- /dev/null +++ b/packages/cli/src/commands/sandbox/list.ts @@ -0,0 +1,33 @@ +import { loadConfigFile } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; + +export const sandboxListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List all sandboxes.", + examples: [["$0 sandbox list", "List all sandboxes"]], + + handler: async ({ output }) => { + const sandboxes = Object.entries(loadConfigFile()?.sandboxes ?? {}); + + if (output === "json") { + logger.log(JSON.stringify(Object.fromEntries(sandboxes), null, 2)); + return; + } + + if (sandboxes.length === 0) { + logger.info("No sandboxes found. Run: bunny sandbox create"); + return; + } + + logger.log( + formatTable( + ["Name", "App ID", "SSH"], + sandboxes.map(([name, s]) => [name, s.app_id, s.ssh_host ?? ""]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts new file mode 100644 index 0000000..efdf05c --- /dev/null +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -0,0 +1,29 @@ +import type { SandboxRecord } from "../../config/schema.ts"; + +export const WORKPLACE = "/workplace"; + +export function sshArgs(record: SandboxRecord, remoteCmd: string): string[] { + const [host, portStr] = (record.ssh_host!.includes(":") + ? record.ssh_host!.split(":") + : [record.ssh_host!, "8023"]) as [string, string]; + + return [ + "sshpass", "-p", record.agent_token, + "ssh", + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + `root@${host}`, + remoteCmd, + ]; +} + +export async function runSshCommand(record: SandboxRecord, remoteCmd: string): Promise { + const proc = Bun.spawn(sshArgs(record, remoteCmd), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + return proc.exited; +} diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts new file mode 100644 index 0000000..9d85ac3 --- /dev/null +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -0,0 +1,48 @@ +import { getSandbox } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { WORKPLACE } from "./ssh-exec.ts"; + +export const sandboxSshCommand = defineCommand({ + command: "ssh ", + describe: "Open an interactive SSH session inside a sandbox.", + examples: [["$0 sandbox ssh my-sandbox", "Open a shell in my-sandbox"]], + + builder: (yargs) => + yargs.positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }), + + handler: async ({ name }) => { + const record = getSandbox(name); + if (!record) { + throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + } + if (!record.ssh_host) { + throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + } + + const [host, portStr] = (record.ssh_host.includes(":") + ? record.ssh_host.split(":") + : [record.ssh_host, "8023"]) as [string, string]; + + const proc = Bun.spawn( + [ + "sshpass", "-p", record.agent_token, + "ssh", + "-t", + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + `root@${host}`, + `cd ${WORKPLACE} && exec bash -l`, + ], + { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, + ); + + process.exitCode = await proc.exited; + }, +}); diff --git a/packages/cli/src/commands/sandbox/url/add.ts b/packages/cli/src/commands/sandbox/url/add.ts new file mode 100644 index 0000000..34d5299 --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -0,0 +1,103 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; + +interface AddArgs { + name: string; + port: number; + label?: string; +} + +export const sandboxUrlAddCommand = defineCommand({ + command: "add ", + describe: "Expose a port as a public CDN endpoint.", + examples: [ + ["$0 sandbox url add my-sandbox 3000", "Expose port 3000"], + ["$0 sandbox url add my-sandbox 8080 --label api", "Expose port 8080 as 'api'"], + ], + + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) + .positional("port", { type: "number", demandOption: true, describe: "Container port to expose" }) + .option("label", { type: "string", describe: "Endpoint display name (defaults to 'port-')" }), + + handler: async ({ name, port, label, profile, apiKey, verbose, output }) => { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching sandbox info..."); + spin.start(); + + // Get the container template ID from the app + const { data: app, error: appError } = await client.GET("/apps/{appId}", { + params: { path: { appId: record.app_id } }, + }); + if (appError || !app) { + spin.stop(); + throw new UserError(`Failed to fetch app: ${JSON.stringify(appError)}`); + } + + const containerId = (app as any).containerTemplates?.[0]?.id as string | undefined; + if (!containerId) { + spin.stop(); + throw new UserError("Could not find container template ID."); + } + + const displayName = label ?? `port-${port}`; + spin.text = `Creating endpoint "${displayName}"...`; + + const { data: ep, error: epError } = await (client as any).POST( + "/apps/{appId}/containers/{containerId}/endpoints", + { + params: { path: { appId: record.app_id, containerId } }, + body: { + displayName, + cdn: { + isSslEnabled: false, + portMappings: [{ containerPort: port, protocols: ["Tcp"] }], + }, + }, + }, + ); + + if (epError) { + spin.stop(); + throw new UserError(`Failed to create endpoint: ${JSON.stringify(epError)}`); + } + + const endpointId = (ep as any)?.id as string; + + // Poll until publicHost is assigned + spin.text = "Waiting for public URL..."; + const deadline = Date.now() + 60_000; + let publicHost: string | null = null; + while (Date.now() < deadline) { + await Bun.sleep(2000); + const { data: list } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId: record.app_id } }, + }); + const found = (list?.items ?? []).find((e: any) => e.id === endpointId); + if (found?.publicHost) { publicHost = found.publicHost; break; } + } + + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify({ id: endpointId, displayName, port, publicHost }, null, 2)); + return; + } + + logger.log(`Endpoint "${displayName}" created.`); + logger.log(` Port: ${port}`); + logger.log(` ID: ${endpointId}`); + logger.log(` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/url/delete.ts b/packages/cli/src/commands/sandbox/url/delete.ts new file mode 100644 index 0000000..a73ed98 --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/delete.ts @@ -0,0 +1,66 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; + +interface DeleteArgs { + name: string; + "endpoint-name": string; + force: boolean; +} + +export const sandboxUrlDeleteCommand = defineCommand({ + command: "delete ", + aliases: ["rm"], + describe: "Delete a public endpoint from a sandbox.", + examples: [["$0 sandbox url delete my-sandbox port-3000", "Delete endpoint by name"]], + + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) + .positional("endpoint-name", { type: "string", demandOption: true, describe: "Endpoint name (from url list)" }) + .option("force", { alias: "f", type: "boolean", default: false, describe: "Skip confirmation" }), + + handler: async ({ name, "endpoint-name": endpointName, force, profile, apiKey, verbose }) => { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Looking up endpoint..."); + spin.start(); + + const { data, error: listError } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId: record.app_id } }, + }); + + spin.stop(); + + if (listError) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(listError)}`); + + const ep = (data?.items ?? [] as any[]).find((e: any) => e.displayName === endpointName); + if (!ep) throw new UserError(`No endpoint named "${endpointName}" found. Run: bunny sandbox url list ${name}`); + + if (!force) { + const ok = await confirm(`Delete endpoint "${endpointName}" from sandbox "${name}"?`); + if (!ok) { logger.info("Aborted."); return; } + } + + const spin2 = spinner("Deleting endpoint..."); + spin2.start(); + + const { error } = await client.DELETE("/apps/{appId}/endpoints/{endpointId}", { + params: { path: { appId: record.app_id, endpointId: ep.id } }, + }); + + spin2.stop(); + + if (error) throw new UserError(`Failed to delete endpoint: ${JSON.stringify(error)}`); + + logger.log(`Endpoint "${endpointName}" deleted.`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/url/index.ts b/packages/cli/src/commands/sandbox/url/index.ts new file mode 100644 index 0000000..c5b88b0 --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/index.ts @@ -0,0 +1,10 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sandboxUrlAddCommand } from "./add.ts"; +import { sandboxUrlDeleteCommand } from "./delete.ts"; +import { sandboxUrlListCommand } from "./list.ts"; + +export const sandboxUrlNamespace = defineNamespace("url", "Manage public URL endpoints for a sandbox.", [ + sandboxUrlAddCommand, + sandboxUrlListCommand, + sandboxUrlDeleteCommand, +]); diff --git a/packages/cli/src/commands/sandbox/url/list.ts b/packages/cli/src/commands/sandbox/url/list.ts new file mode 100644 index 0000000..2b4d22e --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/list.ts @@ -0,0 +1,66 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; + +export const sandboxUrlListCommand = defineCommand({ + command: "list ", + aliases: ["ls"], + describe: "List public endpoints for a sandbox.", + examples: [["$0 sandbox url list my-sandbox", "List all endpoints"]], + + builder: (yargs) => + yargs.positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }), + + handler: async ({ name, profile, apiKey, verbose, output }: any) => { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching endpoints..."); + spin.start(); + + const { data, error } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId: record.app_id } }, + }); + + spin.stop(); + + if (error) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(error)}`); + + const DEFAULT_ENDPOINTS = new Set(["api", "ssh"]); + const items = ((data?.items ?? []) as any[]).filter( + (ep) => !DEFAULT_ENDPOINTS.has(ep.displayName), + ); + + if (output === "json") { + logger.log(JSON.stringify(items, null, 2)); + return; + } + + if (items.length === 0) { + logger.info("No endpoints found."); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Type", "Port", "URL"], + items.map((ep) => [ + ep.id ?? "", + ep.displayName ?? "", + ep.type ?? "", + String(ep.portMappings?.[0]?.containerPort ?? ""), + ep.publicHost ? `https://${ep.publicHost}` : "—", + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index a9e5de3..ccc7ebe 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { logger } from "../core/logger.ts"; import { findConfigFile, getConfigWritePath } from "./paths.ts"; -import { type ConfigFile, ConfigFileSchema } from "./schema.ts"; +import { type ConfigFile, ConfigFileSchema, type SandboxRecord } from "./schema.ts"; export interface ResolvedConfig { apiKey: string; @@ -76,7 +76,7 @@ function saveConfigFile(data: ConfigFile, filePath?: string): void { } export function setProfile(profile: string, apiKey: string): void { - const existing = loadConfigFile() ?? { profiles: {} }; + const existing = loadConfigFile() ?? { profiles: {}, sandboxes: {} }; existing.profiles[profile] = { api_key: apiKey, @@ -97,3 +97,20 @@ export function profileExists(profile: string): boolean { const file = loadConfigFile(); return !!file?.profiles[profile]; } + +export function getSandbox(name: string): SandboxRecord | null { + return loadConfigFile()?.sandboxes?.[name] ?? null; +} + +export function setSandbox(name: string, record: SandboxRecord): void { + const existing = loadConfigFile() ?? { profiles: {}, sandboxes: {} }; + existing.sandboxes[name] = record; + saveConfigFile(existing); +} + +export function deleteSandbox(name: string): void { + const existing = loadConfigFile(); + if (!existing) return; + delete existing.sandboxes[name]; + saveConfigFile(existing); +} diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 055d11b..1f2f918 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -5,10 +5,18 @@ export const ProfileSchema = z.object({ api_url: z.string().optional(), }); +export const SandboxRecordSchema = z.object({ + app_id: z.string(), + agent_token: z.string(), + ssh_host: z.string().nullable().optional(), +}); + export const ConfigFileSchema = z.object({ log_level: z.string().optional(), profiles: z.record(z.string(), ProfileSchema).default({}), + sandboxes: z.record(z.string(), SandboxRecordSchema).default({}), }); export type Profile = z.infer; export type ConfigFile = z.infer; +export type SandboxRecord = z.infer; diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 0000000..94254e4 --- /dev/null +++ b/sandbox/Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# System tools + SSH + Python +RUN apt-get update && apt-get install -y \ + openssh-server \ + curl wget \ + nano vim \ + git \ + procps htop \ + net-tools iputils-ping \ + unzip zip \ + jq \ + python3 python3-pip python3-venv python3-dev \ + build-essential \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && ln -sf /usr/bin/pip3 /usr/bin/pip \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/run/sshd \ + && sed -i 's|#\?Port 22$|Port 8023|' /etc/ssh/sshd_config \ + && sed -i 's|#\?PermitRootLogin.*|PermitRootLogin yes|' /etc/ssh/sshd_config \ + && sed -i 's|#\?PasswordAuthentication.*|PasswordAuthentication yes|' /etc/ssh/sshd_config + +# Node.js (LTS) +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Bun +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:$PATH" + +# Claude Code +RUN install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \ + -o /etc/apt/keyrings/claude-code.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main" \ + > /etc/apt/sources.list.d/claude-code.list \ + && apt-get update && apt-get install -y claude-code \ + && rm -rf /var/lib/apt/lists/* + +# Workplace — SSH sessions land here, bun on PATH, Claude config lives here +ENV CLAUDE_CONFIG_DIR=/workplace/.claude +RUN mkdir -p /workplace \ + && printf 'cd /workplace\nexport PATH="/root/.bun/bin:$PATH"\n' >> /root/.bashrc + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/sandbox/entrypoint.sh b/sandbox/entrypoint.sh new file mode 100644 index 0000000..6144832 --- /dev/null +++ b/sandbox/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +echo "root:${AGENT_TOKEN}" | chpasswd +exec /usr/sbin/sshd -D From de30c2d27684d85099dbec61d8d3f12cb09d9fb6 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Fri, 19 Jun 2026 12:00:25 +0200 Subject: [PATCH 2/3] fix the core style --- biome.json | 4 +- bun.lock | 37 ++--- package.json | 2 +- packages/cli/README.md | 24 ++-- packages/cli/src/commands/sandbox/create.ts | 129 +++++++++++------- packages/cli/src/commands/sandbox/delete.ts | 10 +- packages/cli/src/commands/sandbox/exec.ts | 15 +- packages/cli/src/commands/sandbox/index.ts | 20 +-- packages/cli/src/commands/sandbox/ssh-exec.ts | 28 ++-- packages/cli/src/commands/sandbox/ssh.ts | 32 +++-- packages/cli/src/commands/sandbox/url/add.ts | 47 +++++-- .../cli/src/commands/sandbox/url/delete.ts | 80 ++++++++--- .../cli/src/commands/sandbox/url/index.ts | 10 +- packages/cli/src/commands/sandbox/url/list.ts | 11 +- packages/cli/src/config/index.ts | 6 +- 15 files changed, 307 insertions(+), 148 deletions(-) diff --git a/biome.json b/biome.json index bec3927..a2a907d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -15,7 +15,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "suspicious": { "noExplicitAny": "off" }, diff --git a/bun.lock b/bun.lock index 10474be..ecbaf2d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,11 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "bun-ny-cli", "devDependencies": { - "@biomejs/biome": "^2.4.12", + "@biomejs/biome": "2.5.0", "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.30.0", "@types/bun": "latest", @@ -16,7 +17,7 @@ }, "packages/app-config": { "name": "@bunny.net/app-config", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6", @@ -24,7 +25,7 @@ }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.5.3", + "version": "0.7.0", "bin": { "bunny": "./bin/bunny.cjs", }, @@ -55,23 +56,23 @@ }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", @@ -172,7 +173,7 @@ }, "packages/openapi-client": { "name": "@bunny.net/openapi-client", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "openapi-fetch": "^0.17.0", }, @@ -223,23 +224,23 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], "@bunny.net/app-config": ["@bunny.net/app-config@workspace:packages/app-config"], diff --git a/package.json b/package.json index da9ca50..aa5b79d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "release": "changeset publish" }, "devDependencies": { - "@biomejs/biome": "^2.4.12", + "@biomejs/biome": "2.5.0", "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.30.0", "@types/bun": "latest", diff --git a/packages/cli/README.md b/packages/cli/README.md index 0fbd93c..9bec853 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -808,9 +808,9 @@ bunny sandbox create my-sandbox bunny sandbox create my-sandbox --region NY ``` -| Flag | Description | Default | -| ---------- | ---------------------------------------------------- | ------- | -| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` | +| Flag | Description | Default | +| ---------- | -------------------------------------------------- | ------- | +| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` | Once ready, the output shows the app ID, public HTTPS hostname, and SSH address. @@ -837,9 +837,9 @@ bunny sandbox delete my-sandbox --force bunny sandbox rm my-sandbox -f # alias ``` -| Flag | Alias | Description | Default | -| --------- | ----- | ------------------------- | ------- | -| `--force` | `-f` | Skip confirmation prompt | `false` | +| Flag | Alias | Description | Default | +| --------- | ----- | ------------------------ | ------- | +| `--force` | `-f` | Skip confirmation prompt | `false` | #### `bunny sandbox exec` @@ -856,9 +856,9 @@ bunny sandbox exec my-sandbox --cwd /tmp env bunny sandbox exec my-sandbox -- cat /etc/os-release ``` -| Flag | Description | Default | -| ------- | ---------------------------------------- | ------------ | -| `--cwd` | Working directory inside the sandbox | `/workplace` | +| Flag | Description | Default | +| ------- | ------------------------------------ | ------------ | +| `--cwd` | Working directory inside the sandbox | `/workplace` | #### `bunny sandbox ssh` @@ -884,9 +884,9 @@ bunny sandbox url add my-sandbox 3000 bunny sandbox url add my-sandbox 8080 --label my-api ``` -| Flag | Description | Default | -| --------- | ---------------------------------------------- | ---------------- | -| `--label` | Display name for the endpoint | `port-` | +| Flag | Description | Default | +| --------- | ----------------------------- | ------------- | +| `--label` | Display name for the endpoint | `port-` | ##### `bunny sandbox url list` diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 9bece7b..20119e6 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,5 +1,5 @@ -import { createMcClient } from "@bunny.net/openapi-client"; import { randomBytes } from "node:crypto"; +import { createMcClient } from "@bunny.net/openapi-client"; import { resolveConfig, setSandbox } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -19,7 +19,11 @@ const STARTUP_TIMEOUT_MS = 120_000; type App = Record & { id?: string; status?: string; - containerTemplates?: Array<{ endpoints?: Array & { type?: string; publicHost?: string }> }>; + containerTemplates?: Array<{ + endpoints?: Array< + Record & { type?: string; publicHost?: string } + >; + }>; }; function generateToken(): string { @@ -39,7 +43,11 @@ function extractAnycastHost(app: App): string | null { async function probeSsh(host: string, port: number): Promise { try { - const socket = await Bun.connect({ hostname: host, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + const socket = await Bun.connect({ + hostname: host, + port, + socket: { data() {}, open() {}, close() {}, error() {} }, + }); socket.end(); return true; } catch { @@ -60,9 +68,12 @@ async function waitUntilActive( const { data, error } = await client.GET("/apps/{appId}", { params: { path: { appId } }, }); - if (error) throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); + if (error) + throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); const app = data as App; - const status = (app as Record).status as string | undefined; + const status = (app as Record).status as + | string + | undefined; if (status === "failing" || status === "suspended") { throw new UserError(`Sandbox entered terminal state: ${status}`); } @@ -72,18 +83,24 @@ async function waitUntilActive( } if (!sshHost) { - throw new UserError(`Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`); + throw new UserError( + `Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`, + ); } // Phase 2: probe SSH port until the container accepts connections - const [sshIp, sshPortStr] = (sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"]) as [string, string]; + const [sshIp, sshPortStr] = ( + sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] + ) as [string, string]; const sshPort = Number(sshPortStr); while (Date.now() < deadline) { if (await probeSsh(sshIp, sshPort)) return { sshHost }; await Bun.sleep(POLL_INTERVAL_MS); } - throw new UserError(`Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`); + throw new UserError( + `Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`, + ); } interface CreateArgs { @@ -97,7 +114,10 @@ export const sandboxCreateCommand = defineCommand({ examples: [ ["$0 sandbox create", "Create a sandbox with a generated name"], ["$0 sandbox create my-sandbox", "Create a sandbox named my-sandbox"], - ["$0 sandbox create my-sandbox --region NY", "Create a sandbox in New York"], + [ + "$0 sandbox create my-sandbox --region NY", + "Create a sandbox in New York", + ], ], builder: (yargs) => @@ -121,47 +141,56 @@ export const sandboxCreateCommand = defineCommand({ const spin = spinner("Creating sandbox..."); spin.start(); - const { data: app, error: createError } = await (client as any).POST("/apps", { - body: { - name, - runtimeType: "shared", - autoScaling: { min: 1, max: 1 }, - regionSettings: { - allowedRegionIds: [region], - requiredRegionIds: [region], - }, - volumes: [ - { name: "workplace", size: 10 }, - ], - containerTemplates: [ - { - name: "agent", - imageRegistryId: IMAGE_REGISTRY_ID, - imageNamespace: IMAGE_NAMESPACE, - imageName: IMAGE_NAME, - imageTag: IMAGE_TAG, - imagePullPolicy: "ifNotPresent", - environmentVariables: [{ name: "AGENT_TOKEN", value: agentToken }], - volumeMounts: [ - { name: "workplace", mountPath: WORKPLACE }, - ], - endpoints: [ - { - displayName: "ssh", - anycast: { - type: "ipv4", - portMappings: [{ containerPort: 8023, exposedPort: 8023, protocols: ["Tcp"] }], - }, - }, - ], + const { data: app, error: createError } = await (client as any).POST( + "/apps", + { + body: { + name, + runtimeType: "shared", + autoScaling: { min: 1, max: 1 }, + regionSettings: { + allowedRegionIds: [region], + requiredRegionIds: [region], }, - ], + volumes: [{ name: "workplace", size: 10 }], + containerTemplates: [ + { + name: "agent", + imageRegistryId: IMAGE_REGISTRY_ID, + imageNamespace: IMAGE_NAMESPACE, + imageName: IMAGE_NAME, + imageTag: IMAGE_TAG, + imagePullPolicy: "ifNotPresent", + environmentVariables: [ + { name: "AGENT_TOKEN", value: agentToken }, + ], + volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], + endpoints: [ + { + displayName: "ssh", + anycast: { + type: "ipv4", + portMappings: [ + { + containerPort: 8023, + exposedPort: 8023, + protocols: ["Tcp"], + }, + ], + }, + }, + ], + }, + ], + }, }, - }); + ); if (createError || !app) { spin.stop(); - throw new UserError(`Failed to create sandbox: ${JSON.stringify(createError)}`); + throw new UserError( + `Failed to create sandbox: ${JSON.stringify(createError)}`, + ); } const appId = (app as Record).id as string; @@ -173,13 +202,19 @@ export const sandboxCreateCommand = defineCommand({ } catch (err) { spin.stop(); // best-effort cleanup - await client.DELETE("/apps/{appId}", { params: { path: { appId } } }).catch(() => {}); + await client + .DELETE("/apps/{appId}", { params: { path: { appId } } }) + .catch(() => {}); throw err; } spin.stop(); - const record = { app_id: appId, agent_token: agentToken, ssh_host: sshHost }; + const record = { + app_id: appId, + agent_token: agentToken, + ssh_host: sshHost, + }; setSandbox(name, record); logger.log(`Sandbox "${name}" is ready.`); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts index adc6934..eed4057 100644 --- a/packages/cli/src/commands/sandbox/delete.ts +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -1,5 +1,9 @@ import { createMcClient } from "@bunny.net/openapi-client"; -import { deleteSandbox, getSandbox, resolveConfig } from "../../config/index.ts"; +import { + deleteSandbox, + getSandbox, + resolveConfig, +} from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; @@ -41,7 +45,9 @@ export const sandboxDeleteCommand = defineCommand({ } if (!force) { - const ok = await confirm(`Delete sandbox "${name}" (app ${record.app_id})?`); + const ok = await confirm( + `Delete sandbox "${name}" (app ${record.app_id})?`, + ); if (!ok) { logger.info("Aborted."); return; diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index 3b7a810..c7d89be 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { WORKPLACE, sshArgs } from "./ssh-exec.ts"; +import { sshArgs, WORKPLACE } from "./ssh-exec.ts"; interface ExecArgs { name: string; @@ -14,7 +14,10 @@ export const sandboxExecCommand = defineCommand({ describe: "Run a shell command inside a sandbox via SSH.", examples: [ ["$0 sandbox exec my-sandbox uname -a", "Run a command"], - ["$0 sandbox exec my-sandbox --cwd /tmp ls -la", "Run with a working directory"], + [ + "$0 sandbox exec my-sandbox --cwd /tmp ls -la", + "Run with a working directory", + ], ], builder: (yargs) => @@ -40,10 +43,14 @@ export const sandboxExecCommand = defineCommand({ handler: async ({ name, command, cwd }) => { const record = getSandbox(name); if (!record) { - throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + throw new UserError( + `No sandbox named "${name}" found. Run: bunny sandbox create ${name}`, + ); } if (!record.ssh_host) { - throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + throw new UserError( + `Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`, + ); } const remoteCmd = `cd ${cwd} && ${command.join(" ")}`; diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts index 913fa10..e2f7947 100644 --- a/packages/cli/src/commands/sandbox/index.ts +++ b/packages/cli/src/commands/sandbox/index.ts @@ -6,11 +6,15 @@ import { sandboxListCommand } from "./list.ts"; import { sandboxSshCommand } from "./ssh.ts"; import { sandboxUrlNamespace } from "./url/index.ts"; -export const sandboxNamespace = defineNamespace("sandbox", "Manage sandboxes.", [ - sandboxCreateCommand, - sandboxListCommand, - sandboxDeleteCommand, - sandboxExecCommand, - sandboxSshCommand, - sandboxUrlNamespace, -]); +export const sandboxNamespace = defineNamespace( + "sandbox", + "Manage sandboxes.", + [ + sandboxCreateCommand, + sandboxListCommand, + sandboxDeleteCommand, + sandboxExecCommand, + sandboxSshCommand, + sandboxUrlNamespace, + ], +); diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts index efdf05c..5fd5958 100644 --- a/packages/cli/src/commands/sandbox/ssh-exec.ts +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -3,23 +3,33 @@ import type { SandboxRecord } from "../../config/schema.ts"; export const WORKPLACE = "/workplace"; export function sshArgs(record: SandboxRecord, remoteCmd: string): string[] { - const [host, portStr] = (record.ssh_host!.includes(":") - ? record.ssh_host!.split(":") - : [record.ssh_host!, "8023"]) as [string, string]; + const sshHost = record.ssh_host ?? "localhost"; + const [host, portStr] = ( + sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] + ) as [string, string]; return [ - "sshpass", "-p", record.agent_token, + "sshpass", + "-p", + record.agent_token, "ssh", - "-p", portStr, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", + "-p", + portStr, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", `root@${host}`, remoteCmd, ]; } -export async function runSshCommand(record: SandboxRecord, remoteCmd: string): Promise { +export async function runSshCommand( + record: SandboxRecord, + remoteCmd: string, +): Promise { const proc = Bun.spawn(sshArgs(record, remoteCmd), { stdin: "inherit", stdout: "inherit", diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts index 9d85ac3..024b8cf 100644 --- a/packages/cli/src/commands/sandbox/ssh.ts +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -18,25 +18,37 @@ export const sandboxSshCommand = defineCommand({ handler: async ({ name }) => { const record = getSandbox(name); if (!record) { - throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + throw new UserError( + `No sandbox named "${name}" found. Run: bunny sandbox create ${name}`, + ); } if (!record.ssh_host) { - throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + throw new UserError( + `Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`, + ); } - const [host, portStr] = (record.ssh_host.includes(":") - ? record.ssh_host.split(":") - : [record.ssh_host, "8023"]) as [string, string]; + const [host, portStr] = ( + record.ssh_host.includes(":") + ? record.ssh_host.split(":") + : [record.ssh_host, "8023"] + ) as [string, string]; const proc = Bun.spawn( [ - "sshpass", "-p", record.agent_token, + "sshpass", + "-p", + record.agent_token, "ssh", "-t", - "-p", portStr, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", + "-p", + portStr, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", `root@${host}`, `cd ${WORKPLACE} && exec bash -l`, ], diff --git a/packages/cli/src/commands/sandbox/url/add.ts b/packages/cli/src/commands/sandbox/url/add.ts index 34d5299..f50219d 100644 --- a/packages/cli/src/commands/sandbox/url/add.ts +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -17,14 +17,28 @@ export const sandboxUrlAddCommand = defineCommand({ describe: "Expose a port as a public CDN endpoint.", examples: [ ["$0 sandbox url add my-sandbox 3000", "Expose port 3000"], - ["$0 sandbox url add my-sandbox 8080 --label api", "Expose port 8080 as 'api'"], + [ + "$0 sandbox url add my-sandbox 8080 --label api", + "Expose port 8080 as 'api'", + ], ], builder: (yargs) => yargs - .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) - .positional("port", { type: "number", demandOption: true, describe: "Container port to expose" }) - .option("label", { type: "string", describe: "Endpoint display name (defaults to 'port-')" }), + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("port", { + type: "number", + demandOption: true, + describe: "Container port to expose", + }) + .option("label", { + type: "string", + describe: "Endpoint display name (defaults to 'port-')", + }), handler: async ({ name, port, label, profile, apiKey, verbose, output }) => { const record = getSandbox(name); @@ -45,7 +59,9 @@ export const sandboxUrlAddCommand = defineCommand({ throw new UserError(`Failed to fetch app: ${JSON.stringify(appError)}`); } - const containerId = (app as any).containerTemplates?.[0]?.id as string | undefined; + const containerId = (app as any).containerTemplates?.[0]?.id as + | string + | undefined; if (!containerId) { spin.stop(); throw new UserError("Could not find container template ID."); @@ -70,7 +86,9 @@ export const sandboxUrlAddCommand = defineCommand({ if (epError) { spin.stop(); - throw new UserError(`Failed to create endpoint: ${JSON.stringify(epError)}`); + throw new UserError( + `Failed to create endpoint: ${JSON.stringify(epError)}`, + ); } const endpointId = (ep as any)?.id as string; @@ -85,19 +103,30 @@ export const sandboxUrlAddCommand = defineCommand({ params: { path: { appId: record.app_id } }, }); const found = (list?.items ?? []).find((e: any) => e.id === endpointId); - if (found?.publicHost) { publicHost = found.publicHost; break; } + if (found?.publicHost) { + publicHost = found.publicHost; + break; + } } spin.stop(); if (output === "json") { - logger.log(JSON.stringify({ id: endpointId, displayName, port, publicHost }, null, 2)); + logger.log( + JSON.stringify( + { id: endpointId, displayName, port, publicHost }, + null, + 2, + ), + ); return; } logger.log(`Endpoint "${displayName}" created.`); logger.log(` Port: ${port}`); logger.log(` ID: ${endpointId}`); - logger.log(` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`); + logger.log( + ` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`, + ); }, }); diff --git a/packages/cli/src/commands/sandbox/url/delete.ts b/packages/cli/src/commands/sandbox/url/delete.ts index a73ed98..7643ed9 100644 --- a/packages/cli/src/commands/sandbox/url/delete.ts +++ b/packages/cli/src/commands/sandbox/url/delete.ts @@ -16,15 +16,37 @@ export const sandboxUrlDeleteCommand = defineCommand({ command: "delete ", aliases: ["rm"], describe: "Delete a public endpoint from a sandbox.", - examples: [["$0 sandbox url delete my-sandbox port-3000", "Delete endpoint by name"]], + examples: [ + ["$0 sandbox url delete my-sandbox port-3000", "Delete endpoint by name"], + ], builder: (yargs) => yargs - .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) - .positional("endpoint-name", { type: "string", demandOption: true, describe: "Endpoint name (from url list)" }) - .option("force", { alias: "f", type: "boolean", default: false, describe: "Skip confirmation" }), - - handler: async ({ name, "endpoint-name": endpointName, force, profile, apiKey, verbose }) => { + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("endpoint-name", { + type: "string", + demandOption: true, + describe: "Endpoint name (from url list)", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation", + }), + + handler: async ({ + name, + "endpoint-name": endpointName, + force, + profile, + apiKey, + verbose, + }) => { const record = getSandbox(name); if (!record) throw new UserError(`No sandbox named "${name}" found.`); @@ -34,32 +56,54 @@ export const sandboxUrlDeleteCommand = defineCommand({ const spin = spinner("Looking up endpoint..."); spin.start(); - const { data, error: listError } = await client.GET("/apps/{appId}/endpoints", { - params: { path: { appId: record.app_id } }, - }); + const { data, error: listError } = await client.GET( + "/apps/{appId}/endpoints", + { + params: { path: { appId: record.app_id } }, + }, + ); spin.stop(); - if (listError) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(listError)}`); + if (listError) + throw new UserError( + `Failed to fetch endpoints: ${JSON.stringify(listError)}`, + ); - const ep = (data?.items ?? [] as any[]).find((e: any) => e.displayName === endpointName); - if (!ep) throw new UserError(`No endpoint named "${endpointName}" found. Run: bunny sandbox url list ${name}`); + const ep = (data?.items ?? ([] as any[])).find( + (e: any) => e.displayName === endpointName, + ); + if (!ep) + throw new UserError( + `No endpoint named "${endpointName}" found. Run: bunny sandbox url list ${name}`, + ); if (!force) { - const ok = await confirm(`Delete endpoint "${endpointName}" from sandbox "${name}"?`); - if (!ok) { logger.info("Aborted."); return; } + const ok = await confirm( + `Delete endpoint "${endpointName}" from sandbox "${name}"?`, + ); + if (!ok) { + logger.info("Aborted."); + return; + } } const spin2 = spinner("Deleting endpoint..."); spin2.start(); - const { error } = await client.DELETE("/apps/{appId}/endpoints/{endpointId}", { - params: { path: { appId: record.app_id, endpointId: ep.id } }, - }); + const { error } = await client.DELETE( + "/apps/{appId}/endpoints/{endpointId}", + { + params: { path: { appId: record.app_id, endpointId: ep.id } }, + }, + ); spin2.stop(); - if (error) throw new UserError(`Failed to delete endpoint: ${JSON.stringify(error)}`); + if (error) + throw new UserError( + `Failed to delete endpoint: ${JSON.stringify(error)}`, + ); logger.log(`Endpoint "${endpointName}" deleted.`); }, diff --git a/packages/cli/src/commands/sandbox/url/index.ts b/packages/cli/src/commands/sandbox/url/index.ts index c5b88b0..1cc964f 100644 --- a/packages/cli/src/commands/sandbox/url/index.ts +++ b/packages/cli/src/commands/sandbox/url/index.ts @@ -3,8 +3,8 @@ import { sandboxUrlAddCommand } from "./add.ts"; import { sandboxUrlDeleteCommand } from "./delete.ts"; import { sandboxUrlListCommand } from "./list.ts"; -export const sandboxUrlNamespace = defineNamespace("url", "Manage public URL endpoints for a sandbox.", [ - sandboxUrlAddCommand, - sandboxUrlListCommand, - sandboxUrlDeleteCommand, -]); +export const sandboxUrlNamespace = defineNamespace( + "url", + "Manage public URL endpoints for a sandbox.", + [sandboxUrlAddCommand, sandboxUrlListCommand, sandboxUrlDeleteCommand], +); diff --git a/packages/cli/src/commands/sandbox/url/list.ts b/packages/cli/src/commands/sandbox/url/list.ts index 2b4d22e..4e639c4 100644 --- a/packages/cli/src/commands/sandbox/url/list.ts +++ b/packages/cli/src/commands/sandbox/url/list.ts @@ -14,7 +14,11 @@ export const sandboxUrlListCommand = defineCommand({ examples: [["$0 sandbox url list my-sandbox", "List all endpoints"]], builder: (yargs) => - yargs.positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }), + yargs.positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }), handler: async ({ name, profile, apiKey, verbose, output }: any) => { const record = getSandbox(name); @@ -32,7 +36,10 @@ export const sandboxUrlListCommand = defineCommand({ spin.stop(); - if (error) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(error)}`); + if (error) + throw new UserError( + `Failed to fetch endpoints: ${JSON.stringify(error)}`, + ); const DEFAULT_ENDPOINTS = new Set(["api", "ssh"]); const items = ((data?.items ?? []) as any[]).filter( diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index ccc7ebe..dca92a5 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -2,7 +2,11 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { logger } from "../core/logger.ts"; import { findConfigFile, getConfigWritePath } from "./paths.ts"; -import { type ConfigFile, ConfigFileSchema, type SandboxRecord } from "./schema.ts"; +import { + type ConfigFile, + ConfigFileSchema, + type SandboxRecord, +} from "./schema.ts"; export interface ResolvedConfig { apiKey: string; From 1ec4b006542138ecb2f4aa060e297a013d533ebd Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Mon, 22 Jun 2026 14:28:27 +0200 Subject: [PATCH 3/3] add prompt to sandbox names --- packages/cli/src/commands/sandbox/create.ts | 33 ++++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 20119e6..fb0a710 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { createMcClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; import { resolveConfig, setSandbox } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -104,7 +105,7 @@ async function waitUntilActive( } interface CreateArgs { - name: string; + name?: string; region: string; } @@ -112,7 +113,7 @@ export const sandboxCreateCommand = defineCommand({ command: "create [name]", describe: "Create and start a new sandbox.", examples: [ - ["$0 sandbox create", "Create a sandbox with a generated name"], + ["$0 sandbox create", "Interactive: prompts for a sandbox name"], ["$0 sandbox create my-sandbox", "Create a sandbox named my-sandbox"], [ "$0 sandbox create my-sandbox --region NY", @@ -124,7 +125,6 @@ export const sandboxCreateCommand = defineCommand({ yargs .positional("name", { type: "string", - default: "sandbox", describe: "Name for the sandbox", }) .option("region", { @@ -133,9 +133,24 @@ export const sandboxCreateCommand = defineCommand({ describe: "Region ID to deploy the sandbox in (e.g. AMS, NY, LA)", }), - handler: async ({ profile, verbose, apiKey, name, region }) => { + handler: async ({ profile, verbose, apiKey, name, region, output }) => { const config = resolveConfig(profile, apiKey, verbose); const client = createMcClient(clientOptions(config, verbose)); + + // JSON output stays non-interactive; the name must come from the positional. + const interactive = output !== "json"; + + let sandboxName = name; + if (!sandboxName && interactive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Sandbox name:", + }); + sandboxName = value; + } + if (!sandboxName) throw new UserError("A sandbox name is required."); + const agentToken = generateToken(); const spin = spinner("Creating sandbox..."); @@ -145,7 +160,7 @@ export const sandboxCreateCommand = defineCommand({ "/apps", { body: { - name, + name: sandboxName, runtimeType: "shared", autoScaling: { min: 1, max: 1 }, regionSettings: { @@ -215,11 +230,13 @@ export const sandboxCreateCommand = defineCommand({ agent_token: agentToken, ssh_host: sshHost, }; - setSandbox(name, record); + setSandbox(sandboxName, record); - logger.log(`Sandbox "${name}" is ready.`); + logger.log(`Sandbox "${sandboxName}" is ready.`); logger.log(` App ID: ${appId}`); logger.log(` SSH: ${sshHost}`); - logger.log(`\nRun commands with: bunny sandbox exec ${name} `); + logger.log( + `\nRun commands with: bunny sandbox exec ${sandboxName} `, + ); }, });