diff --git a/.changeset/sandbox-env-vars.md b/.changeset/sandbox-env-vars.md new file mode 100644 index 0000000..afa1e18 --- /dev/null +++ b/.changeset/sandbox-env-vars.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index f742b47..1445d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. ``` @@ -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 @@ -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/*"]) diff --git a/packages/cli/README.md b/packages/cli/README.md index 1d02b80..4bcf8bb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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. @@ -919,11 +927,19 @@ 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 --env 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` | | 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` @@ -931,8 +947,18 @@ Open a full interactive SSH session. Drops you into a bash shell at `/workplace` ```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. @@ -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. diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 5d450dd..fcef1aa 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -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; } @@ -23,22 +24,38 @@ export const sandboxCreateCommand = defineCommand({ "$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"; @@ -66,6 +83,7 @@ export const sandboxCreateCommand = defineCommand({ onDebug: (msg) => logger.debug(msg, true), name: sandboxName, region, + env: envVars, }); } catch (err) { spin.stop(); diff --git a/packages/cli/src/commands/sandbox/env-args.test.ts b/packages/cli/src/commands/sandbox/env-args.test.ts new file mode 100644 index 0000000..4aca291 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env-args.test.ts @@ -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(""); + }); +}); diff --git a/packages/cli/src/commands/sandbox/env-args.ts b/packages/cli/src/commands/sandbox/env-args.ts new file mode 100644 index 0000000..76f17a6 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env-args.ts @@ -0,0 +1,98 @@ +import type { Argv } from "yargs"; +import { UserError } from "../../core/errors.ts"; + +const ENV_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** Add the shared `--env`/`--env-file` options to a command builder. + * Pass `{ shortAlias: false }` on commands that forward arbitrary argv so that + * `-e` is not consumed by yargs before reaching the remote process. */ +export function withEnvOptions( + yargs: Argv, + { shortAlias = true }: { shortAlias?: boolean } = {}, +): Argv { + return yargs + .option("env", { + ...(shortAlias ? { alias: "e" } : {}), + type: "string", + array: true, + describe: "Set an environment variable as KEY=VALUE (repeatable)", + }) + .option("env-file", { + type: "string", + describe: "Load environment variables from a dotenv file", + }); +} + +/** Fields yargs populates for the shared env options. */ +export interface EnvOptionArgs { + env?: string[]; + envFile?: string; +} + +/** + * Merge env vars from a `--env-file` (loaded first) and `KEY=VALUE` entries + * (which override the file). Both key and value are validated. + */ +export async function collectEnv( + entries: string[] = [], + envFile?: string, +): Promise> { + const env: Record = {}; + if (envFile) { + const file = Bun.file(envFile); + if (!(await file.exists())) { + throw new UserError(`Env file not found: ${envFile}`); + } + Object.assign(env, parseDotenv(await file.text())); + } + for (const entry of entries) { + const [key, value] = splitPair(entry); + env[key] = value; + } + return env; +} + +/** Split a `KEY=VALUE` string on the first `=`, validating the key. */ +export function splitPair(entry: string): [string, string] { + const eq = entry.indexOf("="); + if (eq === -1) { + throw new UserError(`Invalid env entry "${entry}". Expected KEY=VALUE.`); + } + const key = entry.slice(0, eq); + assertValidKey(key); + return [key, entry.slice(eq + 1)]; +} + +/** Minimal dotenv parser: KEY=VALUE lines, `#` comments, optional quotes. */ +export function parseDotenv(text: string): Record { + const env: Record = {}; + for (const rawLine of text.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const body = line.startsWith("export ") ? line.slice(7).trim() : line; + const eq = body.indexOf("="); + if (eq === -1) continue; + const key = body.slice(0, eq).trim(); + assertValidKey(key); + let value = body.slice(eq + 1).trim(); + const quote = value[0]; + if ( + value.length >= 2 && + (quote === '"' || quote === "'") && + value.endsWith(quote) + ) { + value = value.slice(1, -1); + } else { + const commentIdx = value.indexOf(" #"); + if (commentIdx !== -1) value = value.slice(0, commentIdx).trim(); + } + env[key] = value; + } + return env; +} + +function assertValidKey(key: string): void { + if (!ENV_KEY_PATTERN.test(key)) { + throw new UserError(`Invalid environment variable name: "${key}"`); + } +} diff --git a/packages/cli/src/commands/sandbox/env/delete.ts b/packages/cli/src/commands/sandbox/env/delete.ts new file mode 100644 index 0000000..db5e000 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env/delete.ts @@ -0,0 +1,69 @@ +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 { sandboxFromName } from "./resolve.ts"; + +interface DeleteArgs { + name: string; + keys: string[]; +} + +export const sandboxEnvDeleteCommand = defineCommand({ + command: "delete ", + aliases: ["rm", "unset"], + describe: "Remove persisted environment variables from a sandbox.", + examples: [ + ["$0 sandbox env delete my-sandbox NODE_ENV", "Remove a variable"], + ["$0 sandbox env rm my-sandbox A B", "Remove multiple variables"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("keys", { + type: "string", + array: true, + demandOption: true, + describe: "Variable names to remove", + }), + + handler: async ({ name, keys, profile, apiKey, verbose, output }) => { + const sandbox = sandboxFromName(name, profile, apiKey, verbose); + + const spin = spinner("Updating environment..."); + spin.start(); + let removed: string[]; + try { + removed = await sandbox.unsetEnv(keys); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; + } + spin.stop(); + + const missing = keys.filter((key) => !removed.includes(key)); + + if (removed.length === 0) { + throw new UserError( + `No matching variable(s) to remove: ${missing.join(", ")}`, + ); + } + + if (output === "json") { + logger.log(JSON.stringify({ removed, missing }, null, 2)); + return; + } + logger.log(`Removed ${removed.length} variable(s): ${removed.join(", ")}`); + if (missing.length > 0) { + logger.warn(`Not set (ignored): ${missing.join(", ")}`); + } + logger.info("The sandbox is redeploying to apply the change."); + }, +}); diff --git a/packages/cli/src/commands/sandbox/env/index.ts b/packages/cli/src/commands/sandbox/env/index.ts new file mode 100644 index 0000000..263b9f6 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env/index.ts @@ -0,0 +1,10 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sandboxEnvDeleteCommand } from "./delete.ts"; +import { sandboxEnvListCommand } from "./list.ts"; +import { sandboxEnvSetCommand } from "./set.ts"; + +export const sandboxEnvNamespace = defineNamespace( + "env", + "Manage persistent environment variables for a sandbox.", + [sandboxEnvSetCommand, sandboxEnvListCommand, sandboxEnvDeleteCommand], +); diff --git a/packages/cli/src/commands/sandbox/env/list.ts b/packages/cli/src/commands/sandbox/env/list.ts new file mode 100644 index 0000000..d0370f7 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env/list.ts @@ -0,0 +1,59 @@ +import { SandboxError } from "@bunny.net/sandbox"; +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"; +import { sandboxFromName } from "./resolve.ts"; + +interface ListArgs { + name: string; +} + +export const sandboxEnvListCommand = defineCommand({ + command: "list ", + aliases: ["ls"], + describe: "List persisted environment variables for a sandbox.", + examples: [["$0 sandbox env list my-sandbox", "List all variables"]], + + builder: (yargs) => + yargs.positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }), + + handler: async ({ name, profile, apiKey, verbose, output }) => { + const sandbox = sandboxFromName(name, profile, apiKey, verbose); + + const spin = spinner("Fetching environment..."); + spin.start(); + let env: Record; + try { + env = await sandbox.getEnv(); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; + } + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(env, null, 2)); + return; + } + + const keys = Object.keys(env).sort(); + if (keys.length === 0) { + logger.info("No environment variables set."); + return; + } + logger.log( + formatTable( + ["Name", "Value"], + keys.map((key) => [key, env[key] ?? ""]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/sandbox/env/resolve.ts b/packages/cli/src/commands/sandbox/env/resolve.ts new file mode 100644 index 0000000..cbdd446 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env/resolve.ts @@ -0,0 +1,31 @@ +import { Sandbox } from "@bunny.net/sandbox"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; + +/** Rebuild a Sandbox handle for API calls from a stored sandbox record. */ +export function sandboxFromName( + name: string, + profile: string, + apiKey: string | undefined, + verbose: boolean | undefined, +): Sandbox { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + return Sandbox.fromHandle( + { + appId: record.app_id, + name, + agentToken: record.agent_token, + sshHost: record.ssh_host ?? "", + }, + { + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), + }, + ); +} diff --git a/packages/cli/src/commands/sandbox/env/set.ts b/packages/cli/src/commands/sandbox/env/set.ts new file mode 100644 index 0000000..0221895 --- /dev/null +++ b/packages/cli/src/commands/sandbox/env/set.ts @@ -0,0 +1,79 @@ +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 { collectEnv } from "../env-args.ts"; +import { sandboxFromName } from "./resolve.ts"; + +interface SetArgs { + name: string; + pairs?: string[]; + envFile?: string; +} + +export const sandboxEnvSetCommand = defineCommand({ + command: "set [pairs..]", + describe: "Persist environment variables on a sandbox.", + examples: [ + ["$0 sandbox env set my-sandbox NODE_ENV=production", "Set a variable"], + ["$0 sandbox env set my-sandbox A=1 B=2", "Set multiple variables"], + [ + "$0 sandbox env set my-sandbox --env-file .env", + "Load variables from a dotenv file", + ], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("pairs", { + type: "string", + array: true, + describe: "Variables as KEY=VALUE", + }) + .option("env-file", { + type: "string", + describe: "Load environment variables from a dotenv file", + }), + + handler: async ({ + name, + pairs, + envFile, + profile, + apiKey, + verbose, + output, + }) => { + const vars = await collectEnv(pairs, envFile); + if (Object.keys(vars).length === 0) { + throw new UserError("Provide at least one KEY=VALUE pair or --env-file."); + } + + const sandbox = sandboxFromName(name, profile, apiKey, verbose); + + const spin = spinner("Updating environment..."); + spin.start(); + try { + await sandbox.setEnv(vars); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; + } + spin.stop(); + + const keys = Object.keys(vars); + if (output === "json") { + logger.log(JSON.stringify({ updated: keys }, null, 2)); + return; + } + logger.log(`Persisted ${keys.length} variable(s): ${keys.join(", ")}`); + logger.info("The sandbox is redeploying to apply the change."); + }, +}); diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index f812390..e6b81a7 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,9 +1,10 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; +import { collectEnv, type EnvOptionArgs, withEnvOptions } from "./env-args.ts"; +import { envPrefix, sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; -interface ExecArgs { +interface ExecArgs extends EnvOptionArgs { name: string; command: string[]; cwd: string; @@ -18,29 +19,36 @@ export const sandboxExecCommand = defineCommand({ "$0 sandbox exec my-sandbox --cwd /tmp ls -la", "Run with a working directory", ], + [ + "$0 sandbox exec my-sandbox --env DEBUG=1 -- node app.js", + "Run with a temporary environment variable", + ], ], 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", - }), + withEnvOptions( + 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", + }), + { shortAlias: false }, + ), - handler: async ({ name, command, cwd }) => { + handler: async ({ name, command, cwd, env, envFile }) => { const record = getSandbox(name); if (!record) { throw new UserError( @@ -53,7 +61,8 @@ export const sandboxExecCommand = defineCommand({ ); } - const remoteCmd = `cd ${JSON.stringify(cwd)} && ${command + const prefix = envPrefix(await collectEnv(env, envFile)); + const remoteCmd = `cd ${JSON.stringify(cwd)} && ${prefix}${command .map((arg) => JSON.stringify(arg)) .join(" ")}`; diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts index e2f7947..bee4122 100644 --- a/packages/cli/src/commands/sandbox/index.ts +++ b/packages/cli/src/commands/sandbox/index.ts @@ -1,6 +1,7 @@ import { defineNamespace } from "../../core/define-namespace.ts"; import { sandboxCreateCommand } from "./create.ts"; import { sandboxDeleteCommand } from "./delete.ts"; +import { sandboxEnvNamespace } from "./env/index.ts"; import { sandboxExecCommand } from "./exec.ts"; import { sandboxListCommand } from "./list.ts"; import { sandboxSshCommand } from "./ssh.ts"; @@ -16,5 +17,6 @@ export const sandboxNamespace = defineNamespace( sandboxExecCommand, sandboxSshCommand, sandboxUrlNamespace, + sandboxEnvNamespace, ], ); diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts index 2ce6ce7..efaa43e 100644 --- a/packages/cli/src/commands/sandbox/ssh-exec.ts +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -5,6 +5,22 @@ import type { SandboxRecord } from "../../config/schema.ts"; export const WORKPLACE = "/workplace"; +/** Single-quote a value for safe use in a remote shell command. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** + * Build an inline `KEY='value' ` prefix that sets env vars for the command + * that follows. Returns "" when there are none. Keys are assumed validated. + */ +export function envPrefix(env: Record): string { + const parts = Object.entries(env).map( + ([key, value]) => `${key}=${shellQuote(value)}`, + ); + return parts.length ? `${parts.join(" ")} ` : ""; +} + export function sshArgs( record: SandboxRecord, remoteCmd: string, diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts index c0546c9..c1f8c15 100644 --- a/packages/cli/src/commands/sandbox/ssh.ts +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -1,21 +1,34 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; +import { collectEnv, type EnvOptionArgs, withEnvOptions } from "./env-args.ts"; +import { envPrefix, sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; -export const sandboxSshCommand = defineCommand({ +interface SshArgs extends EnvOptionArgs { + name: string; +} + +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"]], + examples: [ + ["$0 sandbox ssh my-sandbox", "Open a shell in my-sandbox"], + [ + "$0 sandbox ssh my-sandbox -e DEBUG=1", + "Open a shell with a temporary environment variable", + ], + ], builder: (yargs) => - yargs.positional("name", { - type: "string", - demandOption: true, - describe: "Sandbox name", - }), + withEnvOptions( + yargs.positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }), + ), - handler: async ({ name }) => { + handler: async ({ name, env, envFile }) => { const record = getSandbox(name); if (!record) { throw new UserError( @@ -28,14 +41,17 @@ export const sandboxSshCommand = defineCommand({ ); } - process.exitCode = await withSshEnv(record, async (env) => { + const prefix = envPrefix(await collectEnv(env, envFile)); + process.exitCode = await withSshEnv(record, async (procEnv) => { const proc = Bun.spawn( - sshArgs(record, `cd ${WORKPLACE} && exec bash -l`, { tty: true }), + sshArgs(record, `cd ${WORKPLACE} && ${prefix}exec /bin/bash -l`, { + tty: true, + }), { stdin: "inherit", stdout: "inherit", stderr: "inherit", - env, + env: procEnv, }, ); return proc.exited; diff --git a/packages/sandbox/src/provision.ts b/packages/sandbox/src/provision.ts index 70bbef9..9d535e8 100644 --- a/packages/sandbox/src/provision.ts +++ b/packages/sandbox/src/provision.ts @@ -68,6 +68,39 @@ export function extractAgentToken(app: App): string | null { return null; } +/** Read the first container template's environment variables into a map. */ +export function extractEnv(app: App): Record { + const env: Record = {}; + for (const { name, value } of app.containerTemplates?.[0] + ?.environmentVariables ?? []) { + if (name) env[name] = value ?? ""; + } + return env; +} + +/** + * Replace a container template's environment variables with the given map. + * The MC endpoint is a full replacement, so callers must pass the complete + * desired set (including reserved keys like AGENT_TOKEN). + */ +export async function setContainerEnv( + client: McClient, + appId: string, + containerId: string, + env: Record, +): Promise { + const { error } = await (client as any).PUT( + "/apps/{appId}/containers/{containerId}/env", + { + params: { path: { appId, containerId } }, + body: env, + }, + ); + if (error) { + throw new SandboxError(`Failed to update environment: ${str(error)}`); + } +} + export function firstContainerId(app: App): string | null { return app.containerTemplates?.[0]?.id ?? null; } diff --git a/packages/sandbox/src/sandbox.test.ts b/packages/sandbox/src/sandbox.test.ts index c3b6220..288ee1a 100644 --- a/packages/sandbox/src/sandbox.test.ts +++ b/packages/sandbox/src/sandbox.test.ts @@ -5,7 +5,26 @@ import { firstContainerId, splitHost, } from "./provision.ts"; -import { buildRemoteCommand, resolvePath, shellQuote } from "./sandbox.ts"; +import { + buildRemoteCommand, + resolvePath, + Sandbox, + shellQuote, +} from "./sandbox.ts"; + +describe("Sandbox.create", () => { + test("rejects reserved env key AGENT_TOKEN before any network call", async () => { + await expect( + Sandbox.create({ env: { AGENT_TOKEN: "user-supplied" } }), + ).rejects.toThrow('"AGENT_TOKEN" is reserved and cannot be set.'); + }); + + test("rejects invalid env key names before any network call", async () => { + await expect( + Sandbox.create({ env: { "bad key": "value" } }), + ).rejects.toThrow("Invalid environment variable name"); + }); +}); describe("buildRemoteCommand", () => { test("defaults to the workplace and quotes the command", () => { diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index a44edae..5bdf90b 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -8,9 +8,11 @@ import { deleteApp, extractAgentToken, extractAnycastHost, + extractEnv, firstContainerId, getApp, mcClient, + setContainerEnv, splitHost, WORKPLACE, waitForPublicHost, @@ -73,6 +75,13 @@ export class Sandbox { /** Provision a new sandbox and wait until it accepts connections. */ static async create(options: CreateOptions = {}): Promise { + for (const key of Object.keys(options.env ?? {})) { + assertValidEnvKey(key); + if (RESERVED_ENV_KEYS.has(key)) { + throw new SandboxError(`"${key}" is reserved and cannot be set.`); + } + } + const client = mcClient(options); const name = options.name ?? generateName(); const agentToken = generateToken(); @@ -233,6 +242,62 @@ export class Sandbox { return `https://${host}`; } + /** Return the sandbox's persisted environment variables (reserved keys hidden). */ + async getEnv(): Promise> { + const env = extractEnv(await getApp(this.client, this.appId)); + for (const key of RESERVED_ENV_KEYS) delete env[key]; + return env; + } + + /** + * Persist environment variables, merging with the existing set. Persisted + * vars are baked into the container and survive restarts, unlike the + * per-command `env` passed to runCommand. Triggers a redeploy of the + * sandbox with the new environment, restarting running processes. + */ + async setEnv(vars: Record): Promise { + for (const key of Object.keys(vars)) { + assertValidEnvKey(key); + if (RESERVED_ENV_KEYS.has(key)) { + throw new SandboxError(`"${key}" is reserved and cannot be set.`); + } + } + const app = await getApp(this.client, this.appId); + const containerId = firstContainerId(app); + if (!containerId) { + throw new SandboxError("Could not find a container to update."); + } + await setContainerEnv(this.client, this.appId, containerId, { + ...extractEnv(app), + ...vars, + }); + } + + /** + * Remove persisted environment variables. Returns the keys that were + * actually present and removed; keys that were not set are ignored. Triggers + * a redeploy of the sandbox only when something was removed. + */ + async unsetEnv(keys: string[]): Promise { + for (const key of keys) { + if (RESERVED_ENV_KEYS.has(key)) { + throw new SandboxError(`"${key}" is reserved and cannot be removed.`); + } + } + const app = await getApp(this.client, this.appId); + const env = extractEnv(app); + const removed = keys.filter((key) => Object.hasOwn(env, key)); + if (removed.length === 0) return []; + + const containerId = firstContainerId(app); + if (!containerId) { + throw new SandboxError("Could not find a container to update."); + } + for (const key of removed) delete env[key]; + await setContainerEnv(this.client, this.appId, containerId, env); + return removed; + } + /** Permanently delete the sandbox and its backing app. */ async delete(): Promise { this.transport.close(); @@ -263,13 +328,21 @@ function transportFor(sshHost: string, password: string): SshTransport { const ENV_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +/** Environment variable names the SDK manages and users may not touch. */ +const RESERVED_ENV_KEYS = new Set(["AGENT_TOKEN"]); + +/** Throw unless `key` is a valid POSIX environment variable name. */ +export function assertValidEnvKey(key: string): void { + if (!ENV_KEY_PATTERN.test(key)) { + throw new SandboxError(`Invalid environment variable name: ${key}`); + } +} + export function buildRemoteCommand(opts: RunCommandOptions): string { const parts: string[] = [`cd ${shellQuote(opts.cwd ?? WORKPLACE)} &&`]; if (opts.sudo) parts.push("sudo"); for (const [key, value] of Object.entries(opts.env ?? {})) { - if (!ENV_KEY_PATTERN.test(key)) { - throw new SandboxError(`Invalid environment variable name: ${key}`); - } + assertValidEnvKey(key); parts.push(`${key}=${shellQuote(value)}`); } parts.push(shellQuote(opts.cmd));