Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sandbox-cp-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

feat(sandbox): add `bunny sandbox cp` to copy files between your machine and a sandbox over SFTP (`<sandbox>:<path>` on either side). Uploads preserve the local file mode; a trailing slash or existing directory keeps the source filename.
5 changes: 5 additions & 0 deletions .changeset/sandbox-exec-separator-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sandbox): `sandbox exec` now honors the documented `-- <command>` separator, and a repeatable `--env` flag no longer greedily swallows the command that follows it
5 changes: 5 additions & 0 deletions .changeset/sandbox-ls-and-exec-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(sandbox): add `bunny sandbox files list` (alias: `ls`) to list files in a sandbox directory over SFTP (bare name lists `/workplace`, or `<sandbox>:<path>`), and `--timeout` on `bunny sandbox exec` to close the SSH connection and exit 124 after N seconds.
5 changes: 5 additions & 0 deletions .changeset/sandbox-streaming-and-disposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/sandbox": patch
---

feat(sandbox): stream blocking command output via `onStdout`/`onStderr` callbacks (composes with `timeout`/`signal`), and support `using`/`await using` (Symbol.dispose/asyncDispose) to release the SSH connection when a sandbox leaves scope.
5 changes: 5 additions & 0 deletions .changeset/sandbox-timeouts-file-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/sandbox": patch
---

feat(sandbox): runCommand timeout and AbortSignal cancellation, plus listFiles, deleteFile, rename, and exists file operations
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ This is a Bun workspace monorepo with six packages:
- **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools.
- **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`).
- **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm.
- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. Zero CLI dependencies.
- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies.
- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`.

```
Expand Down Expand Up @@ -157,12 +157,12 @@ bunny-cli/
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # Barrel export: Sandbox, Command, types
│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand, writeFiles, readFile, mkDir, exposePort, domain, getEnv/setEnv/unsetEnv (persisted env), delete
│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand (timeout/signal/onStdout/onStderr), writeFiles, readFile, listFiles, deleteFile, rename, exists, mkDir, exposePort, domain, getEnv/setEnv/unsetEnv (persisted env), delete, disconnect, Symbol.dispose/asyncDispose
│ │ ├── provision.ts # Magic Containers app create/poll/endpoints + auth helpers + container env read/replace
│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec, file IO, reachability)
│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec with limits, file IO, reachability)
│ │ ├── command.ts # Command (detached, logs()) and CommandFinished
│ │ ├── types.ts # Option and handle types
│ │ ├── errors.ts # SandboxError
│ │ ├── errors.ts # SandboxError, CommandTimeoutError
│ │ └── sandbox.test.ts # Tests for pure logic (command building, app extraction)
│ │
│ └── cli/ # @bunny.net/cli package
Expand Down Expand Up @@ -413,16 +413,20 @@ bunny-cli/
│ │ ├── create.ts # Create a sandbox (--region, -e/--env + --env-file bake persisted env vars in)
│ │ ├── list.ts # List sandboxes
│ │ ├── delete.ts # Delete a sandbox and its MC app (--force)
│ │ ├── exec.ts # Run a command via SSH (-e/--env + --env-file inject temporary env vars)
│ │ ├── exec.ts # Run a command via SSH (-e/--env + --env-file inject temporary env vars; --timeout kills after N seconds, exit 124)
│ │ ├── cp.ts # Copy a file to/from a sandbox over SFTP (<sandbox>:<path> picks direction)
│ │ ├── resolve.ts # Shared helpers: parseRemoteRef, sandboxFromName (rebuild a Sandbox from the stored record), connectSandbox (requires SSH endpoint)
│ │ ├── ssh.ts # Open an interactive SSH shell (-e/--env + --env-file inject temporary env vars)
│ │ ├── ssh-exec.ts # Shared SSH helpers: sshArgs, withSshEnv (askpass token), envPrefix (inline KEY='v' assignments)
│ │ ├── env-args.ts # Shared -e/--env + --env-file parsing: withEnvOptions, collectEnv, parseDotenv, splitPair
│ │ ├── files/ # `sandbox files` (alias: file): file operations over SFTP
│ │ │ ├── index.ts # defineNamespace("files", ...)
│ │ │ └── list.ts # List files in a sandbox directory (alias: ls; bare name = /workplace, or <sandbox>:<path>)
│ │ ├── url/ # `sandbox url`: expose/list/delete public CDN endpoints for a port
│ │ │ ├── index.ts # defineNamespace("url", ...)
│ │ │ └── add.ts / list.ts / delete.ts
│ │ └── env/ # `sandbox env`: persisted env vars (survive restart, unlike exec/ssh temp env)
│ │ ├── index.ts # defineNamespace("env", ...)
│ │ ├── resolve.ts # sandboxFromName(): rebuild a Sandbox handle from the stored record for API calls
│ │ ├── set.ts # Persist vars (KEY=VALUE pairs or --env-file), merges with existing
│ │ ├── list.ts # List persisted vars (AGENT_TOKEN hidden)
│ │ └── delete.ts # Remove persisted vars (aliases: rm, unset)
Expand Down
56 changes: 51 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -930,16 +930,62 @@ bunny sandbox exec my-sandbox -- cat /etc/os-release
# Inject temporary environment variables for this command only
bunny sandbox exec my-sandbox --env DEBUG=1 -- node app.js
bunny sandbox exec my-sandbox --env-file .env -- printenv

# Give up after 30 seconds (exit code 124)
bunny sandbox exec my-sandbox --timeout 30 -- bun run build
```

| Flag | Alias | Description | Default |
| ------------ | ----- | ------------------------------------------------ | ------------ |
| `--cwd` | | Working directory inside the sandbox | `/workplace` |
| `--env` | | Environment variable as `KEY=VALUE` (repeatable) | |
| `--env-file` | | Load environment variables from a dotenv file | |
| Flag | Alias | Description | Default |
| ------------ | ----- | --------------------------------------------------------------- | ------------ |
| `--cwd` | | Working directory inside the sandbox | `/workplace` |
| `--env` | | Environment variable as `KEY=VALUE` (repeatable) | |
| `--env-file` | | Load environment variables from a dotenv file | |
| `--timeout` | | Close the SSH connection and exit `124` after this many seconds | |

Variables passed here apply only to that single command and are **not** persisted. For persistent variables, use [`bunny sandbox env`](#bunny-sandbox-env).

#### `bunny sandbox cp`

Copy a file between your machine and a sandbox over SFTP. Exactly one of the two paths must reference a sandbox as `<sandbox>:<path>`; the other is a local path. Remote paths follow the same rules as elsewhere — relative paths resolve against `/workplace`.

```bash
# Upload a file into the sandbox
bunny sandbox cp ./app.js my-sandbox:/workplace/app.js

# Upload relative to /workplace
bunny sandbox cp ./app.js my-sandbox:app.js

# A trailing slash on the destination keeps the source filename
bunny sandbox cp ./app.js my-sandbox:/workplace/src/

# Download a file from the sandbox
bunny sandbox cp my-sandbox:/workplace/out.log ./out.log

# Download into an existing directory (keeps the source filename)
bunny sandbox cp my-sandbox:/workplace/out.log ./logs/
```

Uploads preserve the local file's Unix mode (so executables stay executable). Only single files are supported — directory and sandbox-to-sandbox copies are not.

#### `bunny sandbox files`

Manage files inside a sandbox over SFTP. A bare sandbox name targets `/workplace`; use `<sandbox>:<path>` for a specific directory (relative paths resolve against `/workplace`).

```bash
# List /workplace
bunny sandbox files list my-sandbox
bunny sandbox files ls my-sandbox # alias

# List a specific directory
bunny sandbox files ls my-sandbox:/workplace/src
bunny sandbox files ls my-sandbox:src

# Machine-readable listing (name, type, size, mode)
bunny sandbox files ls my-sandbox --output json
```

Columns: Name, Type (`file`/`directory`/`symlink`/`other`), Size, Mode (octal permissions). To copy files in and out, use [`bunny sandbox cp`](#bunny-sandbox-cp).

#### `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.
Expand Down
126 changes: 126 additions & 0 deletions packages/cli/src/commands/sandbox/cp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { stat } from "node:fs/promises";
import { basename, join } from "node:path";
import { SandboxError } from "@bunny.net/sandbox";
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 { connectSandbox, parseRemoteRef } from "./resolve.ts";

interface CpArgs {
source: string;
dest: string;
}

async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}

export const sandboxCpCommand = defineCommand<CpArgs>({
command: "cp <source> <dest>",
describe: "Copy files between your machine and a sandbox.",
examples: [
["$0 sandbox cp ./app.js my-sandbox:/workplace/app.js", "Upload a file"],
[
"$0 sandbox cp ./app.js my-sandbox:app.js",
"Upload (relative to workplace)",
],
[
"$0 sandbox cp my-sandbox:/workplace/out.log ./out.log",
"Download a file",
],
],

builder: (yargs) =>
yargs
.positional("source", {
type: "string",
demandOption: true,
describe: "Source path (local, or <sandbox>:<path>)",
})
.positional("dest", {
type: "string",
demandOption: true,
describe: "Destination path (local, or <sandbox>:<path>)",
}),

handler: async ({ source, dest, profile, apiKey, verbose, output }) => {
const srcRemote = parseRemoteRef(source);
const destRemote = parseRemoteRef(dest);

if (srcRemote && destRemote) {
throw new UserError(
"Sandbox-to-sandbox copies are not supported. One side must be a local path.",
);
}
const ref = srcRemote ?? destRemote;
if (!ref) {
throw new UserError(
"One path must reference a sandbox as <sandbox>:<path>.",
);
}
const sandbox = connectSandbox(ref.sandbox, profile, apiKey, verbose);

const uploading = Boolean(destRemote);
const spin = spinner(uploading ? "Uploading..." : "Downloading...");
spin.start();

let from: string;
let to: string;
try {
if (uploading) {
const local = source;
const info = await stat(local).catch(() => null);
if (!info) {
throw new UserError(`Local file not found: ${local}`);
}
if (info.isDirectory()) {
throw new UserError(
`${local} is a directory. Only single files can be copied.`,
);
}
// A trailing slash on the remote path means "into this directory".
const remotePath = ref.path.endsWith("/")
? `${ref.path}${basename(local)}`
: ref.path;
const content = Buffer.from(await Bun.file(local).arrayBuffer());
await sandbox.writeFiles([
{ path: remotePath, content, mode: info.mode & 0o777 },
]);
from = local;
to = `${ref.sandbox}:${remotePath}`;
} else {
const content = await sandbox.readFile(ref.path);
if (content === null) {
throw new UserError(`File not found in sandbox: ${ref.path}`);
}
// An existing local directory (or trailing slash) means "into it".
const localPath =
dest.endsWith("/") || (await isDirectory(dest))
? join(dest, basename(ref.path))
: dest;
await Bun.write(localPath, content);
from = `${ref.sandbox}:${ref.path}`;
to = localPath;
}
} catch (err) {
spin.stop();
if (err instanceof SandboxError) throw new UserError(err.message);
throw err;
} finally {
sandbox.disconnect();
}

spin.stop();

if (output === "json") {
logger.log(JSON.stringify({ from, to }, null, 2));
return;
}
logger.log(`Copied ${from} -> ${to}`);
},
});
2 changes: 2 additions & 0 deletions packages/cli/src/commands/sandbox/env-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function withEnvOptions<T>(
...(shortAlias ? { alias: "e" } : {}),
type: "string",
array: true,
// One value per flag; a greedy array would swallow `-- <command>`.
nargs: 1,
describe: "Set an environment variable as KEY=VALUE (repeatable)",
})
.option("env-file", {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/sandbox/env/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { sandboxFromName } from "./resolve.ts";
import { sandboxFromName } from "../resolve.ts";

interface DeleteArgs {
name: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/sandbox/env/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { UserError } from "../../../core/errors.ts";
import { formatTable } from "../../../core/format.ts";
import { logger } from "../../../core/logger.ts";
import { spinner } from "../../../core/ui.ts";
import { sandboxFromName } from "./resolve.ts";
import { sandboxFromName } from "../resolve.ts";

interface ListArgs {
name: string;
Expand Down
31 changes: 0 additions & 31 deletions packages/cli/src/commands/sandbox/env/resolve.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/sandbox/env/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { UserError } from "../../../core/errors.ts";
import { logger } from "../../../core/logger.ts";
import { spinner } from "../../../core/ui.ts";
import { collectEnv } from "../env-args.ts";
import { sandboxFromName } from "./resolve.ts";
import { sandboxFromName } from "../resolve.ts";

interface SetArgs {
name: string;
Expand Down
Loading