diff --git a/.changeset/sandbox-timeouts-file-ops.md b/.changeset/sandbox-timeouts-file-ops.md new file mode 100644 index 0000000..c8af357 --- /dev/null +++ b/.changeset/sandbox-timeouts-file-ops.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/sandbox": minor +--- + +feat(sandbox): runCommand timeout and AbortSignal cancellation, plus listFiles, deleteFile, rename, and exists file operations diff --git a/AGENTS.md b/AGENTS.md index 7da07dd..d01ccb2 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) and `signal` for cancellation. 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,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), writeFiles, readFile, listFiles, deleteFile, rename, exists, 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) +│ │ ├── 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 diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index fb69b07..837b8e4 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -55,6 +55,27 @@ 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(); +``` + +Detached commands manage their own lifetime instead: use `command.kill()`. + ## Reconnecting `Sandbox.create` returns a handle you can persist and rebuild later, with no API round trip: @@ -74,20 +95,24 @@ const sandbox = await Sandbox.get({ apiKey, appId }); ## 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.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 f4dad11..041b4f0 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 c9886d3..2722bfc 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -1,5 +1,5 @@ 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 { CreateOptions, @@ -7,6 +7,7 @@ export type { 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 2393776..8b4d0f5 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 5cf2662..116b240 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,7 @@ import { Sandbox, shellQuote, } from "./sandbox.ts"; +import { collectExec, fileEntryFromAttrs } from "./transport.ts"; describe("Sandbox.create", () => { test("rejects reserved env key AGENT_TOKEN before any network call", async () => { @@ -27,6 +29,146 @@ 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", + ); + }); + + test("rejects timeout combined with detached", async () => { + await expect( + sandbox.runCommand({ cmd: "ls", detached: true, timeout: 5000 }), + ).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, + }), + ).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"); + }); +}); + +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("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 d1592df..860342c 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -26,6 +26,7 @@ import type { GetOptions, RunCommandOptions, SandboxAuth, + SandboxFileEntry, SandboxHandle, } from "./types.ts"; @@ -170,19 +171,38 @@ 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: RunCommandOptions & { detached: true }, + ): Promise; + async runCommand(command: RunCommandOptions): Promise; async runCommand( command: string | RunCommandOptions, args: string[] = [], ): Promise { const opts: RunCommandOptions = typeof command === "string" ? { cmd: command, args } : command; + if ( + opts.timeout !== undefined && + (!Number.isFinite(opts.timeout) || opts.timeout <= 0) + ) { + throw new SandboxError( + "timeout must be a positive number of milliseconds.", + ); + } + if (opts.detached && (opts.timeout !== undefined || opts.signal)) { + throw new SandboxError( + "timeout and signal are not supported with detached; use command.kill().", + ); + } const remote = buildRemoteCommand(opts); if (opts.detached) { return new Command(await this.transport.execStream(remote)); } - const { stdout, stderr, exitCode } = await this.transport.exec(remote); + const { stdout, stderr, exitCode } = await this.transport.exec(remote, { + timeoutMs: opts.timeout, + signal: opts.signal, + }); return new CommandFinished(exitCode, stdout, stderr); } @@ -205,6 +225,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))}`, diff --git a/packages/sandbox/src/transport.ts b/packages/sandbox/src/transport.ts index 933fb88..4e4ab76 100644 --- a/packages/sandbox/src/transport.ts +++ b/packages/sandbox/src/transport.ts @@ -4,7 +4,8 @@ import { type ConnectConfig, type SFTPWrapper, } from "ssh2"; -import { SandboxError } from "./errors.ts"; +import { CommandTimeoutError, SandboxError } from "./errors.ts"; +import type { SandboxFileEntry } from "./types.ts"; export interface TransportConfig { host: string; @@ -20,6 +21,98 @@ 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; +} + +/** 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 = ""; + stream.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + stream.stderr.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const cleanup = () => { + 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 }); + if (limits.timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + kill(); + reject(new CommandTimeoutError(limits.timeoutMs ?? 0, stdout, stderr)); + }, limits.timeoutMs); + } + }); +} + +/** SFTP attrs surface used to build a SandboxFileEntry. */ +export interface FileAttrsLike { + size?: number; + mode: number; + isDirectory(): boolean; + isSymbolicLink(): boolean; + isFile(): boolean; +} + +/** Map SFTP attrs onto a SandboxFileEntry. */ +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 +151,8 @@ 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 { + return collectExec(await this.execStream(command), limits); } /** Start a command and return its live channel for streaming. */ @@ -109,6 +188,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 02bf4a0..8f9298e 100644 --- a/packages/sandbox/src/types.ts +++ b/packages/sandbox/src/types.ts @@ -59,6 +59,20 @@ export interface RunCommandOptions { sudo?: boolean; /** Return immediately with a live Command instead of blocking. */ detached?: boolean; + /** 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; +} + +/** 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 {