Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 349b230913 | |||
| a69118aa2d | |||
| b35df41f08 | |||
| 1f74d48576 | |||
| 573506b4c5 | |||
| c6322d8601 | |||
| 8fb9753fde | |||
| 52282618bd | |||
| f1522eaa0b | |||
| 5b3f2741a3 | |||
| db7fccbaed |
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
106
AGENTS.md
Normal 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
29
Makefile
Normal 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
553
README.md
@ -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
3267
api/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
35
biome.json
Normal file
35
biome.json
Normal 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
538
bun.lock
Normal 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
44
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
98
src/_shared/async-queue.ts
Normal file
98
src/_shared/async-queue.ts
Normal 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
318
src/_shared/http.ts
Normal 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
145
src/_shared/websocket.ts
Normal 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
325
src/capsule.ts
Normal 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
1412
src/client.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
src/code-interpreter/index.ts
Normal file
101
src/code-interpreter/index.ts
Normal 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
209
src/commands.ts
Normal 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
62
src/config.ts
Normal 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
162
src/exceptions.ts
Normal 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
143
src/files.ts
Normal 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
170
src/git/index.ts
Normal 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
90
src/index.ts
Normal 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
4390
src/models/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
157
src/pty.ts
Normal file
157
src/pty.ts
Normal 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
303
tests/capsule.test.ts
Normal 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
495
tests/client.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
89
tests/code-interpreter.test.ts
Normal file
89
tests/code-interpreter.test.ts
Normal 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
160
tests/commands.test.ts
Normal 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
92
tests/files.test.ts
Normal 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
374
tests/foundation.test.ts
Normal 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
106
tests/git.test.ts
Normal 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
43
tests/index.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
181
tests/integration/capsule-features.integration.test.ts
Normal file
181
tests/integration/capsule-features.integration.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
125
tests/integration/capsule.integration.test.ts
Normal file
125
tests/integration/capsule.integration.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
90
tests/integration/client.integration.test.ts
Normal file
90
tests/integration/client.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
232
tests/integration/higher-level-abstractions.integration.test.ts
Normal file
232
tests/integration/higher-level-abstractions.integration.test.ts
Normal 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
131
tests/pty.test.ts
Normal 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
45
tsconfig.json
Normal 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
14
tsup.config.ts
Normal 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 },
|
||||
});
|
||||
11
vitest.integration.config.ts
Normal file
11
vitest.integration.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user