Compare commits

11 Commits
main ... dev

Author SHA1 Message Date
349b230913 feat: align SDK with updated OpenAPI spec and add missing unit tests
- Update generated types from new openapi.yaml (capsule stats, usage,
  metrics, pause/resume lifecycle, host/channel management, auth flow)
- Add Capsule pause/resume/ping/getMetrics lifecycle methods
- Add Capsule.waitForReady abort signal support
- Add PtyManager.connect and PtySession disposal
- Fix HttpClient empty-body response handling (content-length: 0)
- Add streamProcess() to CommandManager for background process streams
- Add integration tests for capsule lifecycle, git, and PTY features
- Add unit tests for AsyncQueue error paths, PtySession.close,
  Git.checkout without create, Git.add single string,
  Notebook.execCell error case, and PtyStartOptions fields
2026-05-16 19:14:55 +06:00
a69118aa2d chore: switch package manager to bun and create docs for codebase 2026-05-16 17:27:47 +06:00
b35df41f08 test public entry point exports 2026-05-16 16:23:27 +06:00
1f74d48576 feat: complete public entry point exports 2026-05-16 16:11:05 +06:00
573506b4c5 feat: add higher-level git and code interpreter APIs 2026-05-15 16:04:59 +06:00
c6322d8601 feat: add high-level capsule feature modules 2026-05-14 23:22:53 +06:00
8fb9753fde feat: add high-level Capsule lifecycle API 2026-05-14 22:51:01 +06:00
52282618bd Delete live-client.integration.test.ts 2026-05-09 18:06:02 +06:00
f1522eaa0b feat: add low-level Wrenn client resources
Implement WrennClient with typed resource mappings for auth, account,
API keys, users, teams, capsules, files, snapshots, hosts, and channels.
Add endpoint mapping tests plus live integration tess that authenticate
with WRENN_TEST_EMAIL/WRENN_TEST_PASS and use WRENN_API_KEY for API-key
scoped endpoints
2026-05-09 18:05:53 +06:00
5b3f2741a3 feat: add SDK foundation layer
Implement config resolution, typed errors, HTTP and WebSocket transport
helpers, and timeout handling for the SDK foundation. Add unit and local
integration tests covering the SDK foundation behaviour and align
package exports with the tsup output.
2026-05-09 16:32:41 +06:00
db7fccbaed feat: initial project structure and generate API types
Initialized `package.json`, add tsup build config (CJS/ESM/DTS), wire up
full Makefile targets (lint/test/check/build), add missing BadRequest
response component to OpenAPI spec, generate TypeScript types from spec,
configure biome to exclude generated files, and add `@types/ws`
2026-05-09 14:51:48 +06:00
38 changed files with 14848 additions and 1 deletions

4
.gitignore vendored
View File

@ -136,3 +136,7 @@ dist
.yarn/install-state.gz
.pnp.*
# AI agents
.opencode*
# Added by code-review-graph
.code-review-graph/

106
AGENTS.md Normal file
View File

@ -0,0 +1,106 @@
# AGENTS.md
## Project
Wrenn JavaScript SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
Package name: `@wrenn/sdk`. Node.js 18+, TypeScript 5.5+, managed with [pnpm](https://pnpm.io/).
## Commands
```bash
pnpm install # install deps
make lint # biome check + format check (no auto-fix)
make test # unit tests only (vitest)
make test-integration # all tests including integration (needs live server)
make generate # regenerate types from OpenAPI spec (openapi-typescript)
make check # lint + unit test
make build # tsup build (CJS + ESM + DTS)
```
- `make test` runs all non-integration tests. To run a specific test file: `pnpm vitest run tests/commands.test.ts`
- No separate typecheck step — `vitest` and `tsup` handle type checking during test/build. `tsc --noEmit` is available but not wired up in CI.
## Architecture
- `src/` — the library package
- `capsule.ts` — high-level `Capsule` class (main user-facing class)
- `client.ts` — low-level `WrennClient` with `CapsulesResource` and `SnapshotsResource`
- `commands.ts` — command execution, streaming, and background process management
- `files.ts` — filesystem operations
- `pty.ts` — interactive terminal (PTY) over WebSocket
- `exceptions.ts` — typed error hierarchy (`WrennError` base)
- `models/generated.ts`**auto-generated** from OpenAPI spec via `openapi-typescript` (never edit directly; run `make generate`)
- `git/` — git operations inside capsules (clone, push, pull, status, branches, etc.)
- `code-interpreter/` — specialized capsule for stateful Jupyter kernel execution
- `_shared/http.ts` — thin `fetch` wrapper with auth headers, base URL, and error mapping
- `_shared/websocket.ts` — WebSocket helper wrapping `ws`
- `config.ts` — constants (`DEFAULT_BASE_URL`, env var names)
- `tests/` — unit tests use `msw` to mock HTTP; integration tests are in `tests/integration/`
- `api/openapi.yaml` — OpenAPI spec used for type generation
## Key Conventions
- Generated types live in `src/models/generated.ts`. Never edit them. Run `make generate` to update.
- No sync/async split — JS is naturally async. One `Capsule` class, all methods return `Promise`.
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule`.
- Uses native `fetch` for HTTP (Node 18+), `ws` for WebSockets, `zod` for runtime validation.
- Resource disposal via `Symbol.asyncDispose` (`await using`). Also supports manual `.close()`.
- Streaming methods return `AsyncGenerator` (e.g. `commands.stream()`, `files.downloadStream()`).
- Static + instance method pattern: `capsule.destroy()` (instance) and `Capsule.destroy(id, opts)` (static).
## Testing
- Unit tests mock HTTP via `msw` (Mock Service Worker for Node).
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
- Integration test fixtures in `tests/integration/setup.ts` create real capsules and clean them up.
- Tests use `vitest` — no `@jest` globals. Use `import { describe, it, expect } from 'vitest'`.
## CI
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
1. `make lint`
2. `make test`
3. `make test-integration`
## Dependencies
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.

29
Makefile Normal file
View File

@ -0,0 +1,29 @@
# Makefile
.PHONY: generate lint test test-integration check build
SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/feat/migrate-to-ch/internal/api/openapi.yaml"
SPEC_PATH = "api/openapi.yaml"
generate:
@echo "Fetching latest OpenAPI spec from Git repo..."
mkdir -p api
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
@echo "Generating TypeScript types..."
mkdir -p src/models
bun run generate
lint:
bunx biome check .
test:
bunx vitest run --exclude tests/integration
test-integration:
bun run test:integration
check:
$(MAKE) lint
$(MAKE) test
build:
bun run build

553
README.md
View File

@ -1,2 +1,553 @@
# js-sdk
# Wrenn JavaScript SDK
JavaScript and TypeScript client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute Python code -- all from Node.js.
Designed as an e2b-style SDK. If you're migrating, `Sandbox` is available as a deprecated alias for `Capsule`.
## Installation
```bash
npm install @wrenn/sdk
```
Requires Node.js 18+.
## Authentication
Set the `WRENN_API_KEY` environment variable:
```bash
export WRENN_API_KEY="wrn_your_api_key_here"
```
Optionally override the API base URL:
```bash
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
```
You can also pass credentials directly:
```ts
import { Capsule } from "@wrenn/sdk";
const capsule = await Capsule.create("minimal", {
apiKey: "wrn_...",
baseUrl: "https://app.wrenn.dev/api",
});
```
---
## Wrenn Capsules
### Quick Start
```ts
import { Capsule } from "@wrenn/sdk";
const capsule = await Capsule.create("minimal");
try {
await capsule.waitForReady();
const result = await capsule.commands.exec("echo", { args: ["hello"] });
console.log(result.stdout); // "hello\n"
} finally {
await capsule.destroy();
}
```
### Creating Capsules
```ts
import { Capsule } from "@wrenn/sdk";
// Create with defaults: template="minimal", vcpus=1, memory_mb=512.
const capsule = await Capsule.create();
// Create with an explicit template.
const python = await Capsule.create("base-python");
// Create with resource and client options.
const larger = await Capsule.create("minimal", {
vcpus: 2,
memory_mb: 1024,
timeout_sec: 300,
apiKey: "wrn_...",
});
// Equivalent options-object form.
const fromOptions = await Capsule.create({
template: "minimal",
vcpus: 2,
memory_mb: 1024,
});
```
### Resource Cleanup
Capsules created with `Capsule.create()` are owned by that SDK instance. When used with `await using`, the remote capsule is destroyed automatically when the block exits:
```ts
await using capsule = await Capsule.create("minimal");
await capsule.waitForReady();
await capsule.commands.exec("echo", { args: ["work"] });
// capsule is automatically destroyed here
```
Capsules attached with `new Capsule(id)` or `Capsule.connect(id)` are not owned. `await using` a connected capsule only runs local cleanup and does not destroy the remote capsule.
Use `destroy()` when you want to delete the remote capsule:
```ts
const capsule = await Capsule.create("minimal");
try {
await capsule.waitForReady();
await capsule.commands.exec("echo", { args: ["work"] });
} finally {
await capsule.destroy();
}
```
### Connecting to Existing Capsules
Attach to an existing capsule by ID. This wraps the ID locally and does not fetch or validate it until you call an API method:
```ts
const capsule = Capsule.connect("cl-abc123");
const info = await capsule.getInfo();
if (info.status === "paused") {
await capsule.resume({ wait: true });
}
const result = await capsule.commands.exec("echo", { args: ["still running"] });
console.log(result.stdout);
```
For code interpreter capsules:
```ts
import { CodeInterpreter } from "@wrenn/sdk";
const interpreter = CodeInterpreter.connect("cl-abc123");
const result = await interpreter.notebook.execCell("print('reconnected')");
console.log(result.stdout);
```
### Lifecycle Management
```ts
// Instance methods.
await capsule.pause(); // returns status like "pausing"
await capsule.resume(); // returns status like "resuming"
await capsule.resume({ wait: true });
await capsule.destroy();
await capsule.ping(); // reset inactivity timer
await capsule.waitForReady();
const info = await capsule.getInfo();
console.log(info.status); // "running"
const metrics = await capsule.getMetrics({ range: "10m" });
// Static helper.
await Capsule.destroy("cl-abc123", { apiKey: "wrn_..." });
```
### Command Execution
Commands are accessed via `capsule.commands`:
```ts
// Foreground command.
const result = await capsule.commands.exec("python3", {
args: ["-c", "print(42)"],
timeoutSec: 30,
cwd: "/app",
});
console.log(result.stdout); // "42\n"
console.log(result.stderr);
console.log(result.exit_code); // 0
// Background process.
const process = await capsule.commands.start("python3", {
args: ["server.py"],
tag: "web-server",
envs: { PORT: "8000" },
cwd: "/app",
});
console.log(process.pid);
console.log(process.tag);
```
#### Streaming Output
```ts
// Stream a new command.
for await (const event of capsule.commands.stream("python3", {
args: ["-u", "train.py"],
})) {
if (event.type === "stdout") {
process.stdout.write(String(event.data ?? ""));
}
if (event.type === "stderr") {
process.stderr.write(String(event.data ?? ""));
}
if (event.type === "exit") {
console.log("exited", event.exit_code);
}
}
// Connect to a running background process stream.
await using connection = await capsule.commands.streamProcess("web-server");
connection.send({ type: "ping" });
// connection is automatically closed here
```
#### Process Management
```ts
const processes = await capsule.commands.list();
for (const proc of processes.processes ?? []) {
console.log(proc.pid, proc.tag);
}
await capsule.commands.kill("web-server", "SIGTERM");
```
### Filesystem
Files are accessed via `capsule.files`:
```ts
// Write and read files.
await capsule.files.write("/app/main.py", "print('hello')");
const content = await capsule.files.read("/app/main.py");
console.log(content.toString());
// List directory.
const listing = await capsule.files.list("/app", { depth: 1 });
for (const entry of listing.entries ?? []) {
console.log(entry.name, entry.type, entry.size);
}
// Create directory.
await capsule.files.mkdir("/app/data");
// Remove file or directory.
await capsule.files.remove("/app/old_data");
```
#### Streaming Large Files
```ts
await capsule.files.uploadStream(
"/data/large.txt",
Buffer.from("large file content"),
);
const chunks: Buffer[] = [];
for await (const chunk of capsule.files.downloadStream("/data/large.txt")) {
chunks.push(chunk);
}
console.log(Buffer.concat(chunks).toString());
```
### Git
Git operations are accessed via `capsule.git`. All commands execute the real `git` binary inside the capsule:
```ts
// Clone a repository.
await capsule.git.clone("https://github.com/org/repo.git", {
path: "/app/repo",
branch: "main",
});
// Use repository-scoped commands.
const status = await capsule.git.status({ cwd: "/app/repo" });
console.log(status.stdout);
const log = await capsule.git.log({ cwd: "/app/repo", maxCount: 5 });
console.log(log.stdout);
// Branches.
await capsule.git.checkout("feature", { cwd: "/app/repo", create: true });
console.log((await capsule.git.branch({ cwd: "/app/repo" })).stdout);
// Stage and commit.
await capsule.git.add(["README.md", "src/index.ts"], { cwd: "/app/repo" });
await capsule.git.commit("initial commit", { cwd: "/app/repo" });
// Push and pull.
await capsule.git.pull({ cwd: "/app/repo", remote: "origin", branch: "main" });
await capsule.git.push({ cwd: "/app/repo", remote: "origin", branch: "main" });
```
Git helpers return command results. Check `exit_code`, `stdout`, and `stderr` for command-level failures.
### Interactive Terminal (PTY)
```ts
await using term = await capsule.pty.start({
cmd: "/bin/bash",
cols: 120,
rows: 40,
cwd: "/home/user",
});
term.input("ls -la\n");
for await (const event of term.events) {
if (event.type === "data") {
process.stdout.write(String(event.data ?? ""));
}
if (event.type === "exit") {
break;
}
}
// terminal WebSocket is automatically closed here
```
Reconnect to a tagged session:
```ts
await using term = await capsule.pty.connect("my-session-tag");
term.input("echo reconnected\n");
// terminal WebSocket is automatically closed here
```
**PtySession methods:**
| Method | Description |
|--------|-------------|
| `input(data)` | Send text or bytes to stdin |
| `resize(cols, rows)` | Resize the terminal |
| `kill()` | Send a kill control message |
| `close()` | Close the WebSocket connection |
| `events` | Async iterator of PTY events |
---
## Code Interpreter
`CodeInterpreter` creates or connects to a capsule intended for Python code execution. It uses the capsule command API to execute cells with `python3 -c`.
### Quick Start
```ts
import { CodeInterpreter } from "@wrenn/sdk";
await using interpreter = await CodeInterpreter.create();
await interpreter.capsule.waitForReady();
const result = await interpreter.notebook.execCell("print('hello')");
console.log(result.stdout); // "hello\n"
// interpreter.capsule is automatically destroyed here
```
Interpreters created with `CodeInterpreter.create()` own their capsule and destroy it on `await using` disposal. Interpreters attached with `CodeInterpreter.connect(id)` follow connected capsule semantics and leave the remote capsule running.
### Custom Templates
By default, `CodeInterpreter.create()` uses the `jupyter` template. You can specify a custom template:
```ts
const interpreter = await CodeInterpreter.create({
template: "my-custom-python-template",
timeout_sec: 300,
});
const result = await interpreter.notebook.execCell("print('custom template')", {
timeoutSec: 60,
});
```
### Code Interpreter + Commands/Files
The code interpreter wrapper exposes the underlying standard capsule:
```ts
const interpreter = await CodeInterpreter.create();
const { capsule } = interpreter;
await interpreter.notebook.execCell("open('/tmp/data.csv', 'w').write('a,b\\n1,2')");
const content = await capsule.files.read("/tmp/data.csv");
console.log(content.toString());
const result = await capsule.commands.exec("wc", { args: ["-l", "/tmp/data.csv"] });
console.log(result.stdout);
```
---
## Error Handling
The SDK maps unsuccessful API responses to typed errors:
```ts
import {
AuthenticationError,
BadRequestError,
ConflictError,
ForbiddenError,
HostHasCapsulesError,
NotFoundError,
PayloadTooLargeError,
ServerError,
TimeoutError,
WrennError,
} from "@wrenn/sdk";
try {
await Capsule.connect("missing").getInfo();
} catch (error) {
if (error instanceof NotFoundError) {
console.log(error.code);
console.log(error.message);
console.log(error.statusCode); // 404
}
if (error instanceof WrennError) {
console.log(error.body);
}
}
```
All SDK errors inherit from `WrennError` and expose `statusCode`, `code`, `message`, and `body`.
---
## Migrating from e2b
Replace your imports and prefer `Capsule` for new code:
```ts
// Before
import { Sandbox } from "e2b";
const sandbox = await Sandbox.create();
// After
import { Capsule } from "@wrenn/sdk";
const capsule = await Capsule.create();
```
The `Sandbox` name is available as a deprecated alias:
```ts
import { Sandbox } from "@wrenn/sdk";
const sandbox = await Sandbox.create("minimal");
```
---
## Low-Level Client
For direct API access, use `WrennClient`:
```ts
import { WrennClient } from "@wrenn/sdk";
const client = new WrennClient({ apiKey: "wrn_..." });
const capsule = await client.capsules.create({
template: "minimal",
vcpus: 1,
memory_mb: 512,
timeout_sec: 300,
});
await client.capsules.pause(capsule.id);
await client.capsules.resume(capsule.id);
await client.capsules.ping(capsule.id);
await client.capsules.destroy(capsule.id);
const templates = await client.snapshots.list();
console.log(templates);
```
Available resource groups:
| Resource | Property |
|----------|----------|
| Auth | `client.auth` |
| Account | `client.account` |
| API keys | `client.apiKeys` |
| Users | `client.users` |
| Teams | `client.teams` |
| Capsules | `client.capsules` |
| Files | `client.files` |
| Snapshots | `client.snapshots` |
| Hosts | `client.hosts` |
| Channels | `client.channels` |
Generated OpenAPI types are exported from the package root:
```ts
import type { components, operations, paths } from "@wrenn/sdk";
type CapsuleSchema = components["schemas"]["Capsule"];
type GetCapsuleOperation = operations["getCapsule"];
type CapsulePath = paths["/v1/capsules/{id}"];
```
---
## Development
This project uses [Bun](https://bun.sh) for dependency management and script execution. The package build still uses `tsup`, and tests use Vitest.
```bash
# Install dependencies
bun install
# Run linting
make lint
# Run unit tests
make test
# Build CJS, ESM, and declaration files
make build
# Run lint + unit tests
make check
```
### Running Integration Tests
Integration tests require a live Wrenn server. Set credentials via environment variables:
```bash
export WRENN_API_KEY="wrn_..."
export WRENN_BASE_URL="https://app.wrenn.dev/api" # optional
```
Then run:
```bash
make test-integration
```
Tests are automatically skipped when `WRENN_API_KEY` is not available.
## License
MIT

3267
api/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

35
biome.json Normal file
View File

@ -0,0 +1,35 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!src/models/generated.ts", "!dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

538
bun.lock Normal file
View File

@ -0,0 +1,538 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "js-sdk",
"dependencies": {
"ws": "^8.20.0",
"zod": "^4.4.3",
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.0",
"@types/ws": "^8.18.1",
"msw": "^2.14.3",
"openapi-typescript": "^7.13.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.5",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@biomejs/biome": ["@biomejs/biome@2.4.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.14", "@biomejs/cli-darwin-x64": "2.4.14", "@biomejs/cli-linux-arm64": "2.4.14", "@biomejs/cli-linux-arm64-musl": "2.4.14", "@biomejs/cli-linux-x64": "2.4.14", "@biomejs/cli-linux-x64-musl": "2.4.14", "@biomejs/cli-win32-arm64": "2.4.14", "@biomejs/cli-win32-x64": "2.4.14" }, "bin": { "biome": "bin/biome" } }, "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.14", "", { "os": "win32", "cpu": "x64" }, "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "2.8.1" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="],
"@inquirer/confirm": ["@inquirer/confirm@6.0.12", "", { "dependencies": { "@inquirer/core": "11.1.9", "@inquirer/type": "4.0.5" }, "optionalDependencies": { "@types/node": "25.6.0" } }, "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og=="],
"@inquirer/core": ["@inquirer/core@11.1.9", "", { "dependencies": { "@inquirer/ansi": "2.0.5", "@inquirer/figures": "2.0.5", "@inquirer/type": "4.0.5", "cli-width": "4.1.0", "fast-wrap-ansi": "0.2.0", "mute-stream": "3.0.0", "signal-exit": "4.1.0" }, "optionalDependencies": { "@types/node": "25.6.0" } }, "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg=="],
"@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="],
"@inquirer/type": ["@inquirer/type@4.0.5", "", { "optionalDependencies": { "@types/node": "25.6.0" } }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.8", "", { "dependencies": { "@open-draft/deferred-promise": "2.2.0", "@open-draft/logger": "0.3.0", "@open-draft/until": "2.1.0", "is-node-process": "1.2.0", "outvariant": "1.4.3", "strict-event-emitter": "0.5.1" } }, "sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "0.10.2" }, "peerDependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="],
"@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "1.2.0", "outvariant": "1.4.3" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "3.1.3", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2", "uri-js-replace": "1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
"@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.14", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.1.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "4.0.2", "assertion-error": "2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "7.19.2" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "25.6.0" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "25.6.0" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "@types/chai": "5.2.3", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "6.2.2", "tinyrainbow": "3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "3.0.3", "magic-string": "0.30.21" }, "optionalDependencies": { "msw": "2.14.3", "vite": "8.0.10" } }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
"@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
"@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
"@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "0.2.5" }, "peerDependencies": { "esbuild": "0.27.7" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" }, "optionalDependencies": { "supports-color": "10.2.2" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "1.0.8" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "3.0.3" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="],
"fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "0.30.21", "mlly": "1.8.2", "rollup": "4.60.3" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="],
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "2.4.10", "set-cookie-parser": "3.1.0" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.0" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "8.16.0", "pathe": "2.0.3", "pkg-types": "1.3.1", "ufo": "1.6.4" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.14.3", "", { "dependencies": { "@inquirer/confirm": "6.0.12", "@mswjs/interceptors": "0.41.8", "@open-draft/deferred-promise": "3.0.0", "@types/statuses": "2.0.6", "cookie": "1.1.1", "graphql": "16.13.2", "headers-polyfill": "5.0.1", "is-node-process": "1.2.0", "outvariant": "1.4.3", "path-to-regexp": "6.3.0", "picocolors": "1.1.1", "rettime": "0.11.11", "statuses": "2.0.2", "strict-event-emitter": "0.5.1", "tough-cookie": "6.0.1", "type-fest": "5.6.0", "until-async": "3.0.2", "yargs": "17.7.2" }, "optionalDependencies": { "typescript": "6.0.3" }, "bin": { "msw": "cli/index.js" } }, "sha512-kk8G5cocVlJ4wsKMGZegn2H6XLOEKjbA+nSJE2354e/SRp4mDicCHUYnMXpymzVcVDCs+GUAsmNqSn+yHv4T2A=="],
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "1.3.0", "object-assign": "4.1.1", "thenify-all": "1.6.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "1.34.14", "ansi-colors": "4.1.3", "change-case": "5.4.4", "parse-json": "8.3.0", "supports-color": "10.2.2", "yargs-parser": "21.1.1" }, "peerDependencies": { "typescript": "6.0.3" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "7.29.0", "index-to-position": "1.2.0", "type-fest": "4.41.0" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "0.1.8", "mlly": "1.8.2", "pathe": "2.0.3" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "3.3.12", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "3.1.3" }, "optionalDependencies": { "postcss": "8.5.14" } }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="],
"rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "0.3.13", "commander": "4.1.1", "lines-and-columns": "1.2.4", "mz": "2.7.0", "pirates": "4.0.7", "tinyglobby": "0.2.16", "ts-interface-checker": "0.1.13" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "1.3.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": "3.3.1" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "7.0.30" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "5.1.0", "cac": "6.7.14", "chokidar": "4.0.3", "consola": "3.4.2", "debug": "4.4.3", "esbuild": "0.27.7", "fix-dts-default-cjs-exports": "1.0.1", "joycon": "3.1.1", "picocolors": "1.1.1", "postcss-load-config": "6.0.1", "resolve-from": "5.0.0", "rollup": "4.60.3", "source-map": "0.7.6", "sucrase": "3.35.1", "tinyexec": "0.3.2", "tinyglobby": "0.2.16", "tree-kill": "1.2.2" }, "optionalDependencies": { "postcss": "8.5.14", "typescript": "6.0.3" }, "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
"type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "1.32.0", "picomatch": "4.0.4", "postcss": "8.5.14", "rolldown": "1.0.0-rc.17", "tinyglobby": "0.2.16" }, "optionalDependencies": { "@types/node": "25.6.0", "esbuild": "0.27.7", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "2.1.0", "expect-type": "1.3.0", "magic-string": "0.30.21", "obug": "2.1.1", "pathe": "2.0.3", "picomatch": "4.0.4", "std-env": "4.1.0", "tinybench": "2.9.0", "tinyexec": "1.1.2", "tinyglobby": "0.2.16", "tinyrainbow": "3.1.0", "why-is-node-running": "2.3.0" }, "optionalDependencies": { "@types/node": "25.6.0" }, "peerDependencies": { "vite": "8.0.10" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.20.0", "", {}, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
"parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"vitest/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
}
}

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "js-sdk",
"version": "0.1.0",
"description": "Wrenn JavaScript SDK — a client library for the Wrenn microVM platform.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"check": "make lint && make test",
"test": "vitest run",
"lint": "make lint",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"generate": "openapi-typescript api/openapi.yaml --output src/models/generated.ts",
"format": "biome format --write ."
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "bun@1.3.14",
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.0",
"@types/ws": "^8.18.1",
"msw": "^2.14.3",
"openapi-typescript": "^7.13.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"dependencies": {
"ws": "^8.20.0",
"zod": "^4.4.3"
}
}

View File

@ -0,0 +1,98 @@
/**
* Small async iterator queue used to bridge callback-based streams to generators.
*
* Producers call {@link push}, {@link end}, or {@link fail}; consumers iterate the
* queue with `for await` or call {@link next} directly.
*
* @template T - Event type yielded by the queue.
*/
export class AsyncQueue<T> implements AsyncIterableIterator<T> {
private readonly values: T[] = [];
private readonly waiters: Array<{
reject: (reason?: unknown) => void;
resolve: (value: IteratorResult<T>) => void;
}> = [];
private closed = false;
private error: unknown;
/** Returns this queue as its own async iterator. */
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this;
}
/**
* Reads the next queued value, waits for one, or completes when the queue ends.
*
* @returns Next async iterator result.
*/
next(): Promise<IteratorResult<T>> {
if (this.values.length) {
return Promise.resolve({ done: false, value: this.values.shift() as T });
}
if (this.error) return Promise.reject(this.error);
if (this.closed) return Promise.resolve({ done: true, value: undefined });
return new Promise((resolve, reject) => {
this.waiters.push({ reject, resolve });
});
}
/**
* Ends the queue when a consumer stops iteration early.
*
* @returns Completed async iterator result.
*/
return(): Promise<IteratorResult<T>> {
this.end();
return Promise.resolve({ done: true, value: undefined });
}
/**
* Fails the queue when a consumer throws into the iterator.
*
* @param error - Error to propagate to waiting consumers.
* @returns Rejected promise containing the provided error.
*/
throw(error?: unknown): Promise<IteratorResult<T>> {
this.fail(error);
return Promise.reject(error);
}
/**
* Pushes a value to the next waiting consumer or buffers it for later reads.
*
* @param value - Value to yield from the iterator.
*/
push(value: T): void {
if (this.closed) return;
const waiter = this.waiters.shift();
if (waiter) {
waiter.resolve({ done: false, value });
return;
}
this.values.push(value);
}
/** Completes the queue and resolves all waiting consumers as done. */
end(): void {
if (this.closed) return;
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter.resolve({ done: true, value: undefined });
}
}
/**
* Fails the queue and rejects all waiting consumers.
*
* @param error - Error to propagate through the iterator.
*/
fail(error: unknown): void {
if (this.closed) return;
this.closed = true;
this.error = error;
for (const waiter of this.waiters.splice(0)) {
waiter.reject(error);
}
}
}

318
src/_shared/http.ts Normal file
View File

@ -0,0 +1,318 @@
import { TimeoutError, throwErrorFromResponse } from "../exceptions.js";
/** Configuration for the low-level HTTP client. */
export interface HttpClientConfig {
/** API origin used for all relative request paths. */
baseUrl: string;
/** API key sent as `X-API-Key`. */
apiKey?: string;
/** Bearer JWT sent as `Authorization: Bearer ...`. */
token?: string;
/** Host token sent as `X-Host-Token`. */
hostToken?: string;
}
/** Per-request options accepted by the low-level HTTP client. */
export interface RequestOptions {
/** Query parameters appended to the request URL. */
params?: Record<string, string | number | boolean | undefined>;
/** Additional headers merged after default authentication headers. */
headers?: Record<string, string>;
/** Request-scoped API key override. */
apiKey?: string;
/** Request-scoped bearer JWT override. */
token?: string;
/** Request-scoped host token override. */
hostToken?: string;
/** Timeout in milliseconds for requests that do not provide a signal. */
timeoutMs?: number;
/** Caller-provided cancellation signal. Takes precedence over `timeoutMs`. */
signal?: AbortSignal;
/** Return response text instead of parsing JSON. */
asText?: boolean;
/** Fetch redirect behavior. Defaults to the runtime fetch default. */
redirect?: RequestRedirect;
}
/** Thin `fetch` wrapper with Wrenn authentication and error mapping. */
export class HttpClient {
private readonly baseUrl: string;
private readonly defaultHeaders: Record<string, string>;
/**
* Creates a low-level HTTP client.
*
* @param config - Base URL and optional authentication credentials.
*/
constructor(config: HttpClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.defaultHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (config.apiKey) {
this.defaultHeaders["X-API-Key"] = config.apiKey;
}
if (config.token) {
this.defaultHeaders.Authorization = `Bearer ${config.token}`;
}
if (config.hostToken) {
this.defaultHeaders["X-Host-Token"] = config.hostToken;
}
}
/**
* Sends a GET request and parses the JSON response.
*
* @param path - API path relative to the configured base URL.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
get<T>(path: string, opts?: RequestOptions): Promise<T> {
return this.request<T>("GET", path, undefined, opts);
}
/**
* Sends a POST request with an optional JSON body.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
return this.request<T>("POST", path, body, opts);
}
/**
* Sends a PATCH request with an optional JSON body.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
return this.request<T>("PATCH", path, body, opts);
}
/**
* Sends a PUT request with an optional JSON body.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed JSON response.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
put<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
return this.request<T>("PUT", path, body, opts);
}
/**
* Sends a DELETE request and expects no response body.
*
* @param path - API path relative to the configured base URL.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Resolves when the response succeeds.
* @throws TimeoutError when the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
delete(path: string, opts?: RequestOptions): Promise<void> {
return this.request<void>("DELETE", path, undefined, opts);
}
/**
* Uploads multipart form data.
*
* @param path - API path relative to the configured base URL.
* @param formData - Multipart payload to send.
* @param opts - Optional query, header, auth, and cancellation settings.
* @throws WrennError subclasses for unsuccessful responses.
*/
async upload(
path: string,
formData: FormData,
opts?: RequestOptions,
): Promise<void> {
const url = this.buildUrl(path, opts?.params);
const headers: Record<string, string> = { ...this.defaultHeaders };
delete headers["Content-Type"];
if (opts?.apiKey) headers["X-API-Key"] = opts.apiKey;
if (opts?.token) headers.Authorization = `Bearer ${opts.token}`;
if (opts?.hostToken) headers["X-Host-Token"] = opts.hostToken;
const init: RequestInit = {
method: "POST",
headers: { ...headers, ...opts?.headers },
body: formData,
};
const res = await this.fetchWithSignal(url, init, opts);
if (!res.ok) {
await throwErrorFromResponse(res);
}
}
/**
* Downloads a binary response body as a web `ReadableStream`.
*
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Response body stream.
* @throws WrennError subclasses for unsuccessful responses.
*/
async download(
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<ReadableStream<Uint8Array>> {
const res = await this.rawRequest("POST", path, body, opts);
if (!res.body) {
throw new Error("Response body is null");
}
return res.body;
}
/**
* Sends a request and returns the raw `Response` object.
*
* This is intended for endpoints that intentionally return non-JSON responses,
* such as OAuth redirects. Unlike {@link request}, this method does not map
* non-OK statuses to SDK errors.
*
* @param method - HTTP method to use.
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and redirect settings.
* @returns Raw fetch response.
* @throws TimeoutError When the configured timeout aborts the request.
*/
response(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<Response> {
return this.rawRequest(method, path, body, opts);
}
/**
* Sends a request and parses the response as JSON unless `asText` is set.
*
* @param method - HTTP method to use.
* @param path - API path relative to the configured base URL.
* @param body - Optional JSON request body.
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
* @returns Parsed response body.
* @throws TimeoutError When the configured timeout aborts the request.
* @throws WrennError subclasses for unsuccessful responses.
*/
async request<T>(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<T> {
const res = await this.rawRequest(method, path, body, opts);
if (!res.ok) {
await throwErrorFromResponse(res);
}
if (res.status === 204 || res.headers.get("content-length") === "0") {
return undefined as T;
}
if (opts?.asText) {
return (await res.text()) as T;
}
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
private async rawRequest(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<Response> {
const url = this.buildUrl(path, opts?.params);
const headers: Record<string, string> = { ...this.defaultHeaders };
if (body === undefined) {
delete headers["Content-Type"];
}
const init: RequestInit = {
method,
headers: { ...headers, ...opts?.headers },
};
if (opts?.redirect) init.redirect = opts.redirect;
if (body !== undefined) {
init.body = JSON.stringify(body);
}
return this.fetchWithSignal(url, init, opts);
}
private async fetchWithSignal(
url: string,
init: RequestInit,
opts?: RequestOptions,
): Promise<Response> {
const requestInit: RequestInit = { ...init };
if (opts?.signal) {
requestInit.signal = opts.signal;
return fetch(url, requestInit);
}
if (opts?.timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
requestInit.signal = controller.signal;
try {
const res = await fetch(url, requestInit);
clearTimeout(timeout);
return res;
} catch (err) {
clearTimeout(timeout);
if (err instanceof DOMException && err.name === "AbortError") {
throw new TimeoutError(`Request timed out after ${opts.timeoutMs}ms`);
}
throw err;
}
}
return fetch(url, requestInit);
}
private buildUrl(
path: string,
params?: Record<string, string | number | boolean | undefined>,
): string {
const url = new URL(`${this.baseUrl}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
}

145
src/_shared/websocket.ts Normal file
View File

@ -0,0 +1,145 @@
import WebSocket from "ws";
import { TimeoutError } from "../exceptions.js";
/** Options used to establish a Wrenn WebSocket connection. */
export interface WsConnectionOpts {
/** HTTP(S) API origin. Converted to WS(S) for the socket URL. */
baseUrl: string;
/** WebSocket path relative to the base URL. */
path: string;
/** API key sent as `X-API-Key`. */
apiKey?: string;
/** Host token sent as `X-Host-Token`. */
hostToken?: string;
/** Callback invoked for each JSON message or raw text payload. */
onMessage: (data: unknown) => void;
/** Callback invoked for socket errors after connection establishment. */
onError?: (error: Error) => void;
/** Callback invoked when the socket closes after connection establishment. */
onClose?: (code: number, reason: string) => void;
/** Connection timeout in milliseconds. Defaults to 30 seconds. */
timeoutMs?: number;
}
/** Minimal WebSocket wrapper for JSON-oriented Wrenn streaming endpoints. */
export class WsConnection {
private ws: WebSocket;
private closed = false;
private constructor(ws: WebSocket) {
this.ws = ws;
}
/** Sends a JSON-encoded message over the open WebSocket. */
send(data: unknown): void {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.ws.send(JSON.stringify(data));
}
/** Closes the WebSocket connection if it is still open. */
close(): void {
if (this.closed) return;
this.closed = true;
this.ws.close();
}
/** Closes the WebSocket when used with `await using`. */
async [Symbol.asyncDispose](): Promise<void> {
this.close();
}
/** Indicates whether the connection has closed or failed. */
get isClosed(): boolean {
return this.closed;
}
/**
* Opens a WebSocket connection and resolves once the socket is ready.
*
* @param opts - Connection URL, authentication, callbacks, and timeout.
* @returns An established WebSocket connection wrapper.
* @throws TimeoutError When the connection is not established before timeout.
*/
static connect(opts: WsConnectionOpts): Promise<WsConnection> {
return new Promise((resolve, reject) => {
const url = new URL(`${opts.baseUrl}${opts.path}`);
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.protocol = protocol;
const headers: Record<string, string> = {};
if (opts.apiKey) {
headers["X-API-Key"] = opts.apiKey;
}
if (opts.hostToken) {
headers["X-Host-Token"] = opts.hostToken;
}
const ws = new WebSocket(url.toString(), {
headers,
});
const timeout = opts.timeoutMs ?? 30_000;
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
settled = true;
};
timeoutHandle = setTimeout(() => {
if (!settled) {
cleanup();
ws.terminate();
reject(
new TimeoutError(
`WebSocket connection timed out after ${timeout}ms`,
),
);
}
}, timeout);
ws.on("open", () => {
if (settled) return;
cleanup();
const conn = new WsConnection(ws);
ws.on("message", (raw) => {
try {
const data = JSON.parse(raw.toString());
opts.onMessage(data);
} catch {
opts.onMessage(raw.toString());
}
});
ws.on("error", (err) => {
conn.closed = true;
opts.onError?.(err);
});
ws.on("close", (code, reason) => {
conn.closed = true;
opts.onClose?.(code, reason.toString());
});
resolve(conn);
});
ws.on("error", (err) => {
if (settled) return;
cleanup();
reject(err);
});
ws.on("close", (code, reason) => {
if (settled) return;
cleanup();
reject(
new Error(
`WebSocket closed before opening (${code}): ${reason.toString()}`,
),
);
});
});
}
}

325
src/capsule.ts Normal file
View File

@ -0,0 +1,325 @@
import {
type OperationJsonBody,
type OperationJsonResponse,
type OperationQueryParams,
WrennClient,
} from "./client.js";
import { CommandManager } from "./commands.js";
import type { ClientConfig } from "./config.js";
import { TimeoutError, WrennError } from "./exceptions.js";
import { FileManager } from "./files.js";
import { Git } from "./git/index.js";
import { PtyManager } from "./pty.js";
/** Capsule metadata returned by lifecycle endpoints. */
export type CapsuleInfo = OperationJsonResponse<"getCapsule", 200>;
/** Resource metrics returned for a capsule. */
export type CapsuleMetrics = OperationJsonResponse<"getCapsuleMetrics", 200>;
/** Query parameters accepted by capsule metrics requests. */
export type CapsuleMetricsOptions = OperationQueryParams<"getCapsuleMetrics">;
type CapsuleStatus = NonNullable<CapsuleInfo["status"]>;
const DEFAULT_TEMPLATE = "minimal";
const DEFAULT_VCPUS = 1;
const DEFAULT_MEMORY_MB = 512;
const DEFAULT_TIMEOUT_SEC = 0;
const DEFAULT_WAIT_TIMEOUT_MS = 60_000;
const DEFAULT_WAIT_INTERVAL_MS = 1_000;
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
"error",
"missing",
"stopping",
"stopped",
]);
/** Options accepted when creating a new capsule. */
export interface CapsuleCreateOptions extends ClientConfig {
/** Template name to boot. Defaults to `minimal`. */
template?: string;
/** Number of virtual CPUs. Defaults to `1`. */
vcpus?: number;
/** Memory allocation in MiB. Defaults to `512`. */
memory_mb?: number;
/** Auto-pause TTL in seconds. Defaults to `0`, meaning no auto-pause. */
timeout_sec?: number;
}
/** Options used by lifecycle operations that wait for a running capsule. */
export interface WaitForReadyOptions {
/** Maximum time to wait before failing. Defaults to 60 seconds. */
timeoutMs?: number;
/** Delay between status polls. Defaults to 1 second. */
intervalMs?: number;
/** Optional cancellation signal. */
signal?: AbortSignal;
}
export type CapsuleResumeOptions = WaitForReadyOptions & {
/** When true, wait until the resumed capsule reports `running`. */
wait?: boolean;
};
function clientConfigFrom(opts?: ClientConfig): ClientConfig | undefined {
if (!opts) return undefined;
const config: ClientConfig = {};
if (opts.baseUrl !== undefined) config.baseUrl = opts.baseUrl;
if (opts.apiKey !== undefined) config.apiKey = opts.apiKey;
if (opts.token !== undefined) config.token = opts.token;
if (opts.hostToken !== undefined) config.hostToken = opts.hostToken;
return config;
}
function assertNotAborted(signal?: AbortSignal): void {
if (!signal?.aborted) return;
throw new DOMException("Operation aborted", "AbortError");
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
assertNotAborted(signal);
return new Promise((resolve, reject) => {
const cleanup = () => signal?.removeEventListener("abort", abort);
const timeout = setTimeout(() => {
cleanup();
resolve();
}, ms);
const abort = () => {
clearTimeout(timeout);
cleanup();
reject(new DOMException("Operation aborted", "AbortError"));
};
signal?.addEventListener("abort", abort, { once: true });
});
}
/** Main user-facing handle for a Wrenn capsule. */
export class Capsule {
private disposed = false;
private ownsRemote = false;
/** Capsule identifier used for all instance operations. */
readonly id: string;
/** Low-level client backing this capsule handle. */
readonly client: WrennClient;
/** Command execution helper bound to this capsule. */
readonly commands: CommandManager;
/** File operation helper bound to this capsule. */
readonly files: FileManager;
/** Git helper bound to this capsule. */
readonly git: Git;
/** Interactive terminal helper bound to this capsule. */
readonly pty: PtyManager;
/**
* Wraps an existing capsule ID without fetching or creating remote resources.
*
* @param id - Existing capsule identifier.
* @param opts - Optional client configuration.
*/
constructor(id: string, opts?: ClientConfig) {
this.id = id;
this.client = new WrennClient(opts);
this.commands = new CommandManager(id, this.client);
this.files = new FileManager(id, this.client);
this.git = new Git(this);
this.pty = new PtyManager(id, this.client);
}
static create(opts?: CapsuleCreateOptions): Promise<Capsule>;
static create(
template: string,
opts?: CapsuleCreateOptions,
): Promise<Capsule>;
/**
* Creates a capsule and returns a high-level capsule handle.
*
* @param templateOrOpts - Template name or creation options. Defaults to `minimal`.
* @param opts - Creation options when the first argument is a template name.
* @returns Capsule handle bound to the created capsule ID.
* @throws WrennError subclasses for unsuccessful API responses.
*/
static async create(
templateOrOpts?: string | CapsuleCreateOptions,
opts?: CapsuleCreateOptions,
): Promise<Capsule> {
const template =
typeof templateOrOpts === "string"
? templateOrOpts
: (templateOrOpts?.template ?? DEFAULT_TEMPLATE);
const createOpts =
typeof templateOrOpts === "string" ? opts : templateOrOpts;
const clientConfig = clientConfigFrom(createOpts);
const client = new WrennClient(clientConfig);
const body: OperationJsonBody<"createCapsule"> = {
memory_mb: createOpts?.memory_mb ?? DEFAULT_MEMORY_MB,
template,
timeout_sec: createOpts?.timeout_sec ?? DEFAULT_TIMEOUT_SEC,
vcpus: createOpts?.vcpus ?? DEFAULT_VCPUS,
};
const capsule = await client.capsules.create(body);
if (!capsule.id) {
throw new WrennError(
500,
"Created capsule response did not include an id",
);
}
return new Capsule(capsule.id, clientConfig).markOwned();
}
/**
* Wraps an existing capsule ID without validating it remotely.
*
* @param id - Existing capsule identifier.
* @param opts - Optional client configuration.
* @returns Capsule handle bound to the existing capsule ID.
*/
static connect(id: string, opts?: ClientConfig): Capsule {
return new Capsule(id, opts);
}
/**
* Destroys a capsule by ID without constructing an instance.
*
* @param id - Capsule identifier to destroy.
* @param opts - Optional client configuration.
* @returns Resolves when the capsule is destroyed.
* @throws WrennError subclasses for unsuccessful API responses.
*/
static destroy(id: string, opts?: ClientConfig): Promise<void> {
return new WrennClient(opts).capsules.destroy(id);
}
/**
* Fetches the latest capsule metadata.
*
* @returns Latest capsule metadata.
* @throws WrennError subclasses for unsuccessful API responses.
*/
getInfo(): Promise<CapsuleInfo> {
return this.client.capsules.get(this.id);
}
/**
* Polls capsule metadata until the capsule reaches `running`.
*
* @param opts - Optional timeout, interval, and abort signal.
* @returns Capsule metadata after it reaches `running`.
* @throws TimeoutError when the wait deadline expires.
* @throws WrennError when the capsule reaches a terminal non-running status.
*/
async waitForReady(opts?: WaitForReadyOptions): Promise<CapsuleInfo> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
const intervalMs = opts?.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
const deadline = Date.now() + timeoutMs;
let isFirstPoll = true;
while (true) {
assertNotAborted(opts?.signal);
if (!isFirstPoll && Date.now() >= deadline) {
throw new TimeoutError(
`Timed out waiting for capsule ${this.id} to become running`,
);
}
const requestOpts = opts?.signal ? { signal: opts.signal } : undefined;
const capsule = await this.client.capsules.get(this.id, requestOpts);
isFirstPoll = false;
if (capsule.status === "running") return capsule;
if (capsule.status && TERMINAL_STATUSES.has(capsule.status)) {
throw new WrennError(
409,
`Capsule ${this.id} reached terminal status "${capsule.status}"`,
);
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new TimeoutError(
`Timed out waiting for capsule ${this.id} to become running`,
);
}
await delay(Math.min(intervalMs, remainingMs), opts?.signal);
}
}
/**
* Destroys this capsule.
*
* @returns Resolves when the capsule is destroyed.
* @throws WrennError subclasses for unsuccessful API responses.
*/
async destroy(): Promise<void> {
if (this.disposed) return;
await this.client.capsules.destroy(this.id);
this.disposed = true;
}
/**
* Pauses this capsule and returns the updated capsule metadata.
*
* @returns Updated capsule metadata after pause.
* @throws WrennError subclasses for unsuccessful API responses.
*/
pause(): Promise<CapsuleInfo> {
return this.client.capsules.pause(this.id);
}
/**
* Resumes this capsule and optionally waits until it becomes ready.
*
* @param opts - Optional readiness wait settings.
* @returns Updated capsule metadata, or running metadata when `wait` is true.
* @throws TimeoutError when waiting for readiness times out.
* @throws WrennError subclasses for unsuccessful API responses.
*/
async resume(opts?: CapsuleResumeOptions): Promise<CapsuleInfo> {
const capsule = await this.client.capsules.resume(this.id);
if (!opts?.wait) return capsule;
return this.waitForReady(opts);
}
/**
* Resets this capsule's inactivity timer.
*
* @returns Resolves when the ping is accepted.
* @throws WrennError subclasses for unsuccessful API responses.
*/
ping(): Promise<void> {
return this.client.capsules.ping(this.id);
}
/**
* Fetches resource metrics for this capsule.
*
* @param opts - Optional metrics query parameters.
* @returns Resource metrics for the capsule.
* @throws WrennError subclasses for unsuccessful API responses.
*/
getMetrics(opts?: CapsuleMetricsOptions): Promise<CapsuleMetrics> {
return this.client.capsules.metrics(this.id, opts);
}
/** Local cleanup hook. This does not mutate or destroy the remote capsule. */
close(): void {}
/** Destroys capsules created by this SDK instance when used with `await using`. */
async [Symbol.asyncDispose](): Promise<void> {
if (this.ownsRemote) {
await this.destroy();
return;
}
this.close();
}
private markOwned(): this {
this.ownsRemote = true;
return this;
}
}
/** @deprecated Use {@link Capsule} instead. */
export const Sandbox = Capsule;

1412
src/client.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
import { Capsule, type CapsuleCreateOptions } from "../capsule.js";
import type { ClientConfig } from "../config.js";
/** Options for executing a Python cell through the code interpreter. */
export interface ExecCellOptions {
/** Command timeout in seconds. Defaults to 30. */
timeoutSec?: number;
}
/** Result returned after executing a Python cell. */
export interface ExecCellResult {
/** Captured standard output from Python. */
stdout: string;
/** Captured standard error from Python. */
stderr: string;
/** Process exit code returned by Python. */
exitCode: number | undefined;
}
/** Executes notebook-style Python cells inside a capsule. */
export class Notebook {
/**
* Creates a notebook helper bound to a capsule.
*
* @param capsule - Capsule used to run Python code.
*/
constructor(private readonly capsule: Capsule) {}
/**
* Executes Python code using the capsule's Python 3 CLI.
*
* @param code - Python source code to execute.
* @param opts - Optional execution timeout.
* @returns Captured stdout, stderr, and exit code.
* @throws WrennError subclasses for unsuccessful command execution requests.
*/
async execCell(
code: string,
opts?: ExecCellOptions,
): Promise<ExecCellResult> {
const result = await this.capsule.commands.exec("python3", {
args: ["-c", code],
timeoutSec: opts?.timeoutSec ?? 30,
});
return {
exitCode: result.exit_code,
stderr: result.stderr ?? "",
stdout: result.stdout ?? "",
};
}
}
/** Specialized capsule wrapper for Python/Jupyter code execution. */
export class CodeInterpreter {
/** Notebook-style Python execution helper. */
readonly notebook: Notebook;
/**
* Creates a code interpreter around an existing capsule instance.
*
* @param capsule - Capsule that provides command execution.
*/
constructor(readonly capsule: Capsule) {
this.notebook = new Notebook(capsule);
}
/**
* Creates a capsule using the Jupyter template by default.
*
* @param opts - Optional capsule creation and client configuration.
* @returns Code interpreter bound to the created capsule.
* @throws WrennError subclasses for unsuccessful API responses.
*/
static async create(opts?: CapsuleCreateOptions): Promise<CodeInterpreter> {
const template = opts?.template ?? "jupyter";
const capsule = await Capsule.create(template, opts);
return new CodeInterpreter(capsule);
}
/**
* Wraps an existing capsule ID without validating it remotely.
*
* @param id - Existing capsule identifier.
* @param opts - Optional client configuration.
* @returns Code interpreter bound to the existing capsule ID.
*/
static connect(id: string, opts?: ClientConfig): CodeInterpreter {
return new CodeInterpreter(Capsule.connect(id, opts));
}
/**
* Disposes the underlying capsule according to its ownership semantics.
*
* Interpreters created with {@link CodeInterpreter.create} destroy their remote
* capsule on disposal. Interpreters attached with {@link CodeInterpreter.connect}
* leave the remote capsule running.
*/
async [Symbol.asyncDispose](): Promise<void> {
await this.capsule[Symbol.asyncDispose]();
}
}

209
src/commands.ts Normal file
View File

@ -0,0 +1,209 @@
import { AsyncQueue } from "./_shared/async-queue.js";
import type { WsConnection } from "./_shared/websocket.js";
import type {
OperationJsonBody,
OperationJsonResponse,
WrennClient,
} from "./client.js";
/** Result returned by a foreground command execution. */
export type CommandResult = OperationJsonResponse<"execCommand", 200>;
/** Metadata returned when a command starts as a background process. */
export type BackgroundProcess = OperationJsonResponse<"execCommand", 202>;
/** Running process list returned by the capsule process endpoint. */
export type ProcessList = OperationJsonResponse<"listProcesses", 200>;
/** Options for foreground command execution. */
export interface CommandOptions {
/** Command-line arguments passed after the executable. */
args?: string[];
/** Server-side timeout in seconds. */
timeoutSec?: number;
/** Working directory inside the capsule. */
cwd?: string;
}
/** Options for starting a background command. */
export interface BackgroundCommandOptions extends CommandOptions {
/** Optional process tag used to reconnect or kill the process later. */
tag?: string;
/** Environment variables applied to the command process. */
envs?: Record<string, string>;
/** Working directory inside the capsule. */
cwd?: string;
}
/** Options for command WebSocket streaming. */
export interface CommandStreamOptions {
/** Command-line arguments sent with the stream start message. */
args?: string[];
/** WebSocket connection timeout in milliseconds. */
timeoutMs?: number;
}
/** Event emitted by command streaming endpoints. */
export interface CommandStreamEvent {
/** Event discriminator such as `start`, `stdout`, `stderr`, `exit`, or `error`. */
type: string;
/** Additional event fields returned by the server. */
[key: string]: unknown;
}
const DEFAULT_COMMAND_TIMEOUT_SEC = 30;
function commandBody(
cmd: string,
background: boolean,
opts?: BackgroundCommandOptions,
): OperationJsonBody<"execCommand"> {
const body: Partial<OperationJsonBody<"execCommand">> = { cmd };
if (background) body.background = true;
if (background || opts?.timeoutSec !== undefined) {
body.timeout_sec = opts?.timeoutSec ?? DEFAULT_COMMAND_TIMEOUT_SEC;
}
if (opts?.args) body.args = opts.args;
if (opts?.tag) body.tag = opts.tag;
if (opts?.envs) body.envs = opts.envs;
if (opts?.cwd) body.cwd = opts.cwd;
return body as OperationJsonBody<"execCommand">;
}
function isTerminalEvent(event: CommandStreamEvent): boolean {
return event.type === "exit" || event.type === "error";
}
/** User-friendly command execution API bound to one capsule. */
export class CommandManager {
/**
* Creates a command manager for one capsule.
*
* @param capsuleId - Capsule identifier to run commands in.
* @param client - Low-level client used for HTTP and WebSocket calls.
*/
constructor(
private readonly capsuleId: string,
private readonly client: WrennClient,
) {}
/**
* Executes a command and waits for foreground output.
*
* @param cmd - Executable or shell command to run.
* @param opts - Optional arguments, timeout, and working directory.
* @returns Command output, stderr, and exit status returned by the API.
* @throws WrennError subclasses for unsuccessful API responses.
*/
exec(cmd: string, opts?: CommandOptions): Promise<CommandResult> {
return this.client.capsules.exec(
this.capsuleId,
commandBody(cmd, false, opts),
) as Promise<CommandResult>;
}
/**
* Starts a command as a background process.
*
* @param cmd - Executable or shell command to run.
* @param opts - Optional arguments, tag, environment, timeout, and working directory.
* @returns Background process metadata, including PID or tag when returned.
* @throws WrennError subclasses for unsuccessful API responses.
*/
start(
cmd: string,
opts?: BackgroundCommandOptions,
): Promise<BackgroundProcess> {
return this.client.capsules.exec(
this.capsuleId,
commandBody(cmd, true, opts),
) as Promise<BackgroundProcess>;
}
/**
* Lists running background processes in the capsule.
*
* @returns Process list returned by the capsule API.
* @throws WrennError subclasses for unsuccessful API responses.
*/
list(): Promise<ProcessList> {
return this.client.capsules.listProcesses(this.capsuleId);
}
/**
* Kills a process by PID or tag.
*
* @param selector - Process PID or tag.
* @param signal - Optional POSIX signal. Defaults to server behavior when omitted.
* @returns Resolves when the process is killed.
* @throws WrennError subclasses for unsuccessful API responses.
*/
kill(selector: string, signal?: "SIGKILL" | "SIGTERM"): Promise<void> {
const params = signal ? { signal } : undefined;
return this.client.capsules.killProcess(this.capsuleId, selector, params);
}
/**
* Streams command events over WebSocket until an `exit` or `error` event arrives.
*
* @param cmd - Executable or shell command to run.
* @param opts - Optional arguments and WebSocket timeout.
* @returns Async generator of command stream events.
* @throws TimeoutError when the WebSocket connection times out.
*/
async *stream(
cmd: string,
opts?: CommandStreamOptions,
): AsyncGenerator<CommandStreamEvent> {
const queue = new AsyncQueue<CommandStreamEvent>();
let connection: WsConnection | undefined;
const socketOpts: Parameters<typeof this.client.capsules.execStream>[1] = {
onClose: () => queue.end(),
onError: (error) => queue.fail(error),
onMessage: (message) => {
const event = message as CommandStreamEvent;
queue.push(event);
if (isTerminalEvent(event)) queue.end();
},
};
if (opts?.timeoutMs !== undefined) socketOpts.timeoutMs = opts.timeoutMs;
connection = await this.client.capsules.execStream(
this.capsuleId,
socketOpts,
);
const start: CommandStreamEvent = { cmd, type: "start" };
if (opts?.args) start.args = opts.args;
connection.send(start);
try {
yield* queue;
} finally {
if (!connection.isClosed) connection.close();
}
}
/**
* Connects to an existing background process stream.
*
* @param selector - Process PID or tag.
* @param opts - Optional WebSocket timeout.
* @returns Established WebSocket connection for the process stream.
* @throws TimeoutError when the WebSocket connection times out.
*/
streamProcess(
selector: string,
opts?: Pick<CommandStreamOptions, "timeoutMs">,
): Promise<WsConnection> {
const socketOpts: Parameters<
typeof this.client.capsules.connectProcess
>[2] = {
onMessage: () => undefined,
};
if (opts?.timeoutMs !== undefined) socketOpts.timeoutMs = opts.timeoutMs;
return this.client.capsules.connectProcess(
this.capsuleId,
selector,
socketOpts,
);
}
}

62
src/config.ts Normal file
View File

@ -0,0 +1,62 @@
/** Default Wrenn API origin used when no base URL is supplied. */
export const DEFAULT_BASE_URL = "https://app.wrenn.dev/api";
/** Environment variable used for API-key authentication. */
export const ENV_API_KEY = "WRENN_API_KEY";
/** Environment variable used for bearer JWT authentication. */
export const ENV_TOKEN = "WRENN_TOKEN";
/** Environment variable used for host-token authentication. */
export const ENV_HOST_TOKEN = "WRENN_HOST_TOKEN";
/** Environment variable used to override the Wrenn API origin. */
export const ENV_BASE_URL = "WRENN_BASE_URL";
/** Client configuration supplied directly by SDK callers. */
export interface ClientConfig {
/** API origin. Defaults to `WRENN_BASE_URL` or {@link DEFAULT_BASE_URL}. */
baseUrl?: string;
/** API key sent as `X-API-Key` for capsule lifecycle operations. */
apiKey?: string;
/** Bearer JWT sent as `Authorization: Bearer ...` for account/team operations. */
token?: string;
/** Host token sent as `X-Host-Token` for host-agent operations. */
hostToken?: string;
}
/** Fully resolved client configuration after applying environment fallbacks. */
export interface ResolvedClientConfig {
/** API origin with environment/default fallback applied. */
baseUrl: string;
/** Resolved API key, if one is available. */
apiKey?: string;
/** Resolved bearer JWT, if one is available. */
token?: string;
/** Resolved host token, if one is available. */
hostToken?: string;
}
/**
* Resolves explicit client options against Wrenn environment variables.
*
* Explicit options always win over environment values. Empty credentials are
* omitted from the returned object so `exactOptionalPropertyTypes` consumers do
* not receive `undefined` credential fields.
*
* @param opts - Optional caller-supplied client configuration.
* @returns A normalized configuration object ready for HTTP/WebSocket clients.
*/
export function resolveConfig(opts?: ClientConfig): ResolvedClientConfig {
const config: ResolvedClientConfig = {
baseUrl: opts?.baseUrl ?? process.env[ENV_BASE_URL] ?? DEFAULT_BASE_URL,
};
const apiKey = opts?.apiKey ?? process.env[ENV_API_KEY];
if (apiKey) config.apiKey = apiKey;
const token = opts?.token ?? process.env[ENV_TOKEN];
if (token) config.token = token;
const hostToken = opts?.hostToken ?? process.env[ENV_HOST_TOKEN];
if (hostToken) config.hostToken = hostToken;
return config;
}

162
src/exceptions.ts Normal file
View File

@ -0,0 +1,162 @@
/** Base class for all SDK errors raised from Wrenn API responses. */
export class WrennError extends Error {
/** HTTP status code associated with the failure. */
readonly statusCode: number;
/** Stable API error code when returned by the server. */
readonly code?: string | undefined;
/** Parsed response body, when available. */
readonly body?: unknown | undefined;
/**
* Creates an SDK error.
*
* @param statusCode - HTTP status code associated with the failure.
* @param message - Human-readable error message.
* @param code - Optional server-provided error code.
* @param body - Optional parsed response body.
*/
constructor(
statusCode: number,
message: string,
code?: string,
body?: unknown,
) {
super(message);
this.name = "WrennError";
this.statusCode = statusCode;
this.code = code;
this.body = body;
}
}
/** Error raised for malformed requests or invalid request parameters. */
export class BadRequestError extends WrennError {
constructor(message: string, code?: string, body?: unknown) {
super(400, message, code, body);
this.name = "BadRequestError";
}
}
/** Error raised when authentication credentials are missing or invalid. */
export class AuthenticationError extends WrennError {
constructor(message: string, code?: string, body?: unknown) {
super(401, message, code, body);
this.name = "AuthenticationError";
}
}
/** Error raised when valid credentials do not grant access to a resource. */
export class ForbiddenError extends WrennError {
constructor(message: string, code?: string, body?: unknown) {
super(403, message, code, body);
this.name = "ForbiddenError";
}
}
/** Error raised when a requested resource cannot be found. */
export class NotFoundError extends WrennError {
constructor(message: string, code?: string, body?: unknown) {
super(404, message, code, body);
this.name = "NotFoundError";
}
}
/** Error raised when the request conflicts with current server state. */
export class ConflictError extends WrennError {
constructor(message: string, code?: string, body?: unknown) {
super(409, message, code, body);
this.name = "ConflictError";
}
}
/** Error raised when an upload or request payload exceeds server limits. */
export class PayloadTooLargeError extends WrennError {
constructor(message: string, code?: string, body?: unknown) {
super(413, message, code, body);
this.name = "PayloadTooLargeError";
}
}
/** Error raised when a request or connection exceeds its configured timeout. */
export class TimeoutError extends WrennError {
constructor(message = "Request timed out", code?: string, body?: unknown) {
super(408, message, code, body);
this.name = "TimeoutError";
}
}
/** Error raised for 5xx responses returned by the Wrenn API. */
export class ServerError extends WrennError {
constructor(
statusCode: number,
message: string,
code?: string,
body?: unknown,
) {
super(statusCode, message, code, body);
this.name = "ServerError";
}
}
/** Error raised when deleting a host that still owns active capsules. */
export class HostHasCapsulesError extends ConflictError {
/** IDs of capsules preventing host deletion. */
readonly sandboxIds: string[];
constructor(
message: string,
sandboxIds: string[],
code?: string,
body?: unknown,
) {
super(message, code, body);
this.name = "HostHasCapsulesError";
this.sandboxIds = sandboxIds;
}
}
interface ApiErrorBody {
error?: {
code?: string;
message?: string;
sandbox_ids?: string[];
};
}
/**
* Converts an unsuccessful `fetch` response into the matching SDK error type.
*
* @param res - Non-OK response returned by `fetch`.
* @throws WrennError subclasses based on the HTTP status code and error body.
*/
export async function throwErrorFromResponse(res: Response): Promise<never> {
const status = res.status;
let body: unknown;
try {
body = await res.json();
} catch {
throw new WrennError(status, res.statusText, undefined, undefined);
}
const errorBody = body as ApiErrorBody | undefined;
const code = errorBody?.error?.code;
const message = errorBody?.error?.message ?? res.statusText;
const sandboxIds = errorBody?.error?.sandbox_ids;
if (status === 400) throw new BadRequestError(message, code, body);
if (status === 401) throw new AuthenticationError(message, code, body);
if (status === 403) throw new ForbiddenError(message, code, body);
if (status === 404) throw new NotFoundError(message, code, body);
if (status === 408) throw new TimeoutError(message, code, body);
if (status === 409) {
if (sandboxIds?.length) {
throw new HostHasCapsulesError(message, sandboxIds, code, body);
}
throw new ConflictError(message, code, body);
}
if (status === 413) throw new PayloadTooLargeError(message, code, body);
if (status >= 500) throw new ServerError(status, message, code, body);
throw new WrennError(status, message, code, body);
}

143
src/files.ts Normal file
View File

@ -0,0 +1,143 @@
import type { OperationJsonResponse, WrennClient } from "./client.js";
/** Directory listing returned by capsule file APIs. */
export type FileList = OperationJsonResponse<"listDir", 200>;
/** Directory creation response returned by capsule file APIs. */
export type MakeDirectoryResult = OperationJsonResponse<"makeDir", 200>;
/** Options for listing capsule directory contents. */
export interface ListFilesOptions {
/** Maximum traversal depth. Defaults to `1`. */
depth?: number;
}
/** File content accepted by high-level upload helpers. */
export type FileContent = Blob | string | Uint8Array | ArrayBuffer;
async function streamToBuffer(
stream: ReadableStream<Uint8Array>,
): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function uploadContent(content: FileContent): Blob | string {
if (typeof content === "string" || content instanceof Blob) return content;
if (content instanceof ArrayBuffer) return new Blob([content]);
const buffer = new ArrayBuffer(content.byteLength);
new Uint8Array(buffer).set(content);
return new Blob([buffer]);
}
/** User-friendly file API bound to one capsule. */
export class FileManager {
/**
* Creates a file manager for one capsule.
*
* @param capsuleId - Capsule identifier to operate on.
* @param client - Low-level client used for file endpoint calls.
*/
constructor(
private readonly capsuleId: string,
private readonly client: WrennClient,
) {}
/**
* Reads a file into a buffer.
*
* @param path - Absolute path inside the capsule.
* @returns File contents as a Node.js `Buffer`.
* @throws WrennError subclasses for unsuccessful API responses.
*/
async read(path: string): Promise<Buffer> {
return streamToBuffer(
await this.client.files.download(this.capsuleId, { path }),
);
}
/**
* Writes content to a file.
*
* @param path - Absolute destination path inside the capsule.
* @param content - Text, binary, or blob content to upload.
* @returns Resolves when the upload completes.
* @throws WrennError subclasses for unsuccessful API responses.
*/
write(path: string, content: FileContent): Promise<void> {
return this.client.files.upload(this.capsuleId, {
file: uploadContent(content),
path,
});
}
/**
* Lists directory contents.
*
* @param path - Directory path inside the capsule.
* @param opts - Optional listing depth.
* @returns Directory listing response.
* @throws WrennError subclasses for unsuccessful API responses.
*/
list(path: string, opts?: ListFilesOptions): Promise<FileList> {
return this.client.files.list(this.capsuleId, {
depth: opts?.depth ?? 1,
path,
});
}
/**
* Creates a directory.
*
* @param path - Directory path inside the capsule.
* @returns Directory creation response.
* @throws WrennError subclasses for unsuccessful API responses.
*/
mkdir(path: string): Promise<MakeDirectoryResult> {
return this.client.files.mkdir(this.capsuleId, { path });
}
/**
* Removes a file or directory.
*
* @param path - Path inside the capsule to remove.
* @returns Resolves when the path is removed.
* @throws WrennError subclasses for unsuccessful API responses.
*/
remove(path: string): Promise<void> {
return this.client.files.remove(this.capsuleId, { path });
}
/**
* Downloads a file as an async stream of buffers.
*
* @param path - Absolute path inside the capsule.
* @returns Async generator yielding file content chunks.
* @throws WrennError subclasses for unsuccessful API responses.
*/
async *downloadStream(path: string): AsyncGenerator<Buffer> {
const stream = await this.client.files.streamDownload(this.capsuleId, {
path,
});
for await (const chunk of stream) {
yield Buffer.from(chunk);
}
}
/**
* Uploads content through the streaming upload endpoint.
*
* @param path - Absolute destination path inside the capsule.
* @param content - Text, binary, or blob content to upload.
* @returns Resolves when the upload completes.
* @throws WrennError subclasses for unsuccessful API responses.
*/
uploadStream(path: string, content: FileContent): Promise<void> {
return this.client.files.streamUpload(this.capsuleId, {
file: uploadContent(content),
path,
});
}
}

170
src/git/index.ts Normal file
View File

@ -0,0 +1,170 @@
import type { Capsule } from "../capsule.js";
import type { CommandOptions, CommandResult } from "../commands.js";
export interface GitOptions {
/** Working directory used for repository-scoped git commands. */
cwd?: string;
}
export interface GitCloneOptions {
/** Destination path for the cloned repository. */
path?: string;
/** Branch to check out during clone. */
branch?: string;
}
export interface GitRemoteBranchOptions extends GitOptions {
/** Remote name. Defaults to git's configured default when omitted. */
remote?: string;
/** Branch name. Defaults to git's configured default when omitted. */
branch?: string;
}
export interface GitLogOptions extends GitOptions {
/** Maximum number of commits to return. */
maxCount?: number;
}
export interface GitCheckoutOptions extends GitOptions {
/** Create the branch before checking it out. */
create?: boolean;
}
/** High-level Git helper that runs git CLI commands inside a capsule. */
export class Git {
/**
* Creates a Git helper bound to one capsule.
*
* @param capsule - Capsule used to execute git commands.
* @param opts - Default git command options, such as repository working directory.
*/
constructor(
private readonly capsule: Capsule,
private readonly opts: GitOptions = {},
) {}
/**
* Clones a repository into the capsule.
*
* @param url - Repository URL accepted by `git clone`.
* @param opts - Optional destination path and branch.
* @returns Command result from the capsule.
*/
clone(url: string, opts?: GitCloneOptions): Promise<CommandResult> {
const args = ["clone"];
if (opts?.branch) args.push("--branch", opts.branch);
args.push(url);
if (opts?.path) args.push(opts.path);
return this.run(args);
}
/**
* Returns porcelain status for the current repository.
*
* @param opts - Optional repository working directory.
* @returns Command result containing `git status --short` output.
*/
status(opts?: GitOptions): Promise<CommandResult> {
return this.run(["status", "--short"], opts);
}
/**
* Pulls changes from a remote branch.
*
* @param opts - Optional repository working directory, remote, and branch.
* @returns Command result from `git pull`.
*/
pull(opts?: GitRemoteBranchOptions): Promise<CommandResult> {
return this.runRemoteBranch("pull", opts);
}
/**
* Pushes changes to a remote branch.
*
* @param opts - Optional repository working directory, remote, and branch.
* @returns Command result from `git push`.
*/
push(opts?: GitRemoteBranchOptions): Promise<CommandResult> {
return this.runRemoteBranch("push", opts);
}
/**
* Returns compact commit history.
*
* @param opts - Optional repository working directory and commit limit.
* @returns Command result containing `git log --oneline` output.
*/
log(opts?: GitLogOptions): Promise<CommandResult> {
const args = ["log", "--oneline"];
if (opts?.maxCount !== undefined) args.push("-n", String(opts.maxCount));
return this.run(args, opts);
}
/**
* Returns the current branch name.
*
* @param opts - Optional repository working directory.
* @returns Command result containing the current branch name.
*/
branch(opts?: GitOptions): Promise<CommandResult> {
return this.run(["branch", "--show-current"], opts);
}
/**
* Checks out an existing branch or creates it when requested.
*
* @param branch - Branch name to check out.
* @param opts - Optional repository working directory and create flag.
* @returns Command result from `git checkout`.
*/
checkout(branch: string, opts?: GitCheckoutOptions): Promise<CommandResult> {
const args = opts?.create
? ["checkout", "-b", branch]
: ["checkout", branch];
return this.run(args, opts);
}
/**
* Stages one or more files.
*
* @param files - File path or file paths to stage.
* @param opts - Optional repository working directory.
* @returns Command result from `git add`.
* @throws Error when no files are provided.
*/
add(files: string | string[], opts?: GitOptions): Promise<CommandResult> {
const fileList = Array.isArray(files) ? files : [files];
if (!fileList.length) {
return Promise.reject(new Error("At least one file is required"));
}
return this.run(["add", ...fileList], opts);
}
/**
* Creates a commit. Git user identity must already be configured inside the capsule.
*
* @param message - Commit message passed to `git commit -m`.
* @param opts - Optional repository working directory.
* @returns Command result from `git commit`.
*/
commit(message: string, opts?: GitOptions): Promise<CommandResult> {
return this.run(["commit", "-m", message], opts);
}
private run(args: string[], opts?: GitOptions): Promise<CommandResult> {
const commandOpts: CommandOptions = { args };
const cwd = opts?.cwd ?? this.opts.cwd;
if (cwd) commandOpts.cwd = cwd;
return this.capsule.commands.exec("git", commandOpts);
}
private runRemoteBranch(
command: "pull" | "push",
opts?: GitRemoteBranchOptions,
): Promise<CommandResult> {
const args: string[] = [command];
if (opts?.remote) args.push(opts.remote);
if (opts?.branch) args.push(opts.branch);
return this.run(args, opts);
}
}

90
src/index.ts Normal file
View File

@ -0,0 +1,90 @@
export type {
CapsuleCreateOptions,
CapsuleInfo,
CapsuleMetrics,
CapsuleMetricsOptions,
CapsuleResumeOptions,
WaitForReadyOptions,
} from "./capsule.js";
export { Capsule, Sandbox } from "./capsule.js";
export type {
FileUploadInput,
OperationJsonBody,
OperationJsonResponse,
OperationQueryParams,
OperationRequestOptions,
} from "./client.js";
export {
AccountResource,
APIKeysResource,
AuthResource,
CapsulesResource,
ChannelsResource,
FilesResource,
HostsResource,
SnapshotsResource,
TeamsResource,
UsersResource,
WrennClient,
} from "./client.js";
export type {
ExecCellOptions,
ExecCellResult,
} from "./code-interpreter/index.js";
export { CodeInterpreter, Notebook } from "./code-interpreter/index.js";
export type {
BackgroundCommandOptions,
BackgroundProcess,
CommandOptions,
CommandResult,
CommandStreamEvent,
CommandStreamOptions,
ProcessList,
} from "./commands.js";
export { CommandManager } from "./commands.js";
export type { ClientConfig, ResolvedClientConfig } from "./config.js";
export {
DEFAULT_BASE_URL,
ENV_API_KEY,
ENV_BASE_URL,
ENV_HOST_TOKEN,
ENV_TOKEN,
resolveConfig,
} from "./config.js";
export {
AuthenticationError,
BadRequestError,
ConflictError,
ForbiddenError,
HostHasCapsulesError,
NotFoundError,
PayloadTooLargeError,
ServerError,
TimeoutError,
throwErrorFromResponse,
WrennError,
} from "./exceptions.js";
export type {
FileContent,
FileList,
ListFilesOptions,
MakeDirectoryResult,
} from "./files.js";
export { FileManager } from "./files.js";
export type {
GitCheckoutOptions,
GitCloneOptions,
GitLogOptions,
GitOptions,
GitRemoteBranchOptions,
} from "./git/index.js";
export { Git } from "./git/index.js";
export type {
$defs,
components,
operations,
paths,
webhooks,
} from "./models/generated.js";
export type { PtyEvent, PtyStartOptions } from "./pty.js";
export { PtyManager, PtySession } from "./pty.js";

4390
src/models/generated.ts Normal file

File diff suppressed because it is too large Load Diff

157
src/pty.ts Normal file
View File

@ -0,0 +1,157 @@
import { AsyncQueue } from "./_shared/async-queue.js";
import type { WsConnection } from "./_shared/websocket.js";
import type { WrennClient } from "./client.js";
/** Options for starting a PTY session inside a capsule. */
export interface PtyStartOptions {
/** Command to start. Defaults to server shell behavior when omitted. */
cmd?: string;
/** Command-line arguments passed after `cmd`. */
args?: string[];
/** Initial terminal column count. */
cols?: number;
/** Initial terminal row count. */
rows?: number;
/** Environment variables applied to the PTY process. */
envs?: Record<string, string>;
/** Working directory inside the capsule. */
cwd?: string;
/** User to run the PTY process as, when supported by the backend. */
user?: string;
/** WebSocket connection timeout in milliseconds. */
timeoutMs?: number;
}
/** Event emitted by an interactive PTY session. */
export interface PtyEvent {
/** Event discriminator such as `data`, `exit`, or `error`. */
type: string;
/** Additional event fields returned by the server. */
[key: string]: unknown;
}
/** Active interactive terminal session backed by a WebSocket connection. */
export class PtySession {
/** Async iterator of PTY events received from the server. */
readonly events: AsyncIterableIterator<PtyEvent>;
/**
* Creates a PTY session wrapper around an established WebSocket.
*
* @param connection - WebSocket connection used for PTY control messages.
* @param queue - Queue of server events exposed as `events`.
*/
constructor(
private readonly connection: WsConnection,
queue: AsyncQueue<PtyEvent>,
) {
this.events = queue;
}
/**
* Sends terminal input to the PTY process.
*
* @param data - Text or bytes to send. Bytes are base64-encoded for transport.
*/
input(data: string | Uint8Array): void {
this.connection.send({
data: Buffer.from(data).toString("base64"),
type: "input",
});
}
/**
* Resizes the remote terminal.
*
* @param cols - New terminal column count.
* @param rows - New terminal row count.
*/
resize(cols: number, rows: number): void {
this.connection.send({ cols, rows, type: "resize" });
}
/** Sends a kill control message for the PTY process. */
kill(): void {
this.connection.send({ type: "kill" });
}
/** Closes the underlying WebSocket connection. */
close(): void {
this.connection.close();
}
/** Closes the underlying WebSocket when used with `await using`. */
async [Symbol.asyncDispose](): Promise<void> {
this.close();
}
}
/** Interactive terminal API bound to one capsule. */
export class PtyManager {
/**
* Creates a PTY manager for one capsule.
*
* @param capsuleId - Capsule identifier to open PTY sessions in.
* @param client - Low-level client used to open the PTY WebSocket.
*/
constructor(
private readonly capsuleId: string,
private readonly client: WrennClient,
) {}
/**
* Starts a new interactive PTY session.
*
* @param opts - Optional command, terminal size, environment, user, and timeout.
* @returns Active PTY session with input, resize, kill, close, and event APIs.
* @throws TimeoutError when the WebSocket connection times out.
*/
async start(opts?: PtyStartOptions): Promise<PtySession> {
const session = await this.open(opts?.timeoutMs);
const message: PtyEvent = { type: "start" };
if (opts?.cmd) message.cmd = opts.cmd;
if (opts?.args) message.args = opts.args;
if (opts?.cols) message.cols = opts.cols;
if (opts?.rows) message.rows = opts.rows;
if (opts?.envs) message.envs = opts.envs;
if (opts?.cwd) message.cwd = opts.cwd;
if (opts?.user) message.user = opts.user;
session.connection.send(message);
return session.value;
}
/**
* Reconnects to a tagged PTY session.
*
* @param tag - Server-side PTY session tag.
* @param opts - Optional WebSocket timeout.
* @returns Active PTY session connected to the existing tag.
* @throws TimeoutError when the WebSocket connection times out.
*/
async connect(
tag: string,
opts?: Pick<PtyStartOptions, "timeoutMs">,
): Promise<PtySession> {
const session = await this.open(opts?.timeoutMs);
session.connection.send({ tag, type: "connect" });
return session.value;
}
private async open(timeoutMs?: number): Promise<{
connection: WsConnection;
value: PtySession;
}> {
const queue = new AsyncQueue<PtyEvent>();
const socketOpts: Parameters<typeof this.client.capsules.ptySession>[1] = {
onClose: () => queue.end(),
onError: (error) => queue.fail(error),
onMessage: (message) => queue.push(message as PtyEvent),
};
if (timeoutMs !== undefined) socketOpts.timeoutMs = timeoutMs;
const connection = await this.client.capsules.ptySession(
this.capsuleId,
socketOpts,
);
return { connection, value: new PtySession(connection, queue) };
}
}

303
tests/capsule.test.ts Normal file
View File

@ -0,0 +1,303 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Capsule, Sandbox } from "../src/capsule.js";
interface CapturedRequest {
url: string;
init: RequestInit;
}
function setupFetch(responses: Response[]) {
const calls: CapturedRequest[] = [];
const fetchMock = vi.fn(
async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
const response = responses.shift();
if (!response) throw new Error("No mock response configured");
return response;
},
);
vi.stubGlobal("fetch", fetchMock);
return { calls, fetchMock };
}
function capsuleResponse(id: string, status = "running") {
return Response.json({
id,
memory_mb: 512,
status,
template: "minimal",
timeout_sec: 0,
vcpus: 1,
});
}
describe("Capsule", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("wraps an existing capsule without fetching it", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = Capsule.connect("cap_1", {
apiKey: "api-key",
baseUrl: "https://api.example.com",
});
expect(capsule.id).toBe("cap_1");
expect(fetchMock).not.toHaveBeenCalled();
expect(capsule).toBeInstanceOf(Capsule);
});
it("creates a capsule from defaulted options", async () => {
const { calls } = setupFetch([capsuleResponse("cap_created")]);
const capsule = await Capsule.create({
apiKey: "api-key",
baseUrl: "https://api.example.com/",
});
expect(capsule.id).toBe("cap_created");
expect(calls.at(-1)?.url).toBe("https://api.example.com/v1/capsules");
expect(calls.at(-1)?.init.method).toBe("POST");
expect(calls.at(-1)?.init.body).toBe(
JSON.stringify({
memory_mb: 512,
template: "minimal",
timeout_sec: 0,
vcpus: 1,
}),
);
});
it("creates a capsule with a template overload and explicit resources", async () => {
const { calls } = setupFetch([capsuleResponse("cap_custom")]);
const capsule = await Capsule.create("node", {
apiKey: "api-key",
baseUrl: "https://api.example.com",
memory_mb: 1024,
timeout_sec: 60,
vcpus: 2,
});
expect(capsule.id).toBe("cap_custom");
expect(calls.at(-1)?.init.body).toBe(
JSON.stringify({
memory_mb: 1024,
template: "node",
timeout_sec: 60,
vcpus: 2,
}),
);
});
it("throws when create does not return an id", async () => {
setupFetch([Response.json({ status: "running" })]);
await expect(
Capsule.create({ baseUrl: "https://api.example.com" }),
).rejects.toThrow("Created capsule response did not include an id");
});
it("maps instance lifecycle methods to the low-level client", async () => {
const { calls } = setupFetch([
capsuleResponse("cap_1"),
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
new Response(null, { status: 204 }),
capsuleResponse("cap_1", "pausing"),
capsuleResponse("cap_1", "resuming"),
new Response(null, { status: 204 }),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.getInfo()).resolves.toMatchObject({ id: "cap_1" });
await expect(capsule.getMetrics({ range: "10m" })).resolves.toMatchObject({
range: "10m",
});
await expect(capsule.ping()).resolves.toBeUndefined();
await expect(capsule.pause()).resolves.toMatchObject({ status: "pausing" });
await expect(capsule.resume()).resolves.toMatchObject({
status: "resuming",
});
await expect(capsule.destroy()).resolves.toBeUndefined();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"GET https://api.example.com/v1/capsules/cap_1",
"GET https://api.example.com/v1/capsules/cap_1/metrics?range=10m",
"POST https://api.example.com/v1/capsules/cap_1/ping",
"POST https://api.example.com/v1/capsules/cap_1/pause",
"POST https://api.example.com/v1/capsules/cap_1/resume",
"DELETE https://api.example.com/v1/capsules/cap_1",
]);
});
it("waits until the capsule is running", async () => {
vi.useFakeTimers();
const { calls } = setupFetch([
capsuleResponse("cap_1", "pending"),
capsuleResponse("cap_1", "starting"),
capsuleResponse("cap_1", "running"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 1_000 });
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(100);
await expect(ready).resolves.toMatchObject({ status: "running" });
expect(calls).toHaveLength(3);
});
it.each([
"error",
"missing",
"stopping",
"stopped",
])("fails waitForReady on terminal capsule state %s", async (status) => {
setupFetch([capsuleResponse("cap_1", status)]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.waitForReady()).rejects.toThrow(
`Capsule cap_1 reached terminal status "${status}"`,
);
});
it("times out while waiting for readiness", async () => {
vi.useFakeTimers();
setupFetch([
capsuleResponse("cap_1", "starting"),
capsuleResponse("cap_1", "starting"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 150 });
const assertion = expect(ready).rejects.toThrow(
"Timed out waiting for capsule cap_1 to become running",
);
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(50);
await assertion;
});
it("resumes and waits until the capsule is running", async () => {
vi.useFakeTimers();
const { calls } = setupFetch([
capsuleResponse("cap_1", "resuming"),
capsuleResponse("cap_1", "running"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.resume({
wait: true,
intervalMs: 100,
timeoutMs: 1_000,
});
await vi.advanceTimersByTimeAsync(100);
await expect(ready).resolves.toMatchObject({ status: "running" });
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"POST https://api.example.com/v1/capsules/cap_1/resume",
"GET https://api.example.com/v1/capsules/cap_1",
]);
});
it("aborts waitForReady when the signal is already aborted", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const controller = new AbortController();
controller.abort();
await expect(
capsule.waitForReady({ signal: controller.signal }),
).rejects.toThrow("Operation aborted");
});
it("aborts waitForReady when the signal fires during polling", async () => {
vi.useFakeTimers();
setupFetch([capsuleResponse("cap_1", "starting")]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const controller = new AbortController();
const ready = capsule.waitForReady({
intervalMs: 100,
timeoutMs: 5_000,
signal: controller.signal,
});
controller.abort();
await expect(ready).rejects.toThrow("Operation aborted");
});
it("does not mutate the remote capsule when connected capsules are closed or disposed", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = Capsule.connect("cap_1", {
baseUrl: "https://api.example.com",
});
capsule.close();
await capsule[Symbol.asyncDispose]();
expect(fetchMock).not.toHaveBeenCalled();
});
it("destroys created capsules when async disposed", async () => {
const { calls } = setupFetch([
capsuleResponse("cap_created"),
new Response(null, { status: 204 }),
]);
const capsule = await Capsule.create({
baseUrl: "https://api.example.com",
});
await capsule[Symbol.asyncDispose]();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"POST https://api.example.com/v1/capsules",
"DELETE https://api.example.com/v1/capsules/cap_created",
]);
});
it("does not destroy an already destroyed owned capsule twice", async () => {
const { calls } = setupFetch([
capsuleResponse("cap_created"),
new Response(null, { status: 204 }),
]);
const capsule = await Capsule.create({
baseUrl: "https://api.example.com",
});
await capsule.destroy();
await capsule[Symbol.asyncDispose]();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"POST https://api.example.com/v1/capsules",
"DELETE https://api.example.com/v1/capsules/cap_created",
]);
});
it("exports Sandbox as a deprecated Capsule alias", () => {
expect(Sandbox).toBe(Capsule);
});
});

495
tests/client.test.ts Normal file
View File

@ -0,0 +1,495 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { WrennClient } from "../src/client.js";
interface CapturedRequest {
url: string;
init: RequestInit;
}
function setupFetch(status = 200, body: unknown = { ok: true }) {
const calls: CapturedRequest[] = [];
const fetchMock = vi.fn(
async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
if (status === 204) return new Response(null, { status });
return Response.json(body, { status });
},
);
vi.stubGlobal("fetch", fetchMock);
return { calls, fetchMock };
}
function expectLastCall(
calls: CapturedRequest[],
expected: { method: string; url: string; body?: unknown },
) {
const call = calls.at(-1);
expect(call?.url).toBe(expected.url);
expect(call?.init.method).toBe(expected.method);
if (expected.body !== undefined) {
expect(call?.init.body).toBe(JSON.stringify(expected.body));
}
}
describe("WrennClient", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("initializes every resource with resolved auth headers", async () => {
const { calls } = setupFetch();
const client = new WrennClient({
apiKey: "api-key",
baseUrl: "https://api.example.com/",
hostToken: "host-token",
token: "jwt-token",
});
await client.auth.login({ email: "a@example.com", password: "password" });
expect(client.account).toBeDefined();
expect(client.apiKeys).toBeDefined();
expect(client.users).toBeDefined();
expect(client.teams).toBeDefined();
expect(client.capsules).toBeDefined();
expect(client.files).toBeDefined();
expect(client.snapshots).toBeDefined();
expect(client.hosts).toBeDefined();
expect(client.channels).toBeDefined();
expect(calls.at(-1)?.init.headers).toMatchObject({
Accept: "application/json",
Authorization: "Bearer jwt-token",
"Content-Type": "application/json",
"X-API-Key": "api-key",
"X-Host-Token": "host-token",
});
});
it("maps auth endpoints", async () => {
const { calls } = setupFetch();
const client = new WrennClient({ baseUrl: "https://api.example.com" });
await client.auth.signup({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/auth/signup",
});
await client.auth.activate({ token: "activation-token" });
expectLastCall(calls, {
body: { token: "activation-token" },
method: "POST",
url: "https://api.example.com/v1/auth/activate",
});
await client.auth.login({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/auth/login",
});
await client.auth.oauthRedirect("github", { redirect: "manual" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/auth/oauth/github",
});
expect(calls.at(-1)?.init.redirect).toBe("manual");
await client.auth.oauthCallback("github", { code: "code", state: "state" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/auth/oauth/github/callback?code=code&state=state",
});
await client.auth.switchTeam({ team_id: "team_1" });
expectLastCall(calls, {
body: { team_id: "team_1" },
method: "POST",
url: "https://api.example.com/v1/auth/switch-team",
});
});
it("maps account, API key, user, and team endpoints", async () => {
const { calls } = setupFetch();
const client = new WrennClient({ baseUrl: "https://api.example.com" });
await client.account.getMe();
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/me",
});
await client.account.updateName({ name: "New Name" });
expectLastCall(calls, {
body: { name: "New Name" },
method: "PATCH",
url: "https://api.example.com/v1/me",
});
await client.account.deleteAccount({ confirmation: "a@example.com" });
expectLastCall(calls, {
body: { confirmation: "a@example.com" },
method: "DELETE",
url: "https://api.example.com/v1/me",
});
await client.account.changePassword({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/me/password",
});
await client.account.requestPasswordReset({ email: "a@example.com" });
expectLastCall(calls, {
body: { email: "a@example.com" },
method: "POST",
url: "https://api.example.com/v1/me/password/reset",
});
await client.account.confirmPasswordReset({
new_password: "password",
token: "token",
});
expectLastCall(calls, {
body: { new_password: "password", token: "token" },
method: "POST",
url: "https://api.example.com/v1/me/password/reset/confirm",
});
await client.account.connectProvider("github");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/me/providers/github/connect",
});
await client.account.disconnectProvider("github");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/me/providers/github",
});
await client.apiKeys.list();
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/api-keys",
});
await client.apiKeys.create({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/api-keys",
});
await client.apiKeys.delete("key/1");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/api-keys/key%2F1",
});
await client.users.search({ email: "alice@" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/users/search?email=alice%40",
});
await client.teams.list();
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/teams",
});
await client.teams.create({ name: "Team" });
expectLastCall(calls, {
body: { name: "Team" },
method: "POST",
url: "https://api.example.com/v1/teams",
});
await client.teams.get("team/1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/teams/team%2F1",
});
await client.teams.rename("team_1", { name: "New" });
expectLastCall(calls, {
body: { name: "New" },
method: "PATCH",
url: "https://api.example.com/v1/teams/team_1",
});
await client.teams.delete("team_1");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/teams/team_1",
});
await client.teams.listMembers("team_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/teams/team_1/members",
});
await client.teams.addMember("team_1", { email: "a@example.com" });
expectLastCall(calls, {
body: { email: "a@example.com" },
method: "POST",
url: "https://api.example.com/v1/teams/team_1/members",
});
await client.teams.updateMemberRole("team_1", "user_1", { role: "admin" });
expectLastCall(calls, {
body: { role: "admin" },
method: "PATCH",
url: "https://api.example.com/v1/teams/team_1/members/user_1",
});
await client.teams.removeMember("team_1", "user_1");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/teams/team_1/members/user_1",
});
await client.teams.leave("team_1");
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/teams/team_1/leave",
});
});
it("maps capsule, file, and snapshot endpoints", async () => {
const { calls } = setupFetch();
const client = new WrennClient({ baseUrl: "https://api.example.com" });
await client.capsules.create({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules",
});
await client.capsules.list();
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/capsules",
});
await client.capsules.get("cap_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/capsules/cap_1",
});
await client.capsules.destroy("cap_1");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/capsules/cap_1",
});
await client.capsules.exec("cap_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/exec",
});
await client.capsules.listProcesses("cap_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/capsules/cap_1/processes",
});
await client.capsules.killProcess("cap_1", "pid/1", { signal: "SIGTERM" });
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/capsules/cap_1/processes/pid%2F1?signal=SIGTERM",
});
await client.capsules.ping("cap_1");
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/ping",
});
await client.capsules.metrics("cap_1", { range: "10m" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/capsules/cap_1/metrics?range=10m",
});
await client.capsules.pause("cap_1");
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/pause",
});
await client.capsules.resume("cap_1");
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/resume",
});
await client.capsules.stats({ range: "1h" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/capsules/stats?range=1h",
});
await client.capsules.usage({ from: "2026-01-01", to: "2026-01-02" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/capsules/usage?from=2026-01-01&to=2026-01-02",
});
await client.files.upload("cap_1", { file: "hello", path: "/tmp/a.txt" });
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/write",
});
expect(calls.at(-1)?.init.body).toBeInstanceOf(FormData);
expect((calls.at(-1)?.init.body as FormData).get("file")).toBeInstanceOf(
Blob,
);
await client.files.download("cap_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/read",
});
await client.files.list("cap_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/list",
});
await client.files.mkdir("cap_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/mkdir",
});
await client.files.remove("cap_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/remove",
});
await client.files.streamUpload("cap_1", {
file: "hello",
path: "/tmp/a.txt",
});
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/stream/write",
});
await client.files.streamDownload("cap_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/capsules/cap_1/files/stream/read",
});
await client.snapshots.create({} as never, { overwrite: "true" });
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/snapshots?overwrite=true",
});
await client.snapshots.list({ type: "base" });
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/snapshots?type=base",
});
await client.snapshots.delete("snap/1");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/snapshots/snap%2F1",
});
});
it("maps host and channel endpoints", async () => {
const { calls } = setupFetch();
const client = new WrennClient({ baseUrl: "https://api.example.com" });
await client.hosts.create({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/hosts",
});
await client.hosts.list();
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/hosts",
});
await client.hosts.get("host_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/hosts/host_1",
});
await client.hosts.delete("host_1", { force: true });
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/hosts/host_1?force=true",
});
await client.hosts.regenerateToken("host_1");
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/hosts/host_1/token",
});
await client.hosts.register({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/hosts/register",
});
await client.hosts.heartbeat("host_1");
expectLastCall(calls, {
method: "POST",
url: "https://api.example.com/v1/hosts/host_1/heartbeat",
});
await client.hosts.refreshToken({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/hosts/auth/refresh",
});
await client.hosts.deletePreview("host_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/hosts/host_1/delete-preview",
});
await client.hosts.listTags("host_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/hosts/host_1/tags",
});
await client.hosts.addTag("host_1", {} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/hosts/host_1/tags",
});
await client.hosts.removeTag("host_1", "gpu/a");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/hosts/host_1/tags/gpu%2Fa",
});
await client.channels.create({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/channels",
});
await client.channels.list();
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/channels",
});
await client.channels.test({} as never);
expectLastCall(calls, {
body: {},
method: "POST",
url: "https://api.example.com/v1/channels/test",
});
await client.channels.get("channel_1");
expectLastCall(calls, {
method: "GET",
url: "https://api.example.com/v1/channels/channel_1",
});
await client.channels.update("channel_1", {} as never);
expectLastCall(calls, {
body: {},
method: "PATCH",
url: "https://api.example.com/v1/channels/channel_1",
});
await client.channels.delete("channel_1");
expectLastCall(calls, {
method: "DELETE",
url: "https://api.example.com/v1/channels/channel_1",
});
await client.channels.rotateConfig("channel_1", {} as never);
expectLastCall(calls, {
body: {},
method: "PUT",
url: "https://api.example.com/v1/channels/channel_1/config",
});
});
});

View File

@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Capsule } from "../src/capsule.js";
import { CodeInterpreter } from "../src/code-interpreter/index.js";
describe("CodeInterpreter", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("creates a capsule with the jupyter template by default", async () => {
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
new Capsule("cap_1", {
baseUrl: "https://api.example.com",
}),
);
const interpreter = await CodeInterpreter.create({
baseUrl: "https://api.example.com",
});
expect(interpreter.capsule.id).toBe("cap_1");
expect(create).toHaveBeenCalledWith("jupyter", {
baseUrl: "https://api.example.com",
});
});
it("connects to an existing capsule", () => {
const interpreter = CodeInterpreter.connect("cap_1", {
baseUrl: "https://api.example.com",
});
expect(interpreter.capsule.id).toBe("cap_1");
});
it("executes notebook cells through python3", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stderr: "",
stdout: "42\n",
});
const interpreter = new CodeInterpreter(capsule);
await expect(
interpreter.notebook.execCell("print(6 * 7)"),
).resolves.toEqual({
exitCode: 0,
stderr: "",
stdout: "42\n",
});
expect(exec).toHaveBeenCalledWith("python3", {
args: ["-c", "print(6 * 7)"],
timeoutSec: 30,
});
});
it("returns stderr and non-zero exit code on failure", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 1,
stderr: "SyntaxError: invalid syntax\n",
stdout: "",
});
const interpreter = new CodeInterpreter(capsule);
await expect(interpreter.notebook.execCell("invalid(")).resolves.toEqual({
exitCode: 1,
stderr: "SyntaxError: invalid syntax\n",
stdout: "",
});
});
it("disposes the wrapped capsule", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const dispose = vi.spyOn(capsule, Symbol.asyncDispose).mockResolvedValue();
const interpreter = new CodeInterpreter(capsule);
await interpreter[Symbol.asyncDispose]();
expect(dispose).toHaveBeenCalledOnce();
});
});

160
tests/commands.test.ts Normal file
View File

@ -0,0 +1,160 @@
import { describe, expect, it, vi } from "vitest";
import { Capsule } from "../src/capsule.js";
describe("CommandManager", () => {
it("executes foreground commands through the capsule client", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.client.capsules, "exec").mockResolvedValue({
exit_code: 0,
stdout: "ok\n",
});
await expect(
capsule.commands.exec("node", {
args: ["--version"],
timeoutSec: 5,
}),
).resolves.toMatchObject({ stdout: "ok\n" });
expect(exec).toHaveBeenCalledWith("cap_1", {
args: ["--version"],
cmd: "node",
timeout_sec: 5,
});
});
it("starts, lists, and kills background processes", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.client.capsules, "exec").mockResolvedValue({
pid: 123,
tag: "worker",
});
const listProcesses = vi
.spyOn(capsule.client.capsules, "listProcesses")
.mockResolvedValue({ processes: [{ pid: 123, tag: "worker" }] });
const killProcess = vi
.spyOn(capsule.client.capsules, "killProcess")
.mockResolvedValue(undefined);
await expect(
capsule.commands.start("sleep", {
args: ["60"],
cwd: "/tmp",
envs: { A: "1" },
tag: "worker",
}),
).resolves.toMatchObject({ tag: "worker" });
await expect(capsule.commands.list()).resolves.toMatchObject({
processes: [{ tag: "worker" }],
});
await expect(
capsule.commands.kill("worker", "SIGTERM"),
).resolves.toBeUndefined();
expect(exec).toHaveBeenCalledWith("cap_1", {
args: ["60"],
background: true,
cmd: "sleep",
cwd: "/tmp",
envs: { A: "1" },
tag: "worker",
timeout_sec: 30,
});
expect(listProcesses).toHaveBeenCalledWith("cap_1");
expect(killProcess).toHaveBeenCalledWith("cap_1", "worker", {
signal: "SIGTERM",
});
});
it("connects to an existing background process stream", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const mockConnection = {
close: vi.fn(),
get isClosed() {
return false;
},
send: vi.fn(),
};
const connectProcess = vi
.spyOn(capsule.client.capsules, "connectProcess")
.mockResolvedValue(mockConnection as never);
const result = await capsule.commands.streamProcess("123");
expect(connectProcess).toHaveBeenCalledWith("cap_1", "123", {
onMessage: expect.any(Function),
});
expect(result).toBe(mockConnection);
});
it("passes timeout to streamProcess", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const connectProcess = vi
.spyOn(capsule.client.capsules, "connectProcess")
.mockResolvedValue({
close: vi.fn(),
get isClosed() {
return false;
},
send: vi.fn(),
} as never);
await capsule.commands.streamProcess("worker", { timeoutMs: 5000 });
expect(connectProcess).toHaveBeenCalledWith("cap_1", "worker", {
onMessage: expect.any(Function),
timeoutMs: 5000,
});
});
it("streams command events over the exec WebSocket", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const sent: unknown[] = [];
let onMessage: ((message: unknown) => void) | undefined;
vi.spyOn(capsule.client.capsules, "execStream").mockImplementation(
async (_id, opts) => {
onMessage = opts.onMessage;
return {
close: vi.fn(),
get isClosed() {
return false;
},
send: (message: unknown) => sent.push(message),
} as never;
},
);
const events = capsule.commands.stream("printf", { args: ["hello"] });
const first = events.next();
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent).toEqual([{ args: ["hello"], cmd: "printf", type: "start" }]);
onMessage?.({ data: "hello", type: "stdout" });
await expect(first).resolves.toEqual({
done: false,
value: { data: "hello", type: "stdout" },
});
const done = events.next();
onMessage?.({ exit_code: 0, type: "exit" });
await expect(done).resolves.toEqual({
done: false,
value: { exit_code: 0, type: "exit" },
});
await expect(events.next()).resolves.toEqual({
done: true,
value: undefined,
});
});
});

92
tests/files.test.ts Normal file
View File

@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import { Capsule } from "../src/capsule.js";
function streamFrom(text: string): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(Buffer.from(text));
controller.close();
},
});
}
describe("FileManager", () => {
it("reads and writes files through the capsule file client", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const download = vi
.spyOn(capsule.client.files, "download")
.mockResolvedValue(streamFrom("hello"));
const upload = vi
.spyOn(capsule.client.files, "upload")
.mockResolvedValue(undefined);
await expect(capsule.files.read("/tmp/a.txt")).resolves.toEqual(
Buffer.from("hello"),
);
await expect(
capsule.files.write("/tmp/a.txt", "hello"),
).resolves.toBeUndefined();
expect(download).toHaveBeenCalledWith("cap_1", { path: "/tmp/a.txt" });
expect(upload).toHaveBeenCalledWith("cap_1", {
file: "hello",
path: "/tmp/a.txt",
});
});
it("maps directory operations", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const list = vi.spyOn(capsule.client.files, "list").mockResolvedValue({
entries: [{ name: "a.txt", path: "/tmp/a.txt", type: "file" }],
});
const mkdir = vi.spyOn(capsule.client.files, "mkdir").mockResolvedValue({
entry: { path: "/tmp/new", type: "directory" },
});
const remove = vi
.spyOn(capsule.client.files, "remove")
.mockResolvedValue(undefined);
await expect(
capsule.files.list("/tmp", { depth: 2 }),
).resolves.toMatchObject({
entries: [{ name: "a.txt" }],
});
await expect(capsule.files.mkdir("/tmp/new")).resolves.toMatchObject({
entry: { type: "directory" },
});
await expect(capsule.files.remove("/tmp/a.txt")).resolves.toBeUndefined();
expect(list).toHaveBeenCalledWith("cap_1", { depth: 2, path: "/tmp" });
expect(mkdir).toHaveBeenCalledWith("cap_1", { path: "/tmp/new" });
expect(remove).toHaveBeenCalledWith("cap_1", { path: "/tmp/a.txt" });
});
it("streams downloads as Buffer chunks and uploads streaming content", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
vi.spyOn(capsule.client.files, "streamDownload").mockResolvedValue(
streamFrom("hello"),
);
const streamUpload = vi
.spyOn(capsule.client.files, "streamUpload")
.mockResolvedValue(undefined);
const chunks: Buffer[] = [];
for await (const chunk of capsule.files.downloadStream("/tmp/a.txt")) {
chunks.push(chunk);
}
await capsule.files.uploadStream("/tmp/a.txt", Buffer.from("hello"));
expect(Buffer.concat(chunks).toString()).toBe("hello");
expect(streamUpload).toHaveBeenCalledWith("cap_1", {
file: expect.any(Blob),
path: "/tmp/a.txt",
});
});
});

374
tests/foundation.test.ts Normal file
View File

@ -0,0 +1,374 @@
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { AsyncQueue } from "../src/_shared/async-queue.js";
import { HttpClient } from "../src/_shared/http.js";
import { WsConnection } from "../src/_shared/websocket.js";
import { resolveConfig } from "../src/config.js";
import {
AuthenticationError,
BadRequestError,
ConflictError,
ForbiddenError,
HostHasCapsulesError,
NotFoundError,
PayloadTooLargeError,
ServerError,
TimeoutError,
throwErrorFromResponse,
WrennError,
} from "../src/exceptions.js";
describe("resolveConfig", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("uses defaults when no options or environment variables are set", () => {
vi.stubEnv("WRENN_BASE_URL", undefined);
vi.stubEnv("WRENN_API_KEY", undefined);
vi.stubEnv("WRENN_TOKEN", undefined);
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
expect(resolveConfig()).toEqual({ baseUrl: "https://app.wrenn.dev/api" });
});
it("prefers explicit options over environment variables", () => {
vi.stubEnv("WRENN_BASE_URL", "https://env.example.com");
vi.stubEnv("WRENN_API_KEY", "env-api-key");
vi.stubEnv("WRENN_TOKEN", "env-token");
vi.stubEnv("WRENN_HOST_TOKEN", "env-host-token");
expect(
resolveConfig({
baseUrl: "https://opts.example.com",
apiKey: "opts-api-key",
token: "opts-token",
hostToken: "opts-host-token",
}),
).toEqual({
baseUrl: "https://opts.example.com",
apiKey: "opts-api-key",
token: "opts-token",
hostToken: "opts-host-token",
});
});
});
describe("throwErrorFromResponse", () => {
it.each([
[400, BadRequestError],
[401, AuthenticationError],
[403, ForbiddenError],
[404, NotFoundError],
[408, TimeoutError],
[409, ConflictError],
[413, PayloadTooLargeError],
[500, ServerError],
])("maps HTTP %i responses", async (status, ErrorClass) => {
const response = Response.json(
{ error: { code: "test_error", message: "Test failure" } },
{ status, statusText: "Failed" },
);
await expect(throwErrorFromResponse(response)).rejects.toMatchObject({
name: ErrorClass.name,
statusCode: status,
code: "test_error",
message: "Test failure",
});
});
it("maps host conflict responses with capsule IDs", async () => {
const response = Response.json(
{
error: {
code: "host_has_capsules",
message: "Host has capsules",
sandbox_ids: ["cap_1", "cap_2"],
},
},
{ status: 409, statusText: "Conflict" },
);
const error = throwErrorFromResponse(response);
await expect(error).rejects.toBeInstanceOf(HostHasCapsulesError);
await expect(error).rejects.toMatchObject({
name: "HostHasCapsulesError",
statusCode: 409,
sandboxIds: ["cap_1", "cap_2"],
});
});
it("falls back to WrennError for non-JSON error bodies", async () => {
const response = new Response("not json", {
status: 418,
statusText: "I'm a teapot",
});
await expect(throwErrorFromResponse(response)).rejects.toBeInstanceOf(
WrennError,
);
});
});
describe("HttpClient", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("sends default auth headers and query params", async () => {
const fetchMock = vi.fn(async () => Response.json({ ok: true }));
vi.stubGlobal("fetch", fetchMock);
const client = new HttpClient({
baseUrl: "https://api.example.com/",
apiKey: "api-key",
token: "jwt-token",
hostToken: "host-token",
});
await expect(
client.get<{ ok: boolean }>("/v1/test", {
params: { a: "one", b: 2, c: true, skipped: undefined },
}),
).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/v1/test?a=one&b=2&c=true",
expect.objectContaining({
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer jwt-token",
"X-API-Key": "api-key",
"X-Host-Token": "host-token",
},
}),
);
});
it("omits content type for bodyless requests", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
await client.delete("/v1/test");
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/v1/test",
expect.objectContaining({
headers: { Accept: "application/json" },
method: "DELETE",
}),
);
});
it("maps unsuccessful responses to SDK errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json(
{ error: { code: "missing", message: "Not found" } },
{ status: 404 },
),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
await expect(client.get("/v1/missing")).rejects.toBeInstanceOf(
NotFoundError,
);
});
it("throws TimeoutError when timeoutMs aborts a request", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const request = client.get("/v1/slow", { timeoutMs: 100 });
const assertion = expect(request).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(100);
await assertion;
});
it("applies timeoutMs to multipart uploads", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const upload = client.upload("/v1/upload", new FormData(), {
timeoutMs: 100,
});
const assertion = expect(upload).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(100);
await assertion;
});
it("downloads binary response bodies as ReadableStream", async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body, { status: 200 })),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const stream = await client.download("/v1/file", { path: "/a.txt" });
expect(stream).toBeInstanceOf(ReadableStream);
const reader = stream.getReader();
const { value } = await reader.read();
expect(value).toEqual(new Uint8Array([1, 2, 3]));
});
it("handles content-length zero as empty response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(null, {
status: 200,
headers: { "content-length": "0" },
}),
),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const result = await client.get("/v1/empty");
expect(result).toBeUndefined();
});
it("handles empty text body as undefined", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("", { status: 200 })),
);
const client = new HttpClient({ baseUrl: "https://api.example.com" });
const result = await client.get("/v1/blank");
expect(result).toBeUndefined();
});
});
describe("WsConnection", () => {
it("connects, sends JSON messages, and receives parsed messages", async () => {
const server = new WebSocketServer({ port: 0 });
const messages: unknown[] = [];
const receivedByServer = new Promise<unknown>((resolve) => {
server.on("connection", (socket, request) => {
expect(request.headers["x-api-key"]).toBe("api-key");
expect(request.headers["x-host-token"]).toBe("host-token");
socket.on("message", (raw) => resolve(JSON.parse(raw.toString())));
socket.send(JSON.stringify({ type: "ready" }));
});
});
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address() as AddressInfo;
const connection = await WsConnection.connect({
baseUrl: `http://127.0.0.1:${address.port}`,
path: "/stream",
apiKey: "api-key",
hostToken: "host-token",
onMessage: (message) => messages.push(message),
});
connection.send({ type: "start" });
await expect(receivedByServer).resolves.toEqual({ type: "start" });
expect(messages).toEqual([{ type: "ready" }]);
await connection[Symbol.asyncDispose]();
expect(connection.isClosed).toBe(true);
server.close();
});
it("rejects with TimeoutError if the connection does not open in time", async () => {
vi.useFakeTimers();
const server = new WebSocketServer({ noServer: true });
const connection = WsConnection.connect({
baseUrl: "http://127.0.0.1:9",
path: "/stream",
timeoutMs: 100,
onMessage: () => undefined,
});
const assertion = expect(connection).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(100);
await assertion;
server.close();
vi.useRealTimers();
});
});
describe("AsyncQueue", () => {
it("rejects waiting consumers when failed", async () => {
const queue = new AsyncQueue<string>();
const pending = queue.next();
queue.fail(new Error("socket died"));
await expect(pending).rejects.toThrow("socket died");
await expect(queue.next()).rejects.toThrow("socket died");
});
it("ends the queue from return and resolves as done", async () => {
const queue = new AsyncQueue<string>();
const result = await queue.return();
expect(result).toEqual({ done: true, value: undefined });
await expect(queue.next()).resolves.toEqual({
done: true,
value: undefined,
});
});
it("propagates error through throw and rejects future reads", async () => {
const queue = new AsyncQueue<string>();
const error = new Error("consumer threw");
const result = queue.throw(error);
await expect(result).rejects.toThrow("consumer threw");
await expect(queue.next()).rejects.toThrow("consumer threw");
});
});

106
tests/git.test.ts Normal file
View File

@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import { Capsule } from "../src/capsule.js";
import { Git } from "../src/git/index.js";
describe("Git", () => {
it("runs clone with optional path and branch", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stdout: "",
});
await expect(
capsule.git.clone("https://example.com/repo.git", {
branch: "main",
path: "/work/repo",
}),
).resolves.toMatchObject({ exit_code: 0 });
expect(exec).toHaveBeenCalledWith("git", {
args: [
"clone",
"--branch",
"main",
"https://example.com/repo.git",
"/work/repo",
],
});
});
it("runs repository commands in cwd", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const git = new Git(capsule, { cwd: "/work/repo" });
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stdout: "main\n",
});
await git.status();
await git.pull({ branch: "main", remote: "origin" });
await git.push({ branch: "main", remote: "origin" });
await git.log({ maxCount: 3 });
await git.branch();
await git.checkout("feature", { create: true });
await git.add(["src/a.ts", "src/b.ts"]);
await git.commit("test commit");
expect(exec.mock.calls).toEqual([
["git", { args: ["status", "--short"], cwd: "/work/repo" }],
["git", { args: ["pull", "origin", "main"], cwd: "/work/repo" }],
["git", { args: ["push", "origin", "main"], cwd: "/work/repo" }],
["git", { args: ["log", "--oneline", "-n", "3"], cwd: "/work/repo" }],
["git", { args: ["branch", "--show-current"], cwd: "/work/repo" }],
["git", { args: ["checkout", "-b", "feature"], cwd: "/work/repo" }],
["git", { args: ["add", "src/a.ts", "src/b.ts"], cwd: "/work/repo" }],
["git", { args: ["commit", "-m", "test commit"], cwd: "/work/repo" }],
]);
});
it("rejects empty git add file lists", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.git.add([])).rejects.toThrow(
"At least one file is required",
);
});
it("checks out an existing branch without creating it", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stdout: "",
});
await capsule.git.checkout("main");
expect(exec).toHaveBeenCalledWith("git", {
args: ["checkout", "main"],
});
});
it("normalizes a single string file argument in add", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
exit_code: 0,
stdout: "",
});
await capsule.git.add("src/a.ts");
expect(exec).toHaveBeenCalledWith("git", {
args: ["add", "src/a.ts"],
});
});
});

43
tests/index.test.ts Normal file
View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { components, operations, paths } from "../src/index.js";
import * as sdk from "../src/index.js";
type CapsuleSchema = components["schemas"]["Capsule"];
type GetCapsuleOperation = operations["getCapsule"];
type CapsulePath = paths["/v1/capsules/{id}"];
function acceptsGeneratedTypes(
_capsule: CapsuleSchema,
_operation: GetCapsuleOperation,
_path: CapsulePath,
): void {}
describe("public entry point", () => {
it("exports the supported runtime API from the package root", () => {
expect(sdk.Capsule).toBeTypeOf("function");
expect(sdk.Sandbox).toBe(sdk.Capsule);
expect(sdk.WrennClient).toBeTypeOf("function");
expect(sdk.CodeInterpreter).toBeTypeOf("function");
expect(sdk.Notebook).toBeTypeOf("function");
expect(sdk.CommandManager).toBeTypeOf("function");
expect(sdk.FileManager).toBeTypeOf("function");
expect(sdk.Git).toBeTypeOf("function");
expect(sdk.PtyManager).toBeTypeOf("function");
expect(sdk.PtySession).toBeTypeOf("function");
expect(sdk.WrennError).toBeTypeOf("function");
expect(sdk.NotFoundError).toBeTypeOf("function");
});
it("keeps internal shared helpers out of the public runtime API", () => {
expect("HttpClient" in sdk).toBe(false);
expect("WsConnection" in sdk).toBe(false);
});
it("exposes generated OpenAPI types from the package root", () => {
acceptsGeneratedTypes(
{} as CapsuleSchema,
{} as GetCapsuleOperation,
{} as CapsulePath,
);
});
});

View File

@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
import type { CommandStreamEvent } from "../../src/commands.js";
import { DEFAULT_BASE_URL } from "../../src/config.js";
import type { PtyEvent } from "../../src/pty.js";
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
const apiKey = process.env.WRENN_API_KEY;
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
const testTimeoutMs = waitTimeoutMs + 45_000;
const describeWithApiKey = apiKey ? describe : describe.skip;
const clientOpts: CapsuleCreateOptions = { baseUrl };
if (apiKey) clientOpts.apiKey = apiKey;
async function withLiveCapsule(
fn: (capsule: Capsule) => Promise<void>,
): Promise<void> {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 120,
});
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
await fn(capsule);
} finally {
if (capsule) {
await capsule.destroy().catch(() => undefined);
}
}
}
async function collectCommandEvents(
events: AsyncGenerator<CommandStreamEvent>,
): Promise<CommandStreamEvent[]> {
const collected: CommandStreamEvent[] = [];
for await (const event of events) {
collected.push(event);
if (event.type === "exit" || event.type === "error") break;
}
return collected;
}
async function nextPtyEvent(
events: AsyncIterableIterator<PtyEvent>,
): Promise<PtyEvent> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Timed out waiting for PTY event")),
15_000,
);
});
const result = await Promise.race([events.next(), timeout]);
if (result.done) throw new Error("PTY closed before next event");
return result.value;
}
describeWithApiKey("Phase 4 live integration", () => {
it(
"executes foreground, streaming, and background commands",
async () => {
await withLiveCapsule(async (capsule) => {
const result = await capsule.commands.exec("printf", {
args: ["phase4-command"],
timeoutSec: 10,
});
expect(result.exit_code).toBe(0);
expect(result.stdout).toContain("phase4-command");
const streamEvents = await collectCommandEvents(
capsule.commands.stream("printf", { args: ["phase4-stream"] }),
);
expect(streamEvents.some((event) => event.type === "stdout")).toBe(
true,
);
expect(streamEvents.at(-1)).toMatchObject({ type: "exit" });
const tag = `phase4-${Date.now()}`;
const process = await capsule.commands.start("sleep", {
args: ["60"],
tag,
});
expect(process.tag).toBe(tag);
const processes = await capsule.commands.list();
expect(processes.processes?.some((entry) => entry.tag === tag)).toBe(
true,
);
await expect(
capsule.commands.kill(tag, "SIGTERM"),
).resolves.toBeUndefined();
});
},
testTimeoutMs,
);
it(
"performs file read/write/list/remove and streaming transfers",
async () => {
await withLiveCapsule(async (capsule) => {
const dir = `/tmp/wrenn-phase4-${Date.now()}`;
const file = `${dir}/hello.txt`;
const streamFile = `${dir}/stream.txt`;
await capsule.files.mkdir(dir);
await capsule.files.write(file, "phase4-file");
const content = await capsule.files.read(file);
expect(content.toString()).toBe("phase4-file");
const listing = await capsule.files.list(dir, { depth: 1 });
expect(
listing.entries?.some((entry) => entry.name === "hello.txt"),
).toBe(true);
await capsule.files.uploadStream(
streamFile,
Buffer.from("phase4-stream-file"),
);
const chunks: Buffer[] = [];
for await (const chunk of capsule.files.downloadStream(streamFile)) {
chunks.push(chunk);
}
expect(Buffer.concat(chunks).toString()).toBe("phase4-stream-file");
await expect(capsule.files.remove(file)).resolves.toBeUndefined();
await expect(capsule.files.remove(streamFile)).resolves.toBeUndefined();
await expect(capsule.files.remove(dir)).resolves.toBeUndefined();
});
},
testTimeoutMs,
);
it(
"starts and controls an interactive PTY session",
async () => {
await withLiveCapsule(async (capsule) => {
const session = await capsule.pty.start({
cmd: "/bin/sh",
cols: 80,
rows: 24,
});
const started = await nextPtyEvent(session.events);
expect(started.type).toBe("started");
session.resize(100, 30);
session.input("printf phase4-pty\\n\nexit\n");
const events: PtyEvent[] = [];
for (let i = 0; i < 10; i += 1) {
const event = await nextPtyEvent(session.events);
events.push(event);
if (event.type === "exit" || event.type === "error") break;
}
const output = events
.filter(
(event) =>
event.type === "output" && typeof event.data === "string",
)
.map((event) =>
Buffer.from(event.data as string, "base64").toString(),
)
.join("");
expect(output).toContain("phase4-pty");
expect(events.at(-1)).toMatchObject({ type: "exit" });
session.close();
});
},
testTimeoutMs,
);
});

View File

@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
import { DEFAULT_BASE_URL } from "../../src/config.js";
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
const apiKey = process.env.WRENN_API_KEY;
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
const describeWithApiKey = apiKey ? describe : describe.skip;
const clientOpts: CapsuleCreateOptions = { baseUrl };
if (apiKey) clientOpts.apiKey = apiKey;
describeWithApiKey("Capsule live integration", () => {
it(
"creates, waits for, inspects, pings, and destroys a live capsule",
async () => {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
expect(capsule.id).toBeTypeOf("string");
expect(capsule.id.length).toBeGreaterThan(0);
const ready = await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
expect(ready).toMatchObject({ id: capsule.id, status: "running" });
const connected = Capsule.connect(capsule.id, clientOpts);
const info = await connected.getInfo();
expect(info.id).toBe(capsule.id);
await expect(connected.ping()).resolves.toBeUndefined();
const metrics = await connected.getMetrics({ range: "10m" });
expect(metrics).toBeTypeOf("object");
await connected.close();
await connected[Symbol.asyncDispose]();
await Capsule.destroy(capsule.id, clientOpts);
capsule = undefined;
} finally {
if (capsule) {
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs + 30_000,
);
it(
"pauses and resumes a live capsule through high-level methods",
async () => {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
const paused = await capsule.pause();
expect(paused).toMatchObject({ id: capsule.id });
expect(paused.status).toBe("paused");
const resumed = await capsule.resume({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
wait: true,
});
expect(resumed).toMatchObject({ id: capsule.id, status: "running" });
} finally {
if (capsule) {
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs * 2 + 30_000,
);
it(
"destroys an owned live capsule when async disposed",
async () => {
let capsuleId: string | undefined;
try {
const capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
capsuleId = capsule.id;
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
await capsule[Symbol.asyncDispose]();
await expect(
Capsule.connect(capsuleId, clientOpts).getInfo(),
).resolves.toMatchObject({ id: capsuleId, status: "stopped" });
capsuleId = undefined;
} finally {
if (capsuleId) {
await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs + 30_000,
);
});

View File

@ -0,0 +1,90 @@
import { beforeAll, describe, expect, it } from "vitest";
import { WrennClient } from "../../src/client.js";
import { type ClientConfig, DEFAULT_BASE_URL } from "../../src/config.js";
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
const apiKey = process.env.WRENN_API_KEY;
const testEmail = process.env.WRENN_TEST_EMAIL;
const testPassword = process.env.WRENN_TEST_PASS;
const describeWithApiKey = apiKey ? describe : describe.skip;
const describeWithLogin = testEmail && testPassword ? describe : describe.skip;
const apiKeyClientOpts: ClientConfig = { baseUrl };
if (apiKey) apiKeyClientOpts.apiKey = apiKey;
describeWithApiKey("WrennClient live API key integration", () => {
const client = new WrennClient(apiKeyClientOpts);
it("lists capsules from the real Wrenn API", async () => {
const capsules = await client.capsules.list();
expect(Array.isArray(capsules)).toBe(true);
});
it("lists snapshot templates from the real Wrenn API", async () => {
const snapshots = await client.snapshots.list();
expect(Array.isArray(snapshots)).toBe(true);
});
it("gets capsule stats from the real Wrenn API", async () => {
const stats = await client.capsules.stats({ range: "1h" });
expect(stats).toBeTypeOf("object");
});
it("gets capsule usage from the real Wrenn API", async () => {
const usage = await client.capsules.usage();
expect(usage).toBeTypeOf("object");
});
});
describeWithLogin("WrennClient live login integration", () => {
let client: WrennClient;
beforeAll(async () => {
const authClient = new WrennClient({ baseUrl });
const auth = await authClient.auth.login({
email: testEmail as string,
password: testPassword as string,
});
expect(auth.token).toBeTypeOf("string");
const token = auth.token;
if (!token) throw new Error("Login response did not include a token");
client = new WrennClient({ baseUrl, token });
});
it("gets the current account profile from the real Wrenn API", async () => {
const me = await client.account.getMe();
expect(me).toBeTypeOf("object");
});
it("lists teams from the real Wrenn API", async () => {
const teams = await client.teams.list();
expect(Array.isArray(teams)).toBe(true);
});
it("lists API keys from the real Wrenn API", async () => {
const keys = await client.apiKeys.list();
expect(Array.isArray(keys)).toBe(true);
});
it("lists notification channels from the real Wrenn API", async () => {
const channels = await client.channels.list();
expect(Array.isArray(channels)).toBe(true);
});
it("lists hosts from the real Wrenn API", async () => {
const hosts = await client.hosts.list();
expect(Array.isArray(hosts)).toBe(true);
});
});

View File

@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
import { CodeInterpreter } from "../../src/code-interpreter/index.js";
import { DEFAULT_BASE_URL } from "../../src/config.js";
import { Git } from "../../src/git/index.js";
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
const apiKey = process.env.WRENN_API_KEY;
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
const codeTemplate = process.env.WRENN_TEST_CODE_TEMPLATE ?? "jupyter";
const codeCapsuleId = process.env.WRENN_TEST_CODE_CAPSULE_ID;
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
const testTimeoutMs = waitTimeoutMs + 60_000;
const describeWithApiKey = apiKey ? describe : describe.skip;
const clientOpts: CapsuleCreateOptions = { baseUrl };
if (apiKey) clientOpts.apiKey = apiKey;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableServerError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
typeof error.statusCode === "number" &&
error.statusCode >= 500
);
}
async function expectCommandOk(
promise: Promise<{ exit_code?: number; stderr?: string }>,
): Promise<void> {
const result = await promise;
expect(result.exit_code, result.stderr).toBe(0);
}
async function withLiveCapsule(
fn: (capsule: Capsule) => Promise<void>,
): Promise<void> {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 120,
});
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
await fn(capsule);
} finally {
if (capsule) {
await capsule.destroy().catch(() => undefined);
}
}
}
async function createCodeInterpreterWithRetry(): Promise<CodeInterpreter> {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await CodeInterpreter.create({
...clientOpts,
template: codeTemplate,
timeout_sec: 120,
});
} catch (error) {
lastError = error;
if (!isRetryableServerError(error) || attempt === 3) break;
await delay(2_000);
}
}
throw lastError;
}
async function getCodeInterpreter(): Promise<{
interpreter: CodeInterpreter;
shouldDestroy: boolean;
}> {
if (!codeCapsuleId) {
return {
interpreter: await createCodeInterpreterWithRetry(),
shouldDestroy: true,
};
}
const interpreter = CodeInterpreter.connect(codeCapsuleId, clientOpts);
const info = await interpreter.capsule.getInfo();
if (info.status === "paused") {
await interpreter.capsule.resume({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
wait: true,
});
} else {
await interpreter.capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
}
return { interpreter, shouldDestroy: false };
}
describeWithApiKey("Higher-level abstractions live integration", () => {
it(
"runs git workflows inside a live capsule",
async () => {
await withLiveCapsule(async (capsule) => {
const root = `/tmp/wrenn-git-${Date.now()}`;
const origin = `${root}/origin.git`;
const work = `${root}/work`;
await expectCommandOk(
capsule.commands.exec("mkdir", { args: ["-p", root] }),
);
await expectCommandOk(
capsule.commands.exec("git", { args: ["init", "--bare", origin] }),
);
await expectCommandOk(capsule.git.clone(origin, { path: work }));
const git = new Git(capsule, { cwd: work });
const pwd = await capsule.commands.exec("pwd", { cwd: work });
expect(pwd.stdout?.trim(), pwd.stderr).toBe(work);
await expectCommandOk(
capsule.commands.exec("git", {
args: ["config", "user.email", "integration@example.com"],
cwd: work,
}),
);
await expectCommandOk(
capsule.commands.exec("git", {
args: ["config", "user.name", "Integration Test"],
cwd: work,
}),
);
await expectCommandOk(
capsule.commands.exec("sh", {
args: ["-lc", "printf 'phase5-git\\n' > README.md"],
cwd: work,
}),
);
const dirty = await git.status();
expect(dirty.exit_code, dirty.stderr).toBe(0);
expect(dirty.stdout, dirty.stderr).toContain("README.md");
await expectCommandOk(git.add("README.md"));
const commit = await git.commit("initial commit");
expect(commit.exit_code, commit.stderr).toBe(0);
const log = await git.log({ maxCount: 1 });
expect(log.exit_code, log.stderr).toBe(0);
expect(log.stdout, log.stderr).toContain("initial commit");
await expectCommandOk(git.branch());
await expectCommandOk(git.checkout("feature", { create: true }));
const branch = await git.branch();
expect(branch.exit_code, branch.stderr).toBe(0);
expect(branch.stdout?.trim(), branch.stderr).toBe("feature");
await expectCommandOk(
git.push({ branch: "feature", remote: "origin" }),
);
await expectCommandOk(
git.pull({ branch: "feature", remote: "origin" }),
);
});
},
testTimeoutMs,
);
it(
"executes a code interpreter cell in a live capsule",
async () => {
let interpreter: CodeInterpreter | undefined;
let shouldDestroy = false;
try {
const codeInterpreter = await getCodeInterpreter();
interpreter = codeInterpreter.interpreter;
shouldDestroy = codeInterpreter.shouldDestroy;
const result = await interpreter.notebook.execCell("print(6 * 7)", {
timeoutSec: 15,
});
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("42");
expect(result.stderr).toBe("");
} finally {
if (interpreter && shouldDestroy) {
await interpreter.capsule.destroy().catch(() => undefined);
}
}
},
testTimeoutMs,
);
it(
"destroys an owned code interpreter capsule when async disposed",
async () => {
let capsuleId: string | undefined;
try {
const interpreter = await createCodeInterpreterWithRetry();
capsuleId = interpreter.capsule.id;
await interpreter.capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
await interpreter[Symbol.asyncDispose]();
await expect(
Capsule.connect(capsuleId, clientOpts).getInfo(),
).resolves.toMatchObject({ id: capsuleId, status: "stopped" });
capsuleId = undefined;
} finally {
if (capsuleId) {
await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined);
}
}
},
testTimeoutMs,
);
});

131
tests/pty.test.ts Normal file
View File

@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import { Capsule } from "../src/capsule.js";
describe("PtyManager", () => {
it("starts a PTY, sends controls, and yields events", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const sent: unknown[] = [];
let onMessage: ((message: unknown) => void) | undefined;
const close = vi.fn();
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
async (_id, opts) => {
onMessage = opts.onMessage;
return {
close,
get isClosed() {
return false;
},
send: (message: unknown) => sent.push(message),
} as never;
},
);
const session = await capsule.pty.start({
cmd: "/bin/sh",
cols: 100,
rows: 30,
});
expect(sent).toEqual([
{ cmd: "/bin/sh", cols: 100, rows: 30, type: "start" },
]);
session.input("ls\n");
session.resize(120, 40);
session.kill();
expect(sent.slice(1)).toEqual([
{ data: Buffer.from("ls\n").toString("base64"), type: "input" },
{ cols: 120, rows: 40, type: "resize" },
{ type: "kill" },
]);
const event = session.events.next();
onMessage?.({ data: Buffer.from("ok").toString("base64"), type: "output" });
await expect(event).resolves.toEqual({
done: false,
value: {
data: Buffer.from("ok").toString("base64"),
type: "output",
},
});
await session[Symbol.asyncDispose]();
expect(close).toHaveBeenCalledOnce();
});
it("connects to an existing PTY tag", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const sent: unknown[] = [];
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
close: vi.fn(),
get isClosed() {
return false;
},
send: (message: unknown) => sent.push(message),
} as never);
await capsule.pty.connect("pty-tag");
expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]);
});
it("closes the connection when close() is called directly", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const close = vi.fn();
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
close,
get isClosed() {
return false;
},
send: vi.fn(),
} as never);
const session = await capsule.pty.start();
session.close();
expect(close).toHaveBeenCalledOnce();
});
it("sends all PtyStartOptions fields in the start message", async () => {
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const sent: unknown[] = [];
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
close: vi.fn(),
get isClosed() {
return false;
},
send: (message: unknown) => sent.push(message),
} as never);
await capsule.pty.start({
cmd: "/bin/bash",
args: ["--login"],
cols: 120,
rows: 40,
envs: { TERM: "xterm-256color" },
cwd: "/home/user",
user: "user",
});
expect(sent).toEqual([
{
type: "start",
cmd: "/bin/bash",
args: ["--login"],
cols: 120,
rows: 40,
envs: { TERM: "xterm-256color" },
cwd: "/home/user",
user: "user",
},
]);
});
});

45
tsconfig.json Normal file
View File

@ -0,0 +1,45 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
"ignoreDeprecations": "6.0"
}
}

14
tsup.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
outExtension({ format }) {
return { js: format === "esm" ? ".js" : ".cjs" };
},
outDir: "dist",
clean: true,
sourcemap: true,
minify: false,
dts: { resolve: true },
});

View File

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
exclude: ["**/node_modules/**", "**/dist/**"],
hookTimeout: 10_000,
include: ["tests/integration/**/*.test.ts"],
testTimeout: 10_000,
},
});