diff --git a/.changeset/sandbox-cp-command.md b/.changeset/sandbox-cp-command.md new file mode 100644 index 00000000..ca9ec68c --- /dev/null +++ b/.changeset/sandbox-cp-command.md @@ -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 (`:` on either side). Uploads preserve the local file mode; a trailing slash or existing directory keeps the source filename. diff --git a/.changeset/sandbox-exec-separator-fix.md b/.changeset/sandbox-exec-separator-fix.md new file mode 100644 index 00000000..897f884d --- /dev/null +++ b/.changeset/sandbox-exec-separator-fix.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sandbox): `sandbox exec` now honors the documented `-- ` separator, and a repeatable `--env` flag no longer greedily swallows the command that follows it diff --git a/.changeset/sandbox-ls-and-exec-timeout.md b/.changeset/sandbox-ls-and-exec-timeout.md new file mode 100644 index 00000000..f2f0b9fa --- /dev/null +++ b/.changeset/sandbox-ls-and-exec-timeout.md @@ -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 `:`), and `--timeout` on `bunny sandbox exec` to close the SSH connection and exit 124 after N seconds. diff --git a/.changeset/sandbox-streaming-and-disposal.md b/.changeset/sandbox-streaming-and-disposal.md new file mode 100644 index 00000000..83427b65 --- /dev/null +++ b/.changeset/sandbox-streaming-and-disposal.md @@ -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. diff --git a/.changeset/sandbox-timeouts-file-ops.md b/.changeset/sandbox-timeouts-file-ops.md new file mode 100644 index 00000000..a9c35d38 --- /dev/null +++ b/.changeset/sandbox-timeouts-file-ops.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/sandbox": patch +--- + +feat(sandbox): runCommand timeout and AbortSignal cancellation, plus listFiles, deleteFile, rename, and exists file operations diff --git a/AGENTS.md b/AGENTS.md index 7da07dda..c767fa43 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`, `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`. ``` @@ -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 @@ -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 (: 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 :) │ │ ├── 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) diff --git a/packages/cli/README.md b/packages/cli/README.md index ca81e1e0..248d953c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 `:`; 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 `:` 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. diff --git a/packages/cli/src/commands/sandbox/cp.ts b/packages/cli/src/commands/sandbox/cp.ts new file mode 100644 index 00000000..96315749 --- /dev/null +++ b/packages/cli/src/commands/sandbox/cp.ts @@ -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 { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +export const sandboxCpCommand = defineCommand({ + command: "cp ", + 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 :)", + }) + .positional("dest", { + type: "string", + demandOption: true, + describe: "Destination path (local, or :)", + }), + + 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 :.", + ); + } + 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}`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/env-args.ts b/packages/cli/src/commands/sandbox/env-args.ts index 76f17a69..f87161de 100644 --- a/packages/cli/src/commands/sandbox/env-args.ts +++ b/packages/cli/src/commands/sandbox/env-args.ts @@ -15,6 +15,8 @@ export function withEnvOptions( ...(shortAlias ? { alias: "e" } : {}), type: "string", array: true, + // One value per flag; a greedy array would swallow `-- `. + nargs: 1, describe: "Set an environment variable as KEY=VALUE (repeatable)", }) .option("env-file", { diff --git a/packages/cli/src/commands/sandbox/env/delete.ts b/packages/cli/src/commands/sandbox/env/delete.ts index db5e0008..d563f0ea 100644 --- a/packages/cli/src/commands/sandbox/env/delete.ts +++ b/packages/cli/src/commands/sandbox/env/delete.ts @@ -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; diff --git a/packages/cli/src/commands/sandbox/env/list.ts b/packages/cli/src/commands/sandbox/env/list.ts index d0370f72..357e832c 100644 --- a/packages/cli/src/commands/sandbox/env/list.ts +++ b/packages/cli/src/commands/sandbox/env/list.ts @@ -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; diff --git a/packages/cli/src/commands/sandbox/env/resolve.ts b/packages/cli/src/commands/sandbox/env/resolve.ts deleted file mode 100644 index cbdd446c..00000000 --- a/packages/cli/src/commands/sandbox/env/resolve.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 index 02218954..5f7921dc 100644 --- a/packages/cli/src/commands/sandbox/env/set.ts +++ b/packages/cli/src/commands/sandbox/env/set.ts @@ -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; diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index e6b81a7e..e6f021a9 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,17 +1,21 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; import { collectEnv, type EnvOptionArgs, withEnvOptions } from "./env-args.ts"; import { envPrefix, sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; interface ExecArgs extends EnvOptionArgs { name: string; - command: string[]; + command?: string[]; + /** Arguments after a `--` separator, populated by yargs. */ + "--"?: string[]; cwd: string; + timeout?: number; } export const sandboxExecCommand = defineCommand({ - command: "exec ", + command: "exec [command..]", describe: "Run a shell command inside a sandbox via SSH.", examples: [ ["$0 sandbox exec my-sandbox uname -a", "Run a command"], @@ -23,12 +27,19 @@ export const sandboxExecCommand = defineCommand({ "$0 sandbox exec my-sandbox --env DEBUG=1 -- node app.js", "Run with a temporary environment variable", ], + [ + "$0 sandbox exec my-sandbox --timeout 30 -- bun run build", + "Kill the command after 30 seconds", + ], ], builder: (yargs) => withEnvOptions( yargs - .parserConfiguration({ "unknown-options-as-args": true }) + .parserConfiguration({ + "unknown-options-as-args": true, + "populate--": true, + }) .positional("name", { type: "string", demandOption: true, @@ -37,18 +48,31 @@ export const sandboxExecCommand = defineCommand({ .positional("command", { type: "string", array: true, - demandOption: true, describe: "Command to execute", }) .option("cwd", { type: "string", default: WORKPLACE, describe: "Working directory inside the sandbox", + }) + .option("timeout", { + type: "number", + describe: + "Close the SSH connection and exit 124 after this many seconds", }), { shortAlias: false }, ), - handler: async ({ name, command, cwd, env, envFile }) => { + handler: async (args) => { + const { name, cwd, env, envFile, timeout } = args; + // The command may arrive as positionals, after a `--` separator, or both. + const command = [...(args.command ?? []), ...(args["--"] ?? [])]; + if (command.length === 0) { + throw new UserError("No command given. Usage: exec "); + } + if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) { + throw new UserError("--timeout must be a positive number of seconds."); + } const record = getSandbox(name); if (!record) { throw new UserError( @@ -73,7 +97,21 @@ export const sandboxExecCommand = defineCommand({ stderr: "inherit", env, }); - return proc.exited; + let timedOut = false; + const timer = + timeout === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + proc.kill(); + }, timeout * 1000); + const exitCode = await proc.exited; + clearTimeout(timer); + if (timedOut) { + logger.error(`Command timed out after ${timeout}s.`); + return 124; + } + return exitCode; }); }, }); diff --git a/packages/cli/src/commands/sandbox/files/index.ts b/packages/cli/src/commands/sandbox/files/index.ts new file mode 100644 index 00000000..0aa0fa23 --- /dev/null +++ b/packages/cli/src/commands/sandbox/files/index.ts @@ -0,0 +1,9 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sandboxFilesListCommand } from "./list.ts"; + +export const sandboxFilesNamespace = defineNamespace( + "files", + "Manage files inside a sandbox: list.", + [sandboxFilesListCommand], + ["file"], +); diff --git a/packages/cli/src/commands/sandbox/files/list.test.ts b/packages/cli/src/commands/sandbox/files/list.test.ts new file mode 100644 index 00000000..b7d48a1e --- /dev/null +++ b/packages/cli/src/commands/sandbox/files/list.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { parseLsTarget } from "./list.ts"; + +describe("parseLsTarget", () => { + test("a bare sandbox name lists the workplace", () => { + expect(parseLsTarget("my-sandbox")).toEqual({ + sandbox: "my-sandbox", + path: ".", + }); + }); + + test("a sandbox:path reference lists that path", () => { + expect(parseLsTarget("my-sandbox:/workplace/src")).toEqual({ + sandbox: "my-sandbox", + path: "/workplace/src", + }); + expect(parseLsTarget("my-sandbox:src")).toEqual({ + sandbox: "my-sandbox", + path: "src", + }); + }); +}); diff --git a/packages/cli/src/commands/sandbox/files/list.ts b/packages/cli/src/commands/sandbox/files/list.ts new file mode 100644 index 00000000..63d46cdf --- /dev/null +++ b/packages/cli/src/commands/sandbox/files/list.ts @@ -0,0 +1,78 @@ +import { SandboxError, type SandboxFileEntry } from "@bunny.net/sandbox"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatBytes, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { connectSandbox, parseRemoteRef, type RemoteRef } from "../resolve.ts"; + +interface ListArgs { + target: string; +} + +/** A bare sandbox name lists the workplace; `sandbox:path` lists that path. */ +export function parseLsTarget(target: string): RemoteRef { + return parseRemoteRef(target) ?? { sandbox: target, path: "." }; +} + +export const sandboxFilesListCommand = defineCommand({ + command: "list ", + aliases: ["ls"], + describe: "List files in a sandbox directory.", + examples: [ + ["$0 sandbox files ls my-sandbox", "List /workplace"], + ["$0 sandbox files ls my-sandbox:/workplace/src", "List a directory"], + ["$0 sandbox files ls my-sandbox:src --output json", "List as JSON"], + ], + + builder: (yargs) => + yargs.positional("target", { + type: "string", + demandOption: true, + describe: "Sandbox name, or : for a specific directory", + }), + + handler: async ({ target, profile, apiKey, verbose, output }) => { + const ref = parseLsTarget(target); + const sandbox = connectSandbox(ref.sandbox, profile, apiKey, verbose); + + const spin = spinner("Listing..."); + spin.start(); + + let entries: SandboxFileEntry[]; + try { + entries = await sandbox.listFiles(ref.path); + // listFiles maps a missing directory to []; tell them apart for a clear error. + if (entries.length === 0 && !(await sandbox.exists(ref.path))) { + throw new UserError(`No such directory in sandbox: ${ref.path}`); + } + } catch (err) { + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; + } finally { + spin.stop(); + sandbox.disconnect(); + } + + if (output === "json") { + logger.log(JSON.stringify(entries, null, 2)); + return; + } + if (entries.length === 0) { + logger.info("Empty directory."); + return; + } + logger.log( + formatTable( + ["Name", "Type", "Size", "Mode"], + entries.map((e) => [ + e.name, + e.type, + e.type === "file" ? formatBytes(e.size) : "", + e.mode.toString(8).padStart(3, "0"), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts index bee41229..88cd9d5f 100644 --- a/packages/cli/src/commands/sandbox/index.ts +++ b/packages/cli/src/commands/sandbox/index.ts @@ -1,8 +1,10 @@ import { defineNamespace } from "../../core/define-namespace.ts"; +import { sandboxCpCommand } from "./cp.ts"; import { sandboxCreateCommand } from "./create.ts"; import { sandboxDeleteCommand } from "./delete.ts"; import { sandboxEnvNamespace } from "./env/index.ts"; import { sandboxExecCommand } from "./exec.ts"; +import { sandboxFilesNamespace } from "./files/index.ts"; import { sandboxListCommand } from "./list.ts"; import { sandboxSshCommand } from "./ssh.ts"; import { sandboxUrlNamespace } from "./url/index.ts"; @@ -15,6 +17,8 @@ export const sandboxNamespace = defineNamespace( sandboxListCommand, sandboxDeleteCommand, sandboxExecCommand, + sandboxCpCommand, + sandboxFilesNamespace, sandboxSshCommand, sandboxUrlNamespace, sandboxEnvNamespace, diff --git a/packages/cli/src/commands/sandbox/resolve.test.ts b/packages/cli/src/commands/sandbox/resolve.test.ts new file mode 100644 index 00000000..22df3b8f --- /dev/null +++ b/packages/cli/src/commands/sandbox/resolve.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { parseRemoteRef } from "./resolve.ts"; + +describe("parseRemoteRef", () => { + test("parses a sandbox reference with an absolute path", () => { + expect(parseRemoteRef("my-sandbox:/workplace/app.js")).toEqual({ + sandbox: "my-sandbox", + path: "/workplace/app.js", + }); + }); + + test("parses a sandbox reference with a relative path", () => { + expect(parseRemoteRef("my-sandbox:app.js")).toEqual({ + sandbox: "my-sandbox", + path: "app.js", + }); + }); + + test("treats a bare local path as local", () => { + expect(parseRemoteRef("./app.js")).toBeNull(); + expect(parseRemoteRef("/tmp/app.js")).toBeNull(); + expect(parseRemoteRef("app.js")).toBeNull(); + }); + + test("rejects prefixes that look like directories, not sandbox names", () => { + expect(parseRemoteRef("./dir:file")).toBeNull(); + expect(parseRemoteRef("~/dir:file")).toBeNull(); + expect(parseRemoteRef("a/b:file")).toBeNull(); + }); + + test("requires a non-empty path after the colon", () => { + expect(parseRemoteRef("my-sandbox:")).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/sandbox/resolve.ts b/packages/cli/src/commands/sandbox/resolve.ts new file mode 100644 index 00000000..806330ea --- /dev/null +++ b/packages/cli/src/commands/sandbox/resolve.ts @@ -0,0 +1,79 @@ +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"; + +/** A `sandbox:path` reference to a file or directory inside a sandbox. */ +export interface RemoteRef { + sandbox: string; + path: string; +} + +/** + * Parse a `sandbox:path` reference. Returns null for local paths — an + * absolute/relative path, a `~` path, or anything whose prefix looks like a + * directory rather than a sandbox name. + */ +export function parseRemoteRef(ref: string): RemoteRef | null { + const idx = ref.indexOf(":"); + if (idx <= 0) return null; + const sandbox = ref.slice(0, idx); + const path = ref.slice(idx + 1); + if (!path) return null; + if ( + sandbox.includes("/") || + sandbox.includes(".") || + sandbox.startsWith("~") + ) { + return null; + } + return { sandbox, path }; +} + +/** 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. Run: bunny sandbox create ${name}`, + ); + } + + 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), + }, + ); +} + +/** Like sandboxFromName, but for SSH/SFTP commands that need an endpoint. */ +export function connectSandbox( + name: string, + profile: string, + apiKey: string | undefined, + verbose: boolean | undefined, +): Sandbox { + const record = getSandbox(name); + if (record && !record.ssh_host) { + throw new UserError( + `Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`, + ); + } + // A missing record gets sandboxFromName's "no sandbox named" error. + return sandboxFromName(name, profile, apiKey, verbose); +} diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index fb69b07e..986be1ee 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -36,6 +36,22 @@ await sandbox.delete(); Authentication comes from the `apiKey` option or the `BUNNYNET_API_KEY` environment variable. +## Streaming output + +For a blocking command, pass `onStdout` / `onStderr` to observe output as it arrives while still awaiting the buffered result. This composes with `timeout` and `signal`: + +```ts +const result = await sandbox.runCommand({ + cmd: "npm", + args: ["install"], + onStdout: (chunk) => process.stdout.write(chunk), + onStderr: (chunk) => process.stderr.write(chunk), +}); +console.log(result.exitCode); +``` + +These options apply to blocking commands only — the types enforce it. Detached commands stream via `command.logs()` instead. + ## Long-running commands Pass `detached: true` to start a process and stream its output: @@ -55,6 +71,48 @@ const finished = await server.wait(); console.log(finished.exitCode); ``` +## Timeouts and cancellation + +Blocking commands accept a `timeout` in milliseconds and an `AbortSignal`. On timeout the remote process is killed and the call rejects with `CommandTimeoutError`, which carries the output collected so far: + +```ts +import { CommandTimeoutError } from "@bunny.net/sandbox"; + +try { + await sandbox.runCommand({ cmd: "bun", args: ["run", "build"], timeout: 30_000 }); +} catch (err) { + if (err instanceof CommandTimeoutError) console.log(err.stdout, err.stderr); +} + +// Or cancel from an AbortSignal; the call rejects with the abort reason. +const controller = new AbortController(); +const pending = sandbox.runCommand({ cmd: "sleep", args: ["600"], signal: controller.signal }); +controller.abort(); +``` + +A signal that is already aborted rejects before the SSH channel opens, so the remote command never starts. + +Detached commands manage their own lifetime instead: use `command.kill()`. + +## Environment variables + +Variables can be baked in at creation (persisted for the sandbox's lifetime), passed per command (temporary), or persisted after creation: + +```ts +// Baked in at creation. +const sandbox = await Sandbox.create({ apiKey, env: { NODE_ENV: "production" } }); + +// For a single command only. +await sandbox.runCommand({ cmd: "node", args: ["app.js"], env: { DEBUG: "1" } }); + +// Persisted after creation (merges with the existing set). +await sandbox.setEnv({ PORT: "8080" }); +console.log(await sandbox.getEnv()); // { NODE_ENV: "production", PORT: "8080" } +await sandbox.unsetEnv(["PORT"]); // ["PORT"] +``` + +`setEnv` and `unsetEnv` redeploy the sandbox with the new environment, restarting running processes. The volume at `/workplace` persists across the restart. + ## Reconnecting `Sandbox.create` returns a handle you can persist and rebuild later, with no API round trip: @@ -72,22 +130,43 @@ Or look a sandbox up by its app ID, recovering its connection details from the A const sandbox = await Sandbox.get({ apiKey, appId }); ``` +## Automatic cleanup + +A sandbox implements `Symbol.dispose` / `Symbol.asyncDispose`, so `using` and `await using` release the SSH connection when the handle leaves scope: + +```ts +{ + await using sandbox = await Sandbox.create({ apiKey }); + await sandbox.runCommand("echo", ["hi"]); +} // connection closed here +``` + +Disposal calls `disconnect()` — it releases the connection but does **not** delete the sandbox. Call `delete()` explicitly to tear down an ephemeral sandbox. + ## API -| Method | Description | -| --------------------------------------------- | ---------------------------------------------------- | -| `Sandbox.create(options)` | Provision a sandbox and wait until SSH is reachable. | -| `Sandbox.get({ appId })` | Retrieve an existing sandbox by app ID. | -| `Sandbox.fromHandle(handle)` | Rebuild a sandbox from a serialized handle. | -| `sandbox.runCommand(cmd, args)` | Run a command, blocking for the result. | -| `sandbox.runCommand({ ..., detached: true })` | Start a command and stream `logs()`. | -| `sandbox.writeFiles(files)` | Upload files, creating parent directories. | -| `sandbox.readFile(path)` | Read a file into a Buffer, or `null` if missing. | -| `sandbox.mkDir(path)` | Create a directory. | -| `sandbox.exposePort(port, label?)` | Expose a port as a public CDN URL. | -| `sandbox.domain(port)` | Return the URL of an already exposed port. | -| `sandbox.delete()` | Delete the sandbox and its backing app. | -| `sandbox.toHandle()` | Serialize the sandbox for reconnection. | +| Method | Description | +| --------------------------------------------- | --------------------------------------------------------- | +| `Sandbox.create(options)` | Provision a sandbox and wait until SSH is reachable. | +| `Sandbox.get({ appId })` | Retrieve an existing sandbox by app ID. | +| `Sandbox.fromHandle(handle)` | Rebuild a sandbox from a serialized handle. | +| `sandbox.runCommand(cmd, args)` | Run a command, blocking for the result. | +| `sandbox.runCommand({ ..., detached: true })` | Start a command and stream `logs()`. | +| `sandbox.writeFiles(files)` | Upload files, creating parent directories. | +| `sandbox.readFile(path)` | Read a file into a Buffer, or `null` if missing. | +| `sandbox.listFiles(path?)` | List directory entries; `[]` if the directory is missing. | +| `sandbox.deleteFile(path)` | Delete a file; `false` if it did not exist. | +| `sandbox.rename(from, to)` | Rename or move; fails if the destination exists. | +| `sandbox.exists(path)` | Whether a file or directory exists. | +| `sandbox.mkDir(path)` | Create a directory. | +| `sandbox.exposePort(port, label?)` | Expose a port as a public CDN URL. | +| `sandbox.domain(port)` | Return the URL of an already exposed port. | +| `sandbox.getEnv()` | Read the persisted environment variables. | +| `sandbox.setEnv(vars)` | Persist environment variables (redeploys). | +| `sandbox.unsetEnv(keys)` | Remove persisted variables; returns the removed keys. | +| `sandbox.disconnect()` | Close the SSH connection without deleting the sandbox. | +| `sandbox.delete()` | Delete the sandbox and its backing app. | +| `sandbox.toHandle()` | Serialize the sandbox for reconnection. | ## Not yet supported diff --git a/packages/sandbox/src/errors.ts b/packages/sandbox/src/errors.ts index f4dad110..041b4f01 100644 --- a/packages/sandbox/src/errors.ts +++ b/packages/sandbox/src/errors.ts @@ -4,3 +4,15 @@ export class SandboxError extends Error { this.name = "SandboxError"; } } + +/** Thrown when a command exceeds its timeout. Carries the output collected so far. */ +export class CommandTimeoutError extends SandboxError { + constructor( + readonly timeoutMs: number, + readonly stdout: string, + readonly stderr: string, + ) { + super(`Command timed out after ${timeoutMs}ms.`); + this.name = "CommandTimeoutError"; + } +} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index c9886d3f..db65fd83 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -1,12 +1,15 @@ export { Command, CommandFinished, type LogChunk } from "./command.ts"; -export { SandboxError } from "./errors.ts"; +export { CommandTimeoutError, SandboxError } from "./errors.ts"; export { Sandbox } from "./sandbox.ts"; export type { + BlockingCommandOptions, CreateOptions, + DetachedCommandOptions, FileToWrite, GetOptions, RunCommandOptions, SandboxAuth, + SandboxFileEntry, SandboxHandle, SandboxImage, } from "./types.ts"; diff --git a/packages/sandbox/src/integration.test.ts b/packages/sandbox/src/integration.test.ts index 23937762..8b4d0f53 100644 --- a/packages/sandbox/src/integration.test.ts +++ b/packages/sandbox/src/integration.test.ts @@ -37,6 +37,27 @@ suite("sandbox integration", () => { // Missing files resolve to null. expect(await sandbox.readFile("does-not-exist.txt")).toBeNull(); + // List, rename, and delete round trip. + await sandbox.writeFiles([{ path: "dir/a.txt", content: "a" }]); + const entries = await sandbox.listFiles("dir"); + expect(entries.map((e) => e.name)).toContain("a.txt"); + expect(await sandbox.listFiles("no-such-dir")).toEqual([]); + expect(await sandbox.exists("dir/a.txt")).toBe(true); + // Renaming onto an existing file is refused rather than overwriting. + await sandbox.writeFiles([{ path: "dir/taken.txt", content: "t" }]); + await expect( + sandbox.rename("dir/a.txt", "dir/taken.txt"), + ).rejects.toThrow("already exists"); + await sandbox.rename("dir/a.txt", "dir/b.txt"); + expect(await sandbox.exists("dir/a.txt")).toBe(false); + expect(await sandbox.deleteFile("dir/b.txt")).toBe(true); + expect(await sandbox.deleteFile("dir/b.txt")).toBe(false); + + // A hanging command rejects once its timeout elapses. + await expect( + sandbox.runCommand({ cmd: "sleep", args: ["30"], timeout: 2000 }), + ).rejects.toThrow("timed out"); + // Stream output from a detached command. const cmd = await sandbox.runCommand({ cmd: "sh", diff --git a/packages/sandbox/src/sandbox.test.ts b/packages/sandbox/src/sandbox.test.ts index 5cf2662e..8afbb453 100644 --- a/packages/sandbox/src/sandbox.test.ts +++ b/packages/sandbox/src/sandbox.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { CommandTimeoutError } from "./errors.ts"; import { extractAgentToken, extractAnycastHost, @@ -12,6 +13,8 @@ import { Sandbox, shellQuote, } from "./sandbox.ts"; +import { collectExec, fileEntryFromAttrs } from "./transport.ts"; +import type { RunCommandOptions } from "./types.ts"; describe("Sandbox.create", () => { test("rejects reserved env key AGENT_TOKEN before any network call", async () => { @@ -27,6 +30,229 @@ describe("Sandbox.create", () => { }); }); +describe("Sandbox.runCommand validation", () => { + // fromHandle connects lazily, so validation errors surface with no network. + const sandbox = Sandbox.fromHandle({ + appId: "app-1", + name: "test", + agentToken: "token", + sshHost: "192.0.2.1", + }); + + test("rejects a non-positive timeout before any network call", async () => { + await expect(sandbox.runCommand({ cmd: "ls", timeout: 0 })).rejects.toThrow( + "timeout must be a positive number", + ); + }); + + // The union type forbids these statically; the casts stand in for + // un-typechecked JS callers, which the runtime guard still protects. + test("rejects timeout combined with detached", async () => { + await expect( + sandbox.runCommand({ + cmd: "ls", + detached: true, + timeout: 5000, + } as RunCommandOptions), + ).rejects.toThrow("not supported with detached"); + }); + + test("rejects signal combined with detached", async () => { + await expect( + sandbox.runCommand({ + cmd: "ls", + detached: true, + signal: new AbortController().signal, + } as RunCommandOptions), + ).rejects.toThrow("not supported with detached"); + }); + + test("rejects output callbacks combined with detached", async () => { + await expect( + sandbox.runCommand({ + cmd: "ls", + detached: true, + onStdout: () => {}, + } as RunCommandOptions), + ).rejects.toThrow("not supported with detached"); + await expect( + sandbox.runCommand({ + cmd: "ls", + detached: true, + onStderr: () => {}, + } as RunCommandOptions), + ).rejects.toThrow("not supported with detached"); + }); +}); + +function fakeStream() { + const listeners: Record void>> = {}; + const stderrListeners: Array<(data: Buffer) => void> = []; + return { + signals: [] as string[], + closed: false, + on(event: string, cb: (...args: never[]) => void) { + listeners[event] ??= []; + listeners[event].push(cb as (...args: unknown[]) => void); + return this; + }, + stderr: { + on(_event: string, cb: (data: Buffer) => void) { + stderrListeners.push(cb); + return this; + }, + }, + signal(name: string) { + this.signals.push(name); + }, + close() { + this.closed = true; + }, + emit(event: string, ...args: unknown[]) { + for (const cb of listeners[event] ?? []) cb(...args); + }, + emitStderr(data: Buffer) { + for (const cb of stderrListeners) cb(data); + }, + }; +} + +describe("collectExec", () => { + test("resolves with collected output on close", async () => { + const stream = fakeStream(); + const pending = collectExec(stream); + stream.emit("data", Buffer.from("out")); + stream.emitStderr(Buffer.from("err")); + stream.emit("close", 0); + expect(await pending).toEqual({ + stdout: "out", + stderr: "err", + exitCode: 0, + }); + }); + + test("times out, kills the remote process, and keeps partial output", async () => { + const stream = fakeStream(); + const pending = collectExec(stream, { timeoutMs: 10 }); + stream.emit("data", Buffer.from("partial")); + const err = await pending.catch((e: unknown) => e); + expect(err).toBeInstanceOf(CommandTimeoutError); + expect((err as CommandTimeoutError).stdout).toBe("partial"); + expect(stream.signals).toContain("KILL"); + expect(stream.closed).toBe(true); + }); + + test("rejects immediately when the signal is already aborted", async () => { + const stream = fakeStream(); + const err = await collectExec(stream, { + signal: AbortSignal.abort("stop"), + }).catch((e: unknown) => e); + expect(err).toBe("stop"); + expect(stream.closed).toBe(true); + }); + + test("rejects with the abort reason on mid-flight abort", async () => { + const stream = fakeStream(); + const controller = new AbortController(); + const pending = collectExec(stream, { signal: controller.signal }); + controller.abort(new Error("cancelled")); + const err = await pending.catch((e: unknown) => e); + expect((err as Error).message).toBe("cancelled"); + expect(stream.signals).toContain("KILL"); + }); + + test("streams chunks to onData as they arrive", async () => { + const stream = fakeStream(); + const chunks: Array<{ stream: string; data: string }> = []; + const pending = collectExec(stream, { onData: (c) => chunks.push(c) }); + stream.emit("data", Buffer.from("out")); + stream.emitStderr(Buffer.from("err")); + stream.emit("close", 0); + await pending; + expect(chunks).toEqual([ + { stream: "stdout", data: "out" }, + { stream: "stderr", data: "err" }, + ]); + }); + + test("stops streaming to onData once the promise has settled", async () => { + const stream = fakeStream(); + const chunks: Array<{ stream: string; data: string }> = []; + const pending = collectExec(stream, { + timeoutMs: 5, + onData: (c) => chunks.push(c), + }); + stream.emit("data", Buffer.from("before")); + await expect(pending).rejects.toThrow(CommandTimeoutError); + // Chunks still buffered in the channel arrive after rejection. + stream.emit("data", Buffer.from("after")); + stream.emitStderr(Buffer.from("late-err")); + expect(chunks).toEqual([{ stream: "stdout", data: "before" }]); + }); +}); + +describe("fileEntryFromAttrs", () => { + const attrs = (mode: number, kind: "dir" | "link" | "file" | "other") => ({ + size: 42, + mode, + isDirectory: () => kind === "dir", + isSymbolicLink: () => kind === "link", + isFile: () => kind === "file", + }); + + test("classifies entries and masks the mode to permission bits", () => { + expect(fileEntryFromAttrs("src", attrs(0o40755, "dir"))).toEqual({ + name: "src", + type: "directory", + size: 42, + mode: 0o755, + }); + expect(fileEntryFromAttrs("a.txt", attrs(0o100644, "file"))).toEqual({ + name: "a.txt", + type: "file", + size: 42, + mode: 0o644, + }); + expect(fileEntryFromAttrs("ln", attrs(0o120777, "link")).type).toBe( + "symlink", + ); + expect(fileEntryFromAttrs("sock", attrs(0o140644, "other")).type).toBe( + "other", + ); + }); +}); + +describe("disposal", () => { + const handle = { + appId: "app-1", + name: "s", + agentToken: "t", + sshHost: "1.2.3.4:8023", + }; + + test("await using disconnects without deleting the sandbox", async () => { + let disconnected = false; + { + await using sandbox = Sandbox.fromHandle(handle); + sandbox.disconnect = () => { + disconnected = true; + }; + } + expect(disconnected).toBe(true); + }); + + test("using disconnects synchronously", () => { + let disconnected = false; + { + using sandbox = Sandbox.fromHandle(handle); + sandbox.disconnect = () => { + disconnected = true; + }; + } + expect(disconnected).toBe(true); + }); +}); + describe("buildRemoteCommand", () => { test("defaults to the workplace and quotes the command", () => { expect(buildRemoteCommand({ cmd: "ls", args: ["-la"] })).toBe( diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index d1592dfd..433eb95f 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import type { createMcClient } from "@bunny.net/openapi-client"; -import { Command, CommandFinished } from "./command.ts"; +import { Command, CommandFinished, type LogChunk } from "./command.ts"; import { SandboxError } from "./errors.ts"; import { addCdnEndpoint, @@ -21,11 +21,14 @@ import { } from "./provision.ts"; import { SshTransport } from "./transport.ts"; import type { + BlockingCommandOptions, CreateOptions, + DetachedCommandOptions, FileToWrite, GetOptions, RunCommandOptions, SandboxAuth, + SandboxFileEntry, SandboxHandle, } from "./types.ts"; @@ -170,7 +173,12 @@ export class Sandbox { /** Run a command, blocking for the result unless detached is set. */ async runCommand(command: string, args?: string[]): Promise; - async runCommand(command: RunCommandOptions): Promise; + async runCommand(command: DetachedCommandOptions): Promise; + async runCommand(command: BlockingCommandOptions): Promise; + // Options not statically known to be blocking or detached get the union. + async runCommand( + command: RunCommandOptions, + ): Promise; async runCommand( command: string | RunCommandOptions, args: string[] = [], @@ -180,9 +188,42 @@ export class Sandbox { const remote = buildRemoteCommand(opts); if (opts.detached) { + // The types forbid this, but un-typechecked callers can still pass blocking-only options. + const stray = opts as unknown as Partial; + if ( + stray.timeout !== undefined || + stray.signal || + stray.onStdout || + stray.onStderr + ) { + throw new SandboxError( + "timeout, signal, onStdout, and onStderr are not supported with detached; use command.kill() and command.logs().", + ); + } return new Command(await this.transport.execStream(remote)); } - const { stdout, stderr, exitCode } = await this.transport.exec(remote); + + if ( + opts.timeout !== undefined && + (!Number.isFinite(opts.timeout) || opts.timeout <= 0) + ) { + throw new SandboxError( + "timeout must be a positive number of milliseconds.", + ); + } + const { onStdout, onStderr } = opts; + const onData = + onStdout || onStderr + ? ({ stream, data }: LogChunk) => { + if (stream === "stdout") onStdout?.(data); + else onStderr?.(data); + } + : undefined; + const { stdout, stderr, exitCode } = await this.transport.exec(remote, { + timeoutMs: opts.timeout, + signal: opts.signal, + onData, + }); return new CommandFinished(exitCode, stdout, stderr); } @@ -205,6 +246,26 @@ export class Sandbox { return this.transport.readFile(resolvePath(path)); } + /** List directory entries, sorted by name; [] when the directory does not exist. Defaults to the workplace. */ + async listFiles(path = "."): Promise { + return this.transport.readDir(resolvePath(path)); + } + + /** Delete a file. Returns false when it does not exist. */ + async deleteFile(path: string): Promise { + return this.transport.unlink(resolvePath(path)); + } + + /** Rename or move a file or directory. Fails when the destination exists. */ + async rename(from: string, to: string): Promise { + return this.transport.rename(resolvePath(from), resolvePath(to)); + } + + /** Whether a file or directory exists at the path. */ + async exists(path: string): Promise { + return (await this.transport.stat(resolvePath(path))) !== null; + } + async mkDir(path: string): Promise { const { exitCode, stderr } = await this.transport.exec( `mkdir -p ${shellQuote(resolvePath(path))}`, @@ -316,6 +377,18 @@ export class Sandbox { this.transport.close(); } + /** + * `using` / `await using` release the SSH connection but deliberately do + * NOT delete the sandbox — call `delete()` explicitly to tear one down. + */ + [Symbol.dispose](): void { + this.disconnect(); + } + + async [Symbol.asyncDispose](): Promise { + this.disconnect(); + } + /** Serialize the sandbox so another process can reconnect via fromHandle. */ toHandle(): SandboxHandle { return { diff --git a/packages/sandbox/src/transport.ts b/packages/sandbox/src/transport.ts index 933fb887..3490d4b1 100644 --- a/packages/sandbox/src/transport.ts +++ b/packages/sandbox/src/transport.ts @@ -4,7 +4,9 @@ import { type ConnectConfig, type SFTPWrapper, } from "ssh2"; -import { SandboxError } from "./errors.ts"; +import type { LogChunk } from "./command.ts"; +import { CommandTimeoutError, SandboxError } from "./errors.ts"; +import type { SandboxFileEntry } from "./types.ts"; export interface TransportConfig { host: string; @@ -20,6 +22,108 @@ export interface ExecResult { exitCode: number | null; } +export interface ExecLimits { + /** Kill the command and reject with CommandTimeoutError after this many milliseconds. */ + timeoutMs?: number; + /** Abort to kill the command and reject with the signal's reason. */ + signal?: AbortSignal; + /** Called with each output chunk as it arrives, before the buffered result resolves. */ + onData?: (chunk: LogChunk) => void; +} + +/** Minimal exec-channel surface, kept narrow so tests can fake it. */ +export interface ExecStreamLike { + on(event: string, cb: (...args: never[]) => void): unknown; + stderr: { on(event: string, cb: (data: Buffer) => void): unknown }; + signal(name: string): void; + close(): void; +} + +/** Collect an exec channel to completion, enforcing the given limits. */ +export function collectExec( + stream: ExecStreamLike, + limits: ExecLimits = {}, +): Promise { + let stdout = ""; + let stderr = ""; + // Chunks buffered in the channel can still arrive after a timeout/abort + // settles the promise; keep them out of onData once settled. + let settled = false; + stream.on("data", (d: Buffer) => { + const data = d.toString(); + stdout += data; + if (!settled) limits.onData?.({ stream: "stdout", data }); + }); + stream.stderr.on("data", (d: Buffer) => { + const data = d.toString(); + stderr += data; + if (!settled) limits.onData?.({ stream: "stderr", data }); + }); + + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const cleanup = () => { + settled = true; + clearTimeout(timer); + limits.signal?.removeEventListener("abort", onAbort); + }; + const kill = () => { + try { + stream.signal("KILL"); + } catch {} + try { + stream.close(); + } catch {} + }; + const onAbort = () => { + cleanup(); + kill(); + reject(limits.signal?.reason); + }; + stream.on("error", (err: Error) => { + cleanup(); + reject(err); + }); + stream.on("close", (code: number | null) => { + cleanup(); + resolve({ stdout, stderr, exitCode: code ?? null }); + }); + if (limits.signal?.aborted) return onAbort(); + limits.signal?.addEventListener("abort", onAbort, { once: true }); + const { timeoutMs } = limits; + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + kill(); + reject(new CommandTimeoutError(timeoutMs, stdout, stderr)); + }, timeoutMs); + } + }); +} + +/** Minimal SFTP attrs surface, kept narrow so tests can fake it. */ +export interface FileAttrsLike { + size?: number; + mode: number; + isDirectory(): boolean; + isSymbolicLink(): boolean; + isFile(): boolean; +} + +export function fileEntryFromAttrs( + name: string, + attrs: FileAttrsLike, +): SandboxFileEntry { + const type = attrs.isDirectory() + ? "directory" + : attrs.isSymbolicLink() + ? "symlink" + : attrs.isFile() + ? "file" + : "other"; + return { name, type, size: attrs.size ?? 0, mode: attrs.mode & 0o7777 }; +} + // ssh2 reports a missing SFTP file as the numeric status NO_SUCH_FILE (2), not a Node ENOENT string. const SFTP_NO_SUCH_FILE = 2; function isMissingFileError(err: unknown): boolean { @@ -58,22 +162,11 @@ export class SshTransport { } /** Run a command to completion and collect its output. */ - async exec(command: string): Promise { - const stream = await this.execStream(command); - let stdout = ""; - let stderr = ""; - stream.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - stream.stderr.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - return new Promise((resolve, reject) => { - stream.on("error", reject); - stream.on("close", (code: number | null) => { - resolve({ stdout, stderr, exitCode: code }); - }); - }); + async exec(command: string, limits?: ExecLimits): Promise { + // Don't open a channel (and start the command) for an already-cancelled call. + // An abort mid-open is caught by collectExec, which kills the fresh channel. + limits?.signal?.throwIfAborted(); + return collectExec(await this.execStream(command), limits); } /** Start a command and return its live channel for streaming. */ @@ -109,6 +202,80 @@ export class SshTransport { }); } + /** List a directory's entries, sorted by name. Resolves to [] when the directory does not exist. */ + async readDir(path: string): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err && isMissingFileError(err)) return resolve([]); + if (err) + return reject(new SandboxError(`Failed to list ${path}.`, err)); + resolve( + list + .map((e) => fileEntryFromAttrs(e.filename, e.attrs)) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + }); + }); + } + + /** Delete a file. Returns false when it does not exist. */ + async unlink(path: string): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.unlink(path, (err) => { + if (!err) return resolve(true); + if (isMissingFileError(err)) return resolve(false); + reject(new SandboxError(`Failed to delete ${path}.`, err)); + }); + }); + } + + /** Rename or move a file or directory. Fails when the destination exists. */ + async rename(from: string, to: string): Promise { + // OpenSSH's SFTP rename overwrites silently, so guard here (small TOCTOU window accepted). + // lstat, not stat: a dangling symlink at the destination must still count as taken. + if (await this.lexists(to)) { + throw new SandboxError(`Failed to rename ${from}: ${to} already exists.`); + } + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.rename(from, to, (err) => { + if (err) { + reject(new SandboxError(`Failed to rename ${from} to ${to}.`, err)); + } else resolve(); + }); + }); + } + + /** Whether anything exists at the path, without following symlinks. */ + private async lexists(path: string): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.lstat(path, (err) => { + if (!err) return resolve(true); + if (isMissingFileError(err)) return resolve(false); + reject(new SandboxError(`Failed to stat ${path}.`, err)); + }); + }); + } + + /** Stat a path into a SandboxFileEntry, or null when it does not exist. */ + async stat(path: string): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.stat(path, (err, attrs) => { + if (!err) { + return resolve( + fileEntryFromAttrs(path.split("/").pop() || path, attrs), + ); + } + if (isMissingFileError(err)) return resolve(null); + reject(new SandboxError(`Failed to stat ${path}.`, err)); + }); + }); + } + close(): void { this.conn?.end(); this.conn = null; diff --git a/packages/sandbox/src/types.ts b/packages/sandbox/src/types.ts index 02bf4a0c..8ec2f83a 100644 --- a/packages/sandbox/src/types.ts +++ b/packages/sandbox/src/types.ts @@ -49,7 +49,7 @@ export interface SandboxHandle { ports?: Record; } -export interface RunCommandOptions { +interface RunCommandOptionsBase { cmd: string; args?: string[]; /** Working directory. Defaults to the sandbox workplace. */ @@ -57,8 +57,40 @@ export interface RunCommandOptions { /** Extra environment variables for this command only. */ env?: Record; sudo?: boolean; - /** Return immediately with a live Command instead of blocking. */ - detached?: boolean; +} + +/** Options for a blocking command (the default). */ +export interface BlockingCommandOptions extends RunCommandOptionsBase { + detached?: false; + /** Kill the command and reject with CommandTimeoutError after this many milliseconds. */ + timeout?: number; + /** Abort to kill the command and reject with the signal's reason. */ + signal?: AbortSignal; + /** Called with stdout chunks as they arrive. */ + onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they arrive. */ + onStderr?: (chunk: string) => void; +} + +/** + * Options for a detached command, which returns a live Command immediately. + * A detached command manages its own lifetime — use `command.kill()` and + * `command.logs()` instead of `timeout`/`signal`/output callbacks. + */ +export interface DetachedCommandOptions extends RunCommandOptionsBase { + detached: true; +} + +export type RunCommandOptions = BlockingCommandOptions | DetachedCommandOptions; + +/** A directory entry returned by listFiles. */ +export interface SandboxFileEntry { + name: string; + type: "file" | "directory" | "symlink" | "other"; + /** Size in bytes. */ + size: number; + /** Unix permission bits. */ + mode: number; } export interface FileToWrite {