Compare commits
9 Commits
5b3f2741a3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 349b230913 | |||
| a69118aa2d | |||
| b35df41f08 | |||
| 1f74d48576 | |||
| 573506b4c5 | |||
| c6322d8601 | |||
| 8fb9753fde | |||
| 52282618bd | |||
| f1522eaa0b |
4
.gitignore
vendored
4
.gitignore
vendored
@ -137,4 +137,6 @@ dist
|
|||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
# AI agents
|
# AI agents
|
||||||
.opencode
|
.opencode*
|
||||||
|
# Added by code-review-graph
|
||||||
|
.code-review-graph/
|
||||||
|
|||||||
39
AGENTS.md
39
AGENTS.md
@ -65,3 +65,42 @@ Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.
|
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.
|
||||||
|
|||||||
12
Makefile
12
Makefile
@ -1,7 +1,7 @@
|
|||||||
# Makefile
|
# Makefile
|
||||||
.PHONY: generate lint test test-integration check build
|
.PHONY: generate lint test test-integration check build
|
||||||
|
|
||||||
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/feat/migrate-to-ch/internal/api/openapi.yaml"
|
||||||
SPEC_PATH = "api/openapi.yaml"
|
SPEC_PATH = "api/openapi.yaml"
|
||||||
|
|
||||||
generate:
|
generate:
|
||||||
@ -10,20 +10,20 @@ generate:
|
|||||||
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
|
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
|
||||||
@echo "Generating TypeScript types..."
|
@echo "Generating TypeScript types..."
|
||||||
mkdir -p src/models
|
mkdir -p src/models
|
||||||
pnpm generate
|
bun run generate
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
pnpm exec biome check .
|
bunx biome check .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pnpm vitest run --exclude tests/integration
|
bunx vitest run --exclude tests/integration
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
pnpm test:integration
|
bun run test:integration
|
||||||
|
|
||||||
check:
|
check:
|
||||||
$(MAKE) lint
|
$(MAKE) lint
|
||||||
$(MAKE) test
|
$(MAKE) test
|
||||||
|
|
||||||
build:
|
build:
|
||||||
pnpm build
|
bun run build
|
||||||
|
|||||||
554
README.md
554
README.md
@ -1 +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
|
||||||
|
|||||||
135
api/openapi.yaml
135
api/openapi.yaml
@ -1,8 +1,8 @@
|
|||||||
openapi: "3.1.0"
|
openapi: "3.1.0"
|
||||||
info:
|
info:
|
||||||
title: Wrenn API
|
title: Wrenn API
|
||||||
description: MicroVM-based code execution platform API.
|
description: AI agent execution platform API.
|
||||||
version: "0.1.4"
|
version: "0.2.0"
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8080
|
- url: http://localhost:8080
|
||||||
@ -866,8 +866,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateCapsuleRequest"
|
$ref: "#/components/schemas/CreateCapsuleRequest"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"202":
|
||||||
description: Capsule created
|
description: Capsule creation initiated (status will be "starting")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -988,8 +988,8 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"202":
|
||||||
description: Capsule destroyed
|
description: Capsule destruction initiated
|
||||||
|
|
||||||
/v1/capsules/{id}/exec:
|
/v1/capsules/{id}/exec:
|
||||||
parameters:
|
parameters:
|
||||||
@ -1260,8 +1260,8 @@ paths:
|
|||||||
destroys all running resources. The capsule exists only as files on
|
destroys all running resources. The capsule exists only as files on
|
||||||
disk and can be resumed later.
|
disk and can be resumed later.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Capsule paused (snapshot taken, resources released)
|
description: Capsule pause initiated (status will be "pausing")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -1289,11 +1289,11 @@ paths:
|
|||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
description: |
|
description: |
|
||||||
Restores a paused capsule from its snapshot using UFFD for lazy
|
Restores a paused capsule from its snapshot using UFFD for lazy
|
||||||
memory loading. Boots a fresh Firecracker process, sets up a new
|
memory loading. Boots a fresh Cloud Hypervisor process, sets up a new
|
||||||
network slot, and waits for envd to become ready.
|
network slot, and waits for envd to become ready.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Capsule resumed (new VM booted from snapshot)
|
description: Capsule resume initiated (status will be "resuming")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -2035,6 +2035,51 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/hosts/sandbox-events:
|
||||||
|
post:
|
||||||
|
summary: Sandbox lifecycle event callback
|
||||||
|
operationId: sandboxEventCallback
|
||||||
|
tags: [hosts]
|
||||||
|
security:
|
||||||
|
- hostTokenAuth: []
|
||||||
|
description: |
|
||||||
|
Receives autonomous lifecycle events from host agents (e.g. auto-pause
|
||||||
|
from the TTL reaper). The event is published to an internal Redis stream
|
||||||
|
for the control plane's event consumer to process.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [event, sandbox_id, host_id]
|
||||||
|
properties:
|
||||||
|
event:
|
||||||
|
type: string
|
||||||
|
enum: [sandbox.auto_paused]
|
||||||
|
sandbox_id:
|
||||||
|
type: string
|
||||||
|
host_id:
|
||||||
|
type: string
|
||||||
|
timestamp:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Event accepted
|
||||||
|
"400":
|
||||||
|
description: Invalid request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"403":
|
||||||
|
description: Host ID mismatch
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
/v1/hosts/auth/refresh:
|
/v1/hosts/auth/refresh:
|
||||||
post:
|
post:
|
||||||
summary: Refresh host JWT
|
summary: Refresh host JWT
|
||||||
@ -2346,7 +2391,63 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/admin/users/{id}/admin:
|
||||||
|
put:
|
||||||
|
summary: Grant or revoke platform admin
|
||||||
|
operationId: setUserAdmin
|
||||||
|
tags: [admin]
|
||||||
|
description: |
|
||||||
|
Sets the platform admin flag on a user. Cannot remove the last admin.
|
||||||
|
Requires platform admin access (JWT + is_admin).
|
||||||
|
The target user's JWT is not re-issued — their frontend will reflect the
|
||||||
|
change on next login or team switch.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "usr-a1b2c3d4"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [admin]
|
||||||
|
properties:
|
||||||
|
admin:
|
||||||
|
type: boolean
|
||||||
|
description: true to grant admin, false to revoke.
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Admin status updated
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"403":
|
||||||
|
description: Caller is not a platform admin
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Invalid request parameters
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
apiKeyAuth:
|
apiKeyAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@ -2366,14 +2467,6 @@ components:
|
|||||||
name: X-Host-Token
|
name: X-Host-Token
|
||||||
description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days.
|
description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days.
|
||||||
|
|
||||||
responses:
|
|
||||||
BadRequest:
|
|
||||||
description: Invalid request
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/Error"
|
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
SignupRequest:
|
SignupRequest:
|
||||||
type: object
|
type: object
|
||||||
@ -2552,7 +2645,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
|
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
|
||||||
template:
|
template:
|
||||||
type: string
|
type: string
|
||||||
vcpus:
|
vcpus:
|
||||||
@ -3019,7 +3112,7 @@ components:
|
|||||||
mem_bytes:
|
mem_bytes:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: "Resident memory in bytes (VmRSS of Firecracker process)"
|
description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||||
disk_bytes:
|
disk_bytes:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|||||||
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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,7 +26,7 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.26.1",
|
"packageManager": "bun@1.3.14",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
|||||||
2321
pnpm-lock.yaml
generated
2321
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { TimeoutError, throwErrorFromResponse } from "../exceptions.js";
|
import { TimeoutError, throwErrorFromResponse } from "../exceptions.js";
|
||||||
|
|
||||||
|
/** Configuration for the low-level HTTP client. */
|
||||||
export interface HttpClientConfig {
|
export interface HttpClientConfig {
|
||||||
/** API origin used for all relative request paths. */
|
/** API origin used for all relative request paths. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@ -29,6 +30,8 @@ export interface RequestOptions {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Return response text instead of parsing JSON. */
|
/** Return response text instead of parsing JSON. */
|
||||||
asText?: boolean;
|
asText?: boolean;
|
||||||
|
/** Fetch redirect behavior. Defaults to the runtime fetch default. */
|
||||||
|
redirect?: RequestRedirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thin `fetch` wrapper with Wrenn authentication and error mapping. */
|
/** Thin `fetch` wrapper with Wrenn authentication and error mapping. */
|
||||||
@ -58,27 +61,70 @@ export class HttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a GET request and parses the JSON response. */
|
/**
|
||||||
|
* 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> {
|
get<T>(path: string, opts?: RequestOptions): Promise<T> {
|
||||||
return this.request<T>("GET", path, undefined, opts);
|
return this.request<T>("GET", path, undefined, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a POST request with an optional JSON body. */
|
/**
|
||||||
|
* 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> {
|
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
||||||
return this.request<T>("POST", path, body, opts);
|
return this.request<T>("POST", path, body, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a PATCH request with an optional JSON body. */
|
/**
|
||||||
|
* 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> {
|
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
||||||
return this.request<T>("PATCH", path, body, opts);
|
return this.request<T>("PATCH", path, body, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a PUT request with an optional JSON body. */
|
/**
|
||||||
|
* 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> {
|
put<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
||||||
return this.request<T>("PUT", path, body, opts);
|
return this.request<T>("PUT", path, body, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a DELETE request and expects no response body. */
|
/**
|
||||||
|
* 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> {
|
delete(path: string, opts?: RequestOptions): Promise<void> {
|
||||||
return this.request<void>("DELETE", path, undefined, opts);
|
return this.request<void>("DELETE", path, undefined, opts);
|
||||||
}
|
}
|
||||||
@ -137,6 +183,29 @@ export class HttpClient {
|
|||||||
return res.body;
|
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.
|
* Sends a request and parses the response as JSON unless `asText` is set.
|
||||||
*
|
*
|
||||||
@ -156,19 +225,22 @@ export class HttpClient {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await this.rawRequest(method, path, body, opts);
|
const res = await this.rawRequest(method, path, body, opts);
|
||||||
|
|
||||||
if (res.status === 204) {
|
|
||||||
return undefined as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
await throwErrorFromResponse(res);
|
await throwErrorFromResponse(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
if (opts?.asText) {
|
if (opts?.asText) {
|
||||||
return (await res.text()) as T;
|
return (await res.text()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await res.json()) as T;
|
const text = await res.text();
|
||||||
|
if (!text) return undefined as T;
|
||||||
|
|
||||||
|
return JSON.parse(text) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rawRequest(
|
private async rawRequest(
|
||||||
@ -188,6 +260,7 @@ export class HttpClient {
|
|||||||
method,
|
method,
|
||||||
headers: { ...headers, ...opts?.headers },
|
headers: { ...headers, ...opts?.headers },
|
||||||
};
|
};
|
||||||
|
if (opts?.redirect) init.redirect = opts.redirect;
|
||||||
|
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
|
|||||||
@ -46,6 +46,11 @@ export class WsConnection {
|
|||||||
this.ws.close();
|
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. */
|
/** Indicates whether the connection has closed or failed. */
|
||||||
get isClosed(): boolean {
|
get isClosed(): boolean {
|
||||||
return this.closed;
|
return this.closed;
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
/** Default Wrenn API origin used when no base URL is supplied. */
|
/** Default Wrenn API origin used when no base URL is supplied. */
|
||||||
export const DEFAULT_BASE_URL = "https://api.wrenn.dev";
|
export const DEFAULT_BASE_URL = "https://app.wrenn.dev/api";
|
||||||
|
|
||||||
/** Environment variable used for API-key authentication. */
|
/** Environment variable used for API-key authentication. */
|
||||||
export const ENV_API_KEY = "WRENN_API_KEY";
|
export const ENV_API_KEY = "WRENN_API_KEY";
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/index.ts
72
src/index.ts
@ -1,7 +1,47 @@
|
|||||||
export type { HttpClientConfig, RequestOptions } from "./_shared/http.js";
|
export type {
|
||||||
export { HttpClient } from "./_shared/http.js";
|
CapsuleCreateOptions,
|
||||||
export type { WsConnectionOpts } from "./_shared/websocket.js";
|
CapsuleInfo,
|
||||||
export { WsConnection } from "./_shared/websocket.js";
|
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 type { ClientConfig, ResolvedClientConfig } from "./config.js";
|
||||||
export {
|
export {
|
||||||
DEFAULT_BASE_URL,
|
DEFAULT_BASE_URL,
|
||||||
@ -24,3 +64,27 @@ export {
|
|||||||
throwErrorFromResponse,
|
throwErrorFromResponse,
|
||||||
WrennError,
|
WrennError,
|
||||||
} from "./exceptions.js";
|
} 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";
|
||||||
|
|||||||
@ -718,7 +718,7 @@ export interface paths {
|
|||||||
/**
|
/**
|
||||||
* Resume a paused capsule
|
* Resume a paused capsule
|
||||||
* @description Restores a paused capsule from its snapshot using UFFD for lazy
|
* @description Restores a paused capsule from its snapshot using UFFD for lazy
|
||||||
* memory loading. Boots a fresh Firecracker process, sets up a new
|
* memory loading. Boots a fresh Cloud Hypervisor process, sets up a new
|
||||||
* network slot, and waits for envd to become ready.
|
* network slot, and waits for envd to become ready.
|
||||||
*/
|
*/
|
||||||
post: operations["resumeCapsule"];
|
post: operations["resumeCapsule"];
|
||||||
@ -1146,6 +1146,28 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/hosts/sandbox-events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Sandbox lifecycle event callback
|
||||||
|
* @description Receives autonomous lifecycle events from host agents (e.g. auto-pause
|
||||||
|
* from the TTL reaper). The event is published to an internal Redis stream
|
||||||
|
* for the control plane's event consumer to process.
|
||||||
|
*/
|
||||||
|
post: operations["sandboxEventCallback"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/hosts/auth/refresh": {
|
"/v1/hosts/auth/refresh": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1312,6 +1334,29 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/admin/users/{id}/admin": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Grant or revoke platform admin
|
||||||
|
* @description Sets the platform admin flag on a user. Cannot remove the last admin.
|
||||||
|
* Requires platform admin access (JWT + is_admin).
|
||||||
|
* The target user's JWT is not re-issued — their frontend will reflect the
|
||||||
|
* change on next login or team switch.
|
||||||
|
*/
|
||||||
|
put: operations["setUserAdmin"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@ -1408,7 +1453,7 @@ export interface components {
|
|||||||
Capsule: {
|
Capsule: {
|
||||||
id?: string;
|
id?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status?: "pending" | "starting" | "running" | "paused" | "hibernated" | "stopped" | "missing" | "error";
|
status?: "pending" | "starting" | "running" | "pausing" | "paused" | "resuming" | "stopping" | "hibernated" | "stopped" | "missing" | "error";
|
||||||
template?: string;
|
template?: string;
|
||||||
vcpus?: number;
|
vcpus?: number;
|
||||||
memory_mb?: number;
|
memory_mb?: number;
|
||||||
@ -1667,7 +1712,7 @@ export interface components {
|
|||||||
cpu_pct?: number;
|
cpu_pct?: number;
|
||||||
/**
|
/**
|
||||||
* Format: int64
|
* Format: int64
|
||||||
* @description Resident memory in bytes (VmRSS of Firecracker process)
|
* @description Resident memory in bytes (VmRSS of Cloud Hypervisor process)
|
||||||
*/
|
*/
|
||||||
mem_bytes?: number;
|
mem_bytes?: number;
|
||||||
/**
|
/**
|
||||||
@ -1743,7 +1788,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Invalid request */
|
/** @description Invalid request parameters */
|
||||||
BadRequest: {
|
BadRequest: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -2746,8 +2791,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule created */
|
/** @description Capsule creation initiated (status will be "starting") */
|
||||||
201: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -2858,8 +2903,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule destroyed */
|
/** @description Capsule destruction initiated */
|
||||||
204: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -3117,8 +3162,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule paused (snapshot taken, resources released) */
|
/** @description Capsule pause initiated (status will be "pausing") */
|
||||||
200: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -3148,8 +3193,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule resumed (new VM booted from snapshot) */
|
/** @description Capsule resume initiated (status will be "resuming") */
|
||||||
200: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -3895,6 +3940,53 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
sandboxEventCallback: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @enum {string} */
|
||||||
|
event: "sandbox.auto_paused";
|
||||||
|
sandbox_id: string;
|
||||||
|
host_id: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
timestamp?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Event accepted */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Host ID mismatch */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
refreshHostToken: {
|
refreshHostToken: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4249,4 +4341,50 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
setUserAdmin: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @description true to grant admin, false to revoke. */
|
||||||
|
admin: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Admin status updated */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
/** @description Caller is not a platform admin */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description User not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,6 +3,7 @@ import type { AddressInfo } from "node:net";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
|
||||||
|
import { AsyncQueue } from "../src/_shared/async-queue.js";
|
||||||
import { HttpClient } from "../src/_shared/http.js";
|
import { HttpClient } from "../src/_shared/http.js";
|
||||||
import { WsConnection } from "../src/_shared/websocket.js";
|
import { WsConnection } from "../src/_shared/websocket.js";
|
||||||
import { resolveConfig } from "../src/config.js";
|
import { resolveConfig } from "../src/config.js";
|
||||||
@ -31,7 +32,7 @@ describe("resolveConfig", () => {
|
|||||||
vi.stubEnv("WRENN_TOKEN", undefined);
|
vi.stubEnv("WRENN_TOKEN", undefined);
|
||||||
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
|
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
|
||||||
|
|
||||||
expect(resolveConfig()).toEqual({ baseUrl: "https://api.wrenn.dev" });
|
expect(resolveConfig()).toEqual({ baseUrl: "https://app.wrenn.dev/api" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers explicit options over environment variables", () => {
|
it("prefers explicit options over environment variables", () => {
|
||||||
@ -233,6 +234,57 @@ describe("HttpClient", () => {
|
|||||||
|
|
||||||
await assertion;
|
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", () => {
|
describe("WsConnection", () => {
|
||||||
@ -263,7 +315,8 @@ describe("WsConnection", () => {
|
|||||||
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
||||||
expect(messages).toEqual([{ type: "ready" }]);
|
expect(messages).toEqual([{ type: "ready" }]);
|
||||||
|
|
||||||
connection.close();
|
await connection[Symbol.asyncDispose]();
|
||||||
|
expect(connection.isClosed).toBe(true);
|
||||||
server.close();
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,3 +338,37 @@ describe("WsConnection", () => {
|
|||||||
vi.useRealTimers();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import {
|
|
||||||
createServer,
|
|
||||||
type IncomingMessage,
|
|
||||||
type ServerResponse,
|
|
||||||
} from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { WebSocketServer } from "ws";
|
|
||||||
|
|
||||||
import { HttpClient } from "../../src/_shared/http.js";
|
|
||||||
import { WsConnection } from "../../src/_shared/websocket.js";
|
|
||||||
import { ConflictError, TimeoutError } from "../../src/exceptions.js";
|
|
||||||
|
|
||||||
interface CapturedRequest {
|
|
||||||
method?: string;
|
|
||||||
url?: string;
|
|
||||||
headers: IncomingMessage["headers"];
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRequestBody(request: IncomingMessage): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let body = "";
|
|
||||||
request.setEncoding("utf8");
|
|
||||||
request.on("data", (chunk) => {
|
|
||||||
body += chunk;
|
|
||||||
});
|
|
||||||
request.on("end", () => resolve(body));
|
|
||||||
request.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function listen(server: ReturnType<typeof createServer>): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address() as AddressInfo;
|
|
||||||
resolve(address.port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeHttpServer(
|
|
||||||
server: ReturnType<typeof createServer>,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeWebSocketServer(server: WebSocketServer): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("foundation integration", () => {
|
|
||||||
const cleanup: Array<() => Promise<void>> = [];
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all(cleanup.splice(0).map((close) => close()));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends JSON requests through a real HTTP server", async () => {
|
|
||||||
let captured: CapturedRequest | undefined;
|
|
||||||
const server = createServer(async (request, response: ServerResponse) => {
|
|
||||||
captured = {
|
|
||||||
method: request.method,
|
|
||||||
url: request.url,
|
|
||||||
headers: request.headers,
|
|
||||||
body: await readRequestBody(request),
|
|
||||||
};
|
|
||||||
response.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
response.end(JSON.stringify({ ok: true }));
|
|
||||||
});
|
|
||||||
const port = await listen(server);
|
|
||||||
cleanup.push(() => closeHttpServer(server));
|
|
||||||
|
|
||||||
const client = new HttpClient({
|
|
||||||
baseUrl: `http://127.0.0.1:${port}`,
|
|
||||||
apiKey: "api-key",
|
|
||||||
token: "jwt-token",
|
|
||||||
hostToken: "host-token",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
client.post(
|
|
||||||
"/v1/foundation",
|
|
||||||
{ hello: "world" },
|
|
||||||
{ params: { page: 1 } },
|
|
||||||
),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
expect(captured).toMatchObject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/v1/foundation?page=1",
|
|
||||||
body: JSON.stringify({ hello: "world" }),
|
|
||||||
});
|
|
||||||
expect(captured?.headers["content-type"]).toBe("application/json");
|
|
||||||
expect(captured?.headers.accept).toBe("application/json");
|
|
||||||
expect(captured?.headers.authorization).toBe("Bearer jwt-token");
|
|
||||||
expect(captured?.headers["x-api-key"]).toBe("api-key");
|
|
||||||
expect(captured?.headers["x-host-token"]).toBe("host-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps real HTTP error responses to SDK errors", async () => {
|
|
||||||
const server = createServer((_request, response) => {
|
|
||||||
response.writeHead(409, { "Content-Type": "application/json" });
|
|
||||||
response.end(
|
|
||||||
JSON.stringify({
|
|
||||||
error: { code: "capsule_busy", message: "Capsule is busy" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const port = await listen(server);
|
|
||||||
cleanup.push(() => closeHttpServer(server));
|
|
||||||
|
|
||||||
const client = new HttpClient({ baseUrl: `http://127.0.0.1:${port}` });
|
|
||||||
|
|
||||||
await expect(client.get("/v1/error")).rejects.toMatchObject({
|
|
||||||
code: "capsule_busy",
|
|
||||||
message: "Capsule is busy",
|
|
||||||
statusCode: 409,
|
|
||||||
});
|
|
||||||
await expect(client.get("/v1/error")).rejects.toBeInstanceOf(ConflictError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aborts real HTTP requests using timeoutMs", async () => {
|
|
||||||
const server = createServer((_request, response) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
response.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
response.end(JSON.stringify({ ok: true }));
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
const port = await listen(server);
|
|
||||||
cleanup.push(() => closeHttpServer(server));
|
|
||||||
|
|
||||||
const client = new HttpClient({ baseUrl: `http://127.0.0.1:${port}` });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
client.get("/v1/slow", { timeoutMs: 10 }),
|
|
||||||
).rejects.toBeInstanceOf(TimeoutError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exchanges JSON messages through a real WebSocket server", async () => {
|
|
||||||
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
|
||||||
cleanup.push(() => closeWebSocketServer(server));
|
|
||||||
|
|
||||||
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 messages: unknown[] = [];
|
|
||||||
const connection = await WsConnection.connect({
|
|
||||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
||||||
path: "/v1/ws",
|
|
||||||
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" }]);
|
|
||||||
expect(connection.isClosed).toBe(false);
|
|
||||||
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user