Compare commits

...

4 Commits

Author SHA1 Message Date
52282618bd Delete live-client.integration.test.ts 2026-05-09 18:06:02 +06:00
f1522eaa0b feat: add low-level Wrenn client resources
Implement WrennClient with typed resource mappings for auth, account,
API keys, users, teams, capsules, files, snapshots, hosts, and channels.
Add endpoint mapping tests plus live integration tess that authenticate
with WRENN_TEST_EMAIL/WRENN_TEST_PASS and use WRENN_API_KEY for API-key
scoped endpoints
2026-05-09 18:05:53 +06:00
5b3f2741a3 feat: add SDK foundation layer
Implement config resolution, typed errors, HTTP and WebSocket transport
helpers, and timeout handling for the SDK foundation. Add unit and local
integration tests covering the SDK foundation behaviour and align
package exports with the tsup output.
2026-05-09 16:32:41 +06:00
db7fccbaed feat: initial project structure and generate API types
Initialized `package.json`, add tsup build config (CJS/ESM/DTS), wire up
full Makefile targets (lint/test/check/build), add missing BadRequest
response component to OpenAPI spec, generate TypeScript types from spec,
configure biome to exclude generated files, and add `@types/ws`
2026-05-09 14:51:48 +06:00
21 changed files with 12951 additions and 1 deletions

2
.gitignore vendored
View File

@ -136,3 +136,5 @@ dist
.yarn/install-state.gz
.pnp.*
# AI agents
.opencode

67
AGENTS.md Normal file
View File

@ -0,0 +1,67 @@
# AGENTS.md
## Project
Wrenn JavaScript SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
Package name: `@wrenn/sdk`. Node.js 18+, TypeScript 5.5+, managed with [pnpm](https://pnpm.io/).
## Commands
```bash
pnpm install # install deps
make lint # biome check + format check (no auto-fix)
make test # unit tests only (vitest)
make test-integration # all tests including integration (needs live server)
make generate # regenerate types from OpenAPI spec (openapi-typescript)
make check # lint + unit test
make build # tsup build (CJS + ESM + DTS)
```
- `make test` runs all non-integration tests. To run a specific test file: `pnpm vitest run tests/commands.test.ts`
- No separate typecheck step — `vitest` and `tsup` handle type checking during test/build. `tsc --noEmit` is available but not wired up in CI.
## Architecture
- `src/` — the library package
- `capsule.ts` — high-level `Capsule` class (main user-facing class)
- `client.ts` — low-level `WrennClient` with `CapsulesResource` and `SnapshotsResource`
- `commands.ts` — command execution, streaming, and background process management
- `files.ts` — filesystem operations
- `pty.ts` — interactive terminal (PTY) over WebSocket
- `exceptions.ts` — typed error hierarchy (`WrennError` base)
- `models/generated.ts`**auto-generated** from OpenAPI spec via `openapi-typescript` (never edit directly; run `make generate`)
- `git/` — git operations inside capsules (clone, push, pull, status, branches, etc.)
- `code-interpreter/` — specialized capsule for stateful Jupyter kernel execution
- `_shared/http.ts` — thin `fetch` wrapper with auth headers, base URL, and error mapping
- `_shared/websocket.ts` — WebSocket helper wrapping `ws`
- `config.ts` — constants (`DEFAULT_BASE_URL`, env var names)
- `tests/` — unit tests use `msw` to mock HTTP; integration tests are in `tests/integration/`
- `api/openapi.yaml` — OpenAPI spec used for type generation
## Key Conventions
- Generated types live in `src/models/generated.ts`. Never edit them. Run `make generate` to update.
- No sync/async split — JS is naturally async. One `Capsule` class, all methods return `Promise`.
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule`.
- Uses native `fetch` for HTTP (Node 18+), `ws` for WebSockets, `zod` for runtime validation.
- Resource disposal via `Symbol.asyncDispose` (`await using`). Also supports manual `.close()`.
- Streaming methods return `AsyncGenerator` (e.g. `commands.stream()`, `files.downloadStream()`).
- Static + instance method pattern: `capsule.destroy()` (instance) and `Capsule.destroy(id, opts)` (static).
## Testing
- Unit tests mock HTTP via `msw` (Mock Service Worker for Node).
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
- Integration test fixtures in `tests/integration/setup.ts` create real capsules and clean them up.
- Tests use `vitest` — no `@jest` globals. Use `import { describe, it, expect } from 'vitest'`.
## CI
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
1. `make lint`
2. `make test`
3. `make test-integration`
## Dependencies
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.

29
Makefile Normal file
View File

@ -0,0 +1,29 @@
# Makefile
.PHONY: generate lint test test-integration check build
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
SPEC_PATH = "api/openapi.yaml"
generate:
@echo "Fetching latest OpenAPI spec from Git repo..."
mkdir -p api
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
@echo "Generating TypeScript types..."
mkdir -p src/models
pnpm generate
lint:
pnpm exec biome check .
test:
pnpm vitest run --exclude tests/integration
test-integration:
pnpm test:integration
check:
$(MAKE) lint
$(MAKE) test
build:
pnpm build

View File

@ -1,2 +1 @@
# js-sdk

3174
api/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

35
biome.json Normal file
View File

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

44
package.json Normal file
View File

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

2321
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

1412
src/client.ts Normal file

File diff suppressed because it is too large Load Diff

62
src/config.ts Normal file
View File

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

162
src/exceptions.ts Normal file
View File

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

46
src/index.ts Normal file
View File

@ -0,0 +1,46 @@
export type { HttpClientConfig, RequestOptions } from "./_shared/http.js";
export { HttpClient } from "./_shared/http.js";
export type { WsConnectionOpts } from "./_shared/websocket.js";
export { WsConnection } from "./_shared/websocket.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 { ClientConfig, ResolvedClientConfig } from "./config.js";
export {
DEFAULT_BASE_URL,
ENV_API_KEY,
ENV_BASE_URL,
ENV_HOST_TOKEN,
ENV_TOKEN,
resolveConfig,
} from "./config.js";
export {
AuthenticationError,
BadRequestError,
ConflictError,
ForbiddenError,
HostHasCapsulesError,
NotFoundError,
PayloadTooLargeError,
ServerError,
TimeoutError,
throwErrorFromResponse,
WrennError,
} from "./exceptions.js";

4252
src/models/generated.ts Normal file

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,492 @@
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);
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",
});
});
});

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

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

View File

@ -0,0 +1,85 @@
import { beforeAll, describe, expect, it } from "vitest";
import { WrennClient } from "../../src/client.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 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;
describeWithApiKey("WrennClient live API key integration", () => {
const client = new WrennClient({ apiKey, baseUrl });
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");
client = new WrennClient({ baseUrl, token: auth.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);
});
});

45
tsconfig.json Normal file
View File

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

14
tsup.config.ts Normal file
View File

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

View File

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