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/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 bb07432..9bec853 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..fb0a710 --- /dev/null +++ b/packages/cli/src/commands/sandbox/create.ts @@ -0,0 +1,242 @@ +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"; +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< + Record & { 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", "Interactive: prompts for a sandbox 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", + 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, 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..."); + spin.start(); + + const { data: app, error: createError } = await (client as any).POST( + "/apps", + { + body: { + name: sandboxName, + 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(sandboxName, record); + + logger.log(`Sandbox "${sandboxName}" is ready.`); + logger.log(` App ID: ${appId}`); + logger.log(` SSH: ${sshHost}`); + logger.log( + `\nRun commands with: bunny sandbox exec ${sandboxName} `, + ); + }, +}); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts new file mode 100644 index 0000000..eed4057 --- /dev/null +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -0,0 +1,76 @@ +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..c7d89be --- /dev/null +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -0,0 +1,66 @@ +import { getSandbox } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { sshArgs, WORKPLACE } 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..e2f7947 --- /dev/null +++ b/packages/cli/src/commands/sandbox/index.ts @@ -0,0 +1,20 @@ +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..5fd5958 --- /dev/null +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -0,0 +1,39 @@ +import type { SandboxRecord } from "../../config/schema.ts"; + +export const WORKPLACE = "/workplace"; + +export function sshArgs(record: SandboxRecord, remoteCmd: 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, + "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..024b8cf --- /dev/null +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -0,0 +1,60 @@ +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..f50219d --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -0,0 +1,132 @@ +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..7643ed9 --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/delete.ts @@ -0,0 +1,110 @@ +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..1cc964f --- /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..4e639c4 --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/list.ts @@ -0,0 +1,73 @@ +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..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 } from "./schema.ts"; +import { + type ConfigFile, + ConfigFileSchema, + type SandboxRecord, +} from "./schema.ts"; export interface ResolvedConfig { apiKey: string; @@ -76,7 +80,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 +101,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