Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions .changeset/sandbox-env-vars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@bunny.net/sandbox": minor
"@bunny.net/cli": minor
---

feat(sandbox): add environment variable support

- SDK: `Sandbox` gains `getEnv`/`setEnv`/`unsetEnv` to read and persist container env vars after creation (merges with the existing set, preserves reserved keys).
- CLI: `sandbox create`, `sandbox exec`, and `sandbox ssh` accept `-e/--env KEY=VALUE` (repeatable) and `--env-file`. Vars on `create` are persisted; on `exec`/`ssh` they are temporary for that invocation.
- CLI: new `sandbox env` namespace (`set`/`list`/`delete`) to manage persisted env vars.
25 changes: 22 additions & 3 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`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Zero CLI dependencies.
- **`@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/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,8 +157,8 @@ 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, delete
│ │ ├── provision.ts # Magic Containers app create/poll/endpoints + auth helpers
│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand, writeFiles, readFile, mkDir, exposePort, domain, getEnv/setEnv/unsetEnv (persisted env), delete
│ │ ├── provision.ts # Magic Containers app create/poll/endpoints + auth helpers + container env read/replace
│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec, file IO, reachability)
│ │ ├── command.ts # Command (detached, logs()) and CommandFinished
│ │ ├── types.ts # Option and handle types
Expand Down Expand Up @@ -408,6 +408,25 @@ bunny-cli/
│ │ ├── remove.ts # Remove environment variable
│ │ └── pull.ts # Pull environment variables to .env file
│ │
│ ├── sandbox/ # `sandbox`: ephemeral dev sandboxes over @bunny.net/sandbox
│ │ ├── index.ts # defineNamespace("sandbox", ...) — registers all sandbox commands
│ │ ├── 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)
│ │ ├── 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
│ │ ├── 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)
│ │
│ └── utils/ # Shared utility functions
├── package.json # Workspace root (workspaces: ["packages/*"])
Expand Down
82 changes: 76 additions & 6 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -871,11 +871,19 @@ bunny sandbox create my-sandbox

# Create in a specific region
bunny sandbox create my-sandbox --region NY

# Bake in environment variables (persisted for the sandbox's lifetime)
bunny sandbox create my-sandbox -e NODE_ENV=production -e PORT=8080
bunny sandbox create my-sandbox --env-file .env
```

| Flag | Description | Default |
| ---------- | -------------------------------------------------- | ------- |
| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` |
| Flag | Alias | Description | Default |
| ------------ | ----- | -------------------------------------------------- | ------- |
| `--region` | | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` |
| `--env` | `-e` | Environment variable as `KEY=VALUE` (repeatable) | |
| `--env-file` | | Load environment variables from a dotenv file | |

Variables set at creation are baked into the container and persist across restarts. Values from `--env` override those loaded from `--env-file`. To change them later, use [`bunny sandbox env`](#bunny-sandbox-env).

Once ready, the output shows the app ID, public HTTPS hostname, and SSH address.

Expand Down Expand Up @@ -919,20 +927,38 @@ bunny sandbox exec my-sandbox --cwd /tmp env

# Pipe-friendly: exit code is propagated
bunny sandbox exec my-sandbox -- cat /etc/os-release

# Inject temporary environment variables for this command only
bunny sandbox exec my-sandbox -e DEBUG=1 -- node app.js
bunny sandbox exec my-sandbox --env-file .env -- printenv
```

| Flag | Description | Default |
| ------- | ------------------------------------ | ------------ |
| `--cwd` | Working directory inside the sandbox | `/workplace` |
| Flag | Alias | Description | Default |
| ------------ | ----- | ------------------------------------------------ | ------------ |
| `--cwd` | | Working directory inside the sandbox | `/workplace` |
| `--env` | `-e` | Environment variable as `KEY=VALUE` (repeatable) | |
| `--env-file` | | Load environment variables from a dotenv file | |

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 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

# Set temporary environment variables for the session
bunny sandbox ssh my-sandbox -e DEBUG=1 --env-file .env
```

| Flag | Alias | Description | Default |
| ------------ | ----- | ------------------------------------------------ | ------- |
| `--env` | `-e` | Environment variable as `KEY=VALUE` (repeatable) | |
| `--env-file` | | Load environment variables from a dotenv file | |

Variables apply only to the session and are not persisted.

#### `bunny sandbox url`

Manage public CDN endpoints for ports running inside a sandbox. Useful for exposing a dev server or API to the internet.
Expand Down Expand Up @@ -980,6 +1006,50 @@ bunny sandbox url rm my-sandbox my-api -f # alias
| --------- | ----- | ------------------------ | ------- |
| `--force` | `-f` | Skip confirmation prompt | `false` |

#### `bunny sandbox env`

Manage a sandbox's **persistent** environment variables, the ones baked into the container. Unlike the temporary `--env` passed to `exec`/`ssh`, these survive across sessions. Changing them redeploys the sandbox with the new environment (running processes restart).

##### `bunny sandbox env set`

Set one or more persistent variables, merging with the existing set.

```bash
# Set a single variable
bunny sandbox env set my-sandbox NODE_ENV=production

# Set several at once
bunny sandbox env set my-sandbox API_URL=https://api.example.com LOG_LEVEL=debug

# Load from a dotenv file
bunny sandbox env set my-sandbox --env-file .env
```

| Flag | Description | Default |
| ------------ | --------------------------------------------- | ------- |
| `--env-file` | Load environment variables from a dotenv file | |

##### `bunny sandbox env list`

List the sandbox's persistent variables. The internal `AGENT_TOKEN` is hidden.

```bash
bunny sandbox env list my-sandbox
bunny sandbox env ls my-sandbox # alias
```

Columns: Name, Value.

##### `bunny sandbox env delete`

Remove one or more persistent variables. Names that are not set are reported and skipped; if none match, the command errors and nothing is redeployed.

```bash
bunny sandbox env delete my-sandbox NODE_ENV
bunny sandbox env rm my-sandbox API_URL LOG_LEVEL # alias
bunny sandbox env unset my-sandbox API_URL # alias
```

### `bunny api`

Make a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key.
Expand Down
42 changes: 30 additions & 12 deletions packages/cli/src/commands/sandbox/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ 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 { collectEnv, type EnvOptionArgs, withEnvOptions } from "./env-args.ts";

const DEFAULT_REGION = "AMS";

interface CreateArgs {
interface CreateArgs extends EnvOptionArgs {
name?: string;
region: string;
}
Expand All @@ -23,22 +24,38 @@ export const sandboxCreateCommand = defineCommand<CreateArgs>({
"$0 sandbox create my-sandbox --region NY",
"Create a sandbox in New York",
],
[
"$0 sandbox create my-sandbox -e NODE_ENV=production --env-file .env",
"Bake environment variables into the sandbox",
],
],

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)",
}),
withEnvOptions(
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 }) => {
handler: async ({
profile,
verbose,
apiKey,
name,
region,
env,
envFile,
output,
}) => {
const config = resolveConfig(profile, apiKey, verbose);
const envVars = await collectEnv(env, envFile);

// JSON output stays non-interactive; the name must come from the positional.
const interactive = output !== "json";
Expand Down Expand Up @@ -66,6 +83,7 @@ export const sandboxCreateCommand = defineCommand<CreateArgs>({
onDebug: (msg) => logger.debug(msg, true),
name: sandboxName,
region,
env: envVars,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject reserved AGENT_TOKEN during sandbox creation

The new create-time env forwarding allows --env AGENT_TOKEN=... to reach sandbox provisioning, while the SDK generates its own agent token for SSH and later setEnv explicitly reserves AGENT_TOKEN. Passing it at creation sends a duplicate or overriding AGENT_TOKEN to Magic Containers, which can leave the container using a different SSH token than the one saved locally. Apply the same reserved-key check before forwarding create env vars.

Useful? React with 👍 / 👎.

});
} catch (err) {
spin.stop();
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/commands/sandbox/env-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { collectEnv, parseDotenv, splitPair } from "./env-args.ts";
import { envPrefix } from "./ssh-exec.ts";

describe("splitPair", () => {
test("splits on the first =", () => {
expect(splitPair("URL=http://x?a=b")).toEqual(["URL", "http://x?a=b"]);
});

test("allows empty values", () => {
expect(splitPair("EMPTY=")).toEqual(["EMPTY", ""]);
});

test("rejects entries without =", () => {
expect(() => splitPair("NOPE")).toThrow("Expected KEY=VALUE");
});

test("rejects invalid key names", () => {
expect(() => splitPair("1BAD=x")).toThrow("Invalid environment variable");
expect(() => splitPair("has-dash=x")).toThrow();
});
});

describe("parseDotenv", () => {
test("parses lines, comments, quotes and export", () => {
const env = parseDotenv(
[
"# comment",
"",
"A=1",
"export B=two",
`C="quoted value"`,
"D='single'",
" E = spaced ",
].join("\n"),
);
expect(env).toEqual({
A: "1",
B: "two",
C: "quoted value",
D: "single",
E: "spaced",
});
});
});

describe("collectEnv", () => {
test("entries override the env file (file loaded first)", async () => {
// No env file here; just confirm entries merge in order.
const env = await collectEnv(["A=1", "B=2", "A=3"]);
expect(env).toEqual({ A: "3", B: "2" });
});

test("returns an empty object with no inputs", async () => {
expect(await collectEnv()).toEqual({});
});
});

describe("envPrefix", () => {
test("builds a shell-quoted assignment prefix", () => {
expect(envPrefix({ A: "1", B: "two words" })).toBe("A='1' B='two words' ");
});

test("escapes single quotes in values", () => {
expect(envPrefix({ A: "it's" })).toBe("A='it'\\''s' ");
});

test("is empty when there are no vars", () => {
expect(envPrefix({})).toBe("");
});
});
Loading