From f1522eaa0b3b4dde7e79536d242d586f8b7e35ec Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sat, 9 May 2026 18:05:53 +0600 Subject: [PATCH] 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 --- src/_shared/http.ts | 26 + src/client.ts | 1412 +++++++++++++++++ src/index.ts | 20 + tests/client.test.ts | 492 ++++++ tests/integration/client.integration.test.ts | 85 + .../foundation.integration.test.ts | 183 --- .../live-client.integration.test.ts | 85 + 7 files changed, 2120 insertions(+), 183 deletions(-) create mode 100644 src/client.ts create mode 100644 tests/client.test.ts create mode 100644 tests/integration/client.integration.test.ts delete mode 100644 tests/integration/foundation.integration.test.ts create mode 100644 tests/integration/live-client.integration.test.ts diff --git a/src/_shared/http.ts b/src/_shared/http.ts index a3c76df..28ea53b 100644 --- a/src/_shared/http.ts +++ b/src/_shared/http.ts @@ -29,6 +29,8 @@ export interface RequestOptions { 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. */ @@ -137,6 +139,29 @@ export class HttpClient { 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 { + return this.rawRequest(method, path, body, opts); + } + /** * Sends a request and parses the response as JSON unless `asText` is set. * @@ -188,6 +213,7 @@ export class HttpClient { method, headers: { ...headers, ...opts?.headers }, }; + if (opts?.redirect) init.redirect = opts.redirect; if (body !== undefined) { init.body = JSON.stringify(body); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..550dab6 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,1412 @@ +import { HttpClient, type RequestOptions } from "./_shared/http.js"; +import { WsConnection, type WsConnectionOpts } from "./_shared/websocket.js"; +import { type ClientConfig, resolveConfig } from "./config.js"; +import type { operations } from "./models/generated.js"; + +type OperationId = keyof operations; +type JsonBody = operations[TOperation] extends { + requestBody: { content: { "application/json": infer TBody } }; +} + ? TBody + : never; +type QueryParams = + operations[TOperation]["parameters"] extends { + query?: infer TQuery; + } + ? TQuery + : never; +type ResponseFor< + TOperation extends OperationId, + TStatus extends number, +> = TStatus extends keyof operations[TOperation]["responses"] + ? operations[TOperation]["responses"][TStatus] + : never; +type JsonResponse = + ResponseFor extends { + content: { "application/json": infer TResponse }; + } + ? TResponse + : never; +type SuccessJson = + | JsonResponse + | JsonResponse + | JsonResponse; + +type JsonRequestOptions = Omit< + RequestOptions, + "params" +> & { + params?: QueryParams; +}; + +type SocketOptions = Omit< + WsConnectionOpts, + "apiKey" | "baseUrl" | "hostToken" | "path" +>; + +/** + * Input accepted by low-level file upload endpoint mappings. + * + * Used by {@link FilesResource.upload} and {@link FilesResource.streamUpload} + * to construct the multipart/form-data payload expected by the Wrenn API. + */ +export interface FileUploadInput { + /** Absolute destination path inside the capsule. */ + path: string; + /** File content to include in the multipart payload. */ + file: Blob | string; + /** Optional filename used when `file` is a `Blob`. */ + filename?: string; +} + +function encodePath(value: string): string { + return encodeURIComponent(value); +} + +function withParams( + params?: QueryParams, + opts?: Omit, +): RequestOptions | undefined { + const requestOptions: RequestOptions = { ...opts }; + if (params && typeof params === "object") { + requestOptions.params = params as NonNullable; + } + return Object.keys(requestOptions).length ? requestOptions : undefined; +} + +function createFileFormData(input: FileUploadInput): FormData { + const formData = new FormData(); + formData.append("path", input.path); + if (typeof input.file === "string") { + formData.append("file", input.file); + } else { + if (input.filename) { + formData.append("file", input.file, input.filename); + } else { + formData.append("file", input.file); + } + } + return formData; +} + +/** Shared base class for low-level API resource groups. */ +class BaseResource { + protected readonly http: HttpClient; + protected readonly baseUrl: string; + protected readonly apiKey: string | undefined; + protected readonly hostToken: string | undefined; + + constructor(config: { + http: HttpClient; + baseUrl: string; + apiKey?: string; + hostToken?: string; + }) { + this.http = config.http; + this.baseUrl = config.baseUrl; + this.apiKey = config.apiKey; + this.hostToken = config.hostToken; + } + + protected connectSocket( + path: string, + opts: SocketOptions, + ): Promise { + const connectionOpts: WsConnectionOpts = { + ...opts, + baseUrl: this.baseUrl, + path, + }; + if (this.apiKey) connectionOpts.apiKey = this.apiKey; + if (this.hostToken) connectionOpts.hostToken = this.hostToken; + return WsConnection.connect(connectionOpts); + } +} + +/** + * Low-level auth endpoints. + * + * These methods map directly to `/v1/auth/*` OpenAPI operations and do not + * persist returned tokens automatically. + */ +export class AuthResource extends BaseResource { + /** + * Creates an inactive user account. + * + * @param body - Signup request body. + * @param opts - Optional request settings. + * @returns Account creation response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + signup( + body: JsonBody<"signup">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/auth/signup", body, opts); + } + + /** + * Activates an account with an email token. + * + * @param body - Activation token request body. + * @param opts - Optional request settings. + * @returns Auth response containing the issued JWT. + * @throws WrennError subclasses for unsuccessful API responses. + */ + activate( + body: JsonBody<"activate">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/auth/activate", body, opts); + } + + /** + * Logs in with email and password. + * + * @param body - Login credentials. + * @param opts - Optional request settings. + * @returns Auth response containing the issued JWT. + * @throws WrennError subclasses for unsuccessful API responses. + */ + login( + body: JsonBody<"login">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/auth/login", body, opts); + } + + /** + * Starts the OAuth redirect flow. + * + * @param provider - OAuth provider to use. + * @param opts - Optional request settings. Use `redirect: "manual"` to inspect the 302 response. + * @returns Raw fetch response because this endpoint returns a redirect, not JSON. + * @throws TimeoutError if the configured timeout aborts the request. + */ + oauthRedirect(provider: "github", opts?: RequestOptions): Promise { + return this.http.response( + "GET", + `/v1/auth/oauth/${encodePath(provider)}`, + undefined, + opts, + ); + } + + /** + * Handles the OAuth callback redirect. + * + * @param provider - OAuth provider used for the callback. + * @param params - Callback query parameters returned by the provider. + * @param opts - Optional request settings. Use `redirect: "manual"` to inspect the 302 response. + * @returns Raw fetch response because this endpoint returns a redirect, not JSON. + * @throws TimeoutError if the configured timeout aborts the request. + */ + oauthCallback( + provider: "github", + params?: QueryParams<"oauthCallback">, + opts?: Omit, + ): Promise { + return this.http.response( + "GET", + `/v1/auth/oauth/${encodePath(provider)}/callback`, + undefined, + withParams<"oauthCallback">(params, opts), + ); + } + + /** + * Re-issues a JWT scoped to another team. + * + * @param body - Target team request body. + * @param opts - Optional request settings. + * @returns Auth response containing the team-scoped JWT. + * @throws WrennError subclasses for unsuccessful API responses. + */ + switchTeam( + body: JsonBody<"switchTeam">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/auth/switch-team", body, opts); + } +} + +/** + * Low-level account endpoints. + * + * These methods map directly to current-user account operations under `/v1/me`. + */ +export class AccountResource extends BaseResource { + /** + * Gets the current user profile. + * + * @param opts - Optional request settings. + * @returns Current user profile. + * @throws WrennError subclasses for unsuccessful API responses. + */ + getMe(opts?: RequestOptions): Promise> { + return this.http.get("/v1/me", opts); + } + + /** + * Updates the current user's display name. + * + * @param body - New display name payload. + * @param opts - Optional request settings. + * @returns Auth response containing the updated JWT. + * @throws WrennError subclasses for unsuccessful API responses. + */ + updateName( + body: JsonBody<"updateName">, + opts?: RequestOptions, + ): Promise> { + return this.http.patch("/v1/me", body, opts); + } + + /** + * Schedules the current account for deletion. + * + * @param body - Email confirmation payload. + * @param opts - Optional request settings. + * @returns Resolves when the deletion is accepted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + deleteAccount( + body: JsonBody<"deleteAccount">, + opts?: RequestOptions, + ): Promise { + return this.http.request("DELETE", "/v1/me", body, opts); + } + + /** + * Changes or adds the current user's password. + * + * @param body - Password update request body. + * @param opts - Optional request settings. + * @returns Resolves when the password is updated. + * @throws WrennError subclasses for unsuccessful API responses. + */ + changePassword( + body: JsonBody<"changePassword">, + opts?: RequestOptions, + ): Promise { + return this.http.post("/v1/me/password", body, opts); + } + + /** + * Requests a password reset email. + * + * @param body - Email address payload. + * @param opts - Optional request settings. + * @returns Resolves when the request is accepted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + requestPasswordReset( + body: JsonBody<"requestPasswordReset">, + opts?: RequestOptions, + ): Promise { + return this.http.post("/v1/me/password/reset", body, opts); + } + + /** + * Confirms a password reset token. + * + * @param body - Reset token and new password payload. + * @param opts - Optional request settings. + * @returns Resolves when the password reset succeeds. + * @throws WrennError subclasses for unsuccessful API responses. + */ + confirmPasswordReset( + body: JsonBody<"confirmPasswordReset">, + opts?: RequestOptions, + ): Promise { + return this.http.post("/v1/me/password/reset/confirm", body, opts); + } + + /** + * Returns the authorization URL for linking an OAuth provider. + * + * @param provider - OAuth provider to connect. + * @param opts - Optional request settings. + * @returns Provider authorization URL payload. + * @throws WrennError subclasses for unsuccessful API responses. + */ + connectProvider( + provider: "github", + opts?: RequestOptions, + ): Promise> { + return this.http.get( + `/v1/me/providers/${encodePath(provider)}/connect`, + opts, + ); + } + + /** + * Disconnects a linked OAuth provider. + * + * @param provider - OAuth provider to disconnect. + * @param opts - Optional request settings. + * @returns Resolves when the provider is disconnected. + * @throws WrennError subclasses for unsuccessful API responses. + */ + disconnectProvider(provider: "github", opts?: RequestOptions): Promise { + return this.http.delete(`/v1/me/providers/${encodePath(provider)}`, opts); + } +} + +/** Low-level API key endpoints for managing team-scoped SDK credentials. */ +export class APIKeysResource extends BaseResource { + /** + * Lists API keys for the active team. + * + * @param opts - Optional request settings. + * @returns API key metadata. Plaintext key values are not returned. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list(opts?: RequestOptions): Promise> { + return this.http.get("/v1/api-keys", opts); + } + + /** + * Creates an API key. + * + * @param body - API key creation request body. + * @param opts - Optional request settings. + * @returns Created API key response, including the plaintext key once. + * @throws WrennError subclasses for unsuccessful API responses. + */ + create( + body: JsonBody<"createAPIKey">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/api-keys", body, opts); + } + + /** + * Deletes an API key. + * + * @param id - API key identifier. + * @param opts - Optional request settings. + * @returns Resolves when the key is deleted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + delete(id: string, opts?: RequestOptions): Promise { + return this.http.delete(`/v1/api-keys/${encodePath(id)}`, opts); + } +} + +/** Low-level user lookup endpoints used by team member flows. */ +export class UsersResource extends BaseResource { + /** + * Searches users by email prefix. + * + * @param params - Search query parameters. + * @param opts - Optional request settings. + * @returns Matching users. + * @throws WrennError subclasses for unsuccessful API responses. + */ + search( + params: QueryParams<"searchUsers">, + opts?: Omit, + ): Promise> { + return this.http.get( + "/v1/users/search", + withParams<"searchUsers">(params, opts), + ); + } +} + +/** Low-level team and team membership endpoints. */ +export class TeamsResource extends BaseResource { + /** + * Lists teams for the authenticated user. + * + * @param opts - Optional request settings. + * @returns Teams the user belongs to. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list(opts?: RequestOptions): Promise> { + return this.http.get("/v1/teams", opts); + } + + /** + * Creates a team. + * + * @param body - Team creation payload. + * @param opts - Optional request settings. + * @returns Created team and caller role. + * @throws WrennError subclasses for unsuccessful API responses. + */ + create( + body: JsonBody<"createTeam">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/teams", body, opts); + } + + /** + * Gets team details. + * + * @param id - Team identifier. + * @param opts - Optional request settings. + * @returns Team details and members. + * @throws WrennError subclasses for unsuccessful API responses. + */ + get( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/teams/${encodePath(id)}`, opts); + } + + /** + * Renames a team. + * + * @param id - Team identifier. + * @param body - Rename payload. + * @param opts - Optional request settings. + * @returns Resolves when the rename succeeds. + * @throws WrennError subclasses for unsuccessful API responses. + */ + rename( + id: string, + body: JsonBody<"renameTeam">, + opts?: RequestOptions, + ): Promise { + return this.http.patch(`/v1/teams/${encodePath(id)}`, body, opts); + } + + /** + * Deletes a team. + * + * @param id - Team identifier. + * @param opts - Optional request settings. + * @returns Resolves when the team is deleted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + delete(id: string, opts?: RequestOptions): Promise { + return this.http.delete(`/v1/teams/${encodePath(id)}`, opts); + } + + /** + * Lists team members. + * + * @param id - Team identifier. + * @param opts - Optional request settings. + * @returns Team members and roles. + * @throws WrennError subclasses for unsuccessful API responses. + */ + listMembers( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/teams/${encodePath(id)}/members`, opts); + } + + /** + * Adds a member by email. + * + * @param id - Team identifier. + * @param body - Member email payload. + * @param opts - Optional request settings. + * @returns Created team member record. + * @throws WrennError subclasses for unsuccessful API responses. + */ + addMember( + id: string, + body: JsonBody<"addTeamMember">, + opts?: RequestOptions, + ): Promise> { + return this.http.post(`/v1/teams/${encodePath(id)}/members`, body, opts); + } + + /** + * Updates a team member role. + * + * @param id - Team identifier. + * @param uid - Target user identifier. + * @param body - Role update payload. + * @param opts - Optional request settings. + * @returns Resolves when the role is updated. + * @throws WrennError subclasses for unsuccessful API responses. + */ + updateMemberRole( + id: string, + uid: string, + body: JsonBody<"updateMemberRole">, + opts?: RequestOptions, + ): Promise { + return this.http.patch( + `/v1/teams/${encodePath(id)}/members/${encodePath(uid)}`, + body, + opts, + ); + } + + /** + * Removes a team member. + * + * @param id - Team identifier. + * @param uid - Target user identifier. + * @param opts - Optional request settings. + * @returns Resolves when the member is removed. + * @throws WrennError subclasses for unsuccessful API responses. + */ + removeMember(id: string, uid: string, opts?: RequestOptions): Promise { + return this.http.delete( + `/v1/teams/${encodePath(id)}/members/${encodePath(uid)}`, + opts, + ); + } + + /** + * Leaves a team. + * + * @param id - Team identifier. + * @param opts - Optional request settings. + * @returns Resolves when the user leaves the team. + * @throws WrennError subclasses for unsuccessful API responses. + */ + leave(id: string, opts?: RequestOptions): Promise { + return this.http.post(`/v1/teams/${encodePath(id)}/leave`, undefined, opts); + } +} + +/** + * Low-level capsule lifecycle and process endpoints. + * + * User-friendly command streaming and PTY abstractions are implemented in later + * feature modules; this resource only exposes endpoint-level mappings. + */ +export class CapsulesResource extends BaseResource { + /** + * Creates a capsule. + * + * @param body - Capsule creation payload. + * @param opts - Optional request settings. + * @returns Created capsule. + * @throws WrennError subclasses for unsuccessful API responses. + */ + create( + body: JsonBody<"createCapsule">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/capsules", body, opts); + } + + /** + * Lists capsules for the active team. + * + * @param opts - Optional request settings. + * @returns Capsules owned by the active team. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list(opts?: RequestOptions): Promise> { + return this.http.get("/v1/capsules", opts); + } + + /** + * Gets capsule details. + * + * @param id - Capsule identifier. + * @param opts - Optional request settings. + * @returns Capsule details. + * @throws WrennError subclasses for unsuccessful API responses. + */ + get( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/capsules/${encodePath(id)}`, opts); + } + + /** + * Destroys a capsule. + * + * @param id - Capsule identifier. + * @param opts - Optional request settings. + * @returns Resolves when the capsule is destroyed. + * @throws WrennError subclasses for unsuccessful API responses. + */ + destroy(id: string, opts?: RequestOptions): Promise { + return this.http.delete(`/v1/capsules/${encodePath(id)}`, opts); + } + + /** + * Executes a command in a capsule. + * + * @param id - Capsule identifier. + * @param body - Command execution payload. + * @param opts - Optional request settings. + * @returns Foreground command output or background process response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + exec( + id: string, + body: JsonBody<"execCommand">, + opts?: RequestOptions, + ): Promise> { + return this.http.post(`/v1/capsules/${encodePath(id)}/exec`, body, opts); + } + + /** + * Lists running processes in a capsule. + * + * @param id - Capsule identifier. + * @param opts - Optional request settings. + * @returns Running process list. + * @throws WrennError subclasses for unsuccessful API responses. + */ + listProcesses( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/capsules/${encodePath(id)}/processes`, opts); + } + + /** + * Kills a process by PID or tag. + * + * @param id - Capsule identifier. + * @param selector - Process PID or tag. + * @param params - Optional signal query parameter. + * @param opts - Optional request settings. + * @returns Resolves when the process is killed. + * @throws WrennError subclasses for unsuccessful API responses. + */ + killProcess( + id: string, + selector: string, + params?: QueryParams<"killProcess">, + opts?: Omit, + ): Promise { + return this.http.delete( + `/v1/capsules/${encodePath(id)}/processes/${encodePath(selector)}`, + withParams<"killProcess">(params, opts), + ); + } + + /** + * Connects to a running process stream over WebSocket. + * + * @param id - Capsule identifier. + * @param selector - Process PID or tag. + * @param opts - WebSocket callbacks and timeout settings. + * @returns Established WebSocket connection. + * @throws TimeoutError when the WebSocket connection times out. + */ + connectProcess( + id: string, + selector: string, + opts: SocketOptions, + ): Promise { + return this.connectSocket( + `/v1/capsules/${encodePath(id)}/processes/${encodePath(selector)}/stream`, + opts, + ); + } + + /** + * Opens the command execution WebSocket. + * + * @param id - Capsule identifier. + * @param opts - WebSocket callbacks and timeout settings. + * @returns Established WebSocket connection. + * @throws TimeoutError when the WebSocket connection times out. + */ + execStream(id: string, opts: SocketOptions): Promise { + return this.connectSocket( + `/v1/capsules/${encodePath(id)}/exec/stream`, + opts, + ); + } + + /** + * Opens the interactive PTY WebSocket. + * + * @param id - Capsule identifier. + * @param opts - WebSocket callbacks and timeout settings. + * @returns Established WebSocket connection. + * @throws TimeoutError when the WebSocket connection times out. + */ + ptySession(id: string, opts: SocketOptions): Promise { + return this.connectSocket(`/v1/capsules/${encodePath(id)}/pty`, opts); + } + + /** + * Resets the capsule inactivity timer. + * + * @param id - Capsule identifier. + * @param opts - Optional request settings. + * @returns Resolves when the ping is accepted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + ping(id: string, opts?: RequestOptions): Promise { + return this.http.post( + `/v1/capsules/${encodePath(id)}/ping`, + undefined, + opts, + ); + } + + /** + * Gets per-capsule resource metrics. + * + * @param id - Capsule identifier. + * @param params - Optional metrics range query parameters. + * @param opts - Optional request settings. + * @returns Capsule metrics response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + metrics( + id: string, + params?: QueryParams<"getCapsuleMetrics">, + opts?: Omit, + ): Promise> { + return this.http.get( + `/v1/capsules/${encodePath(id)}/metrics`, + withParams<"getCapsuleMetrics">(params, opts), + ); + } + + /** + * Pauses a running capsule. + * + * @param id - Capsule identifier. + * @param opts - Optional request settings. + * @returns Updated capsule after pause. + * @throws WrennError subclasses for unsuccessful API responses. + */ + pause( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.post( + `/v1/capsules/${encodePath(id)}/pause`, + undefined, + opts, + ); + } + + /** + * Resumes a paused capsule. + * + * @param id - Capsule identifier. + * @param opts - Optional request settings. + * @returns Updated capsule after resume. + * @throws WrennError subclasses for unsuccessful API responses. + */ + resume( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.post( + `/v1/capsules/${encodePath(id)}/resume`, + undefined, + opts, + ); + } + + /** + * Gets capsule usage stats for the active team. + * + * @param params - Optional stats range query parameters. + * @param opts - Optional request settings. + * @returns Capsule stats response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + stats( + params?: QueryParams<"getCapsuleStats">, + opts?: Omit, + ): Promise> { + return this.http.get( + "/v1/capsules/stats", + withParams<"getCapsuleStats">(params, opts), + ); + } + + /** + * Gets daily CPU and RAM usage for the active team. + * + * @param params - Optional date range query parameters. + * @param opts - Optional request settings. + * @returns Usage response for the active team. + * @throws WrennError subclasses for unsuccessful API responses. + */ + usage( + params?: QueryParams<"getCapsuleUsage">, + opts?: Omit, + ): Promise> { + return this.http.get( + "/v1/capsules/usage", + withParams<"getCapsuleUsage">(params, opts), + ); + } +} + +/** Low-level capsule file endpoints. */ +export class FilesResource extends BaseResource { + /** + * Uploads a file to a capsule. + * + * @param id - Capsule identifier. + * @param input - Destination path and file content. + * @param opts - Optional request settings. + * @returns Resolves when the upload completes. + * @throws WrennError subclasses for unsuccessful API responses. + */ + upload( + id: string, + input: FileUploadInput, + opts?: RequestOptions, + ): Promise { + return this.http.upload( + `/v1/capsules/${encodePath(id)}/files/write`, + createFileFormData(input), + opts, + ); + } + + /** + * Downloads a file from a capsule. + * + * @param id - Capsule identifier. + * @param body - File read request body. + * @param opts - Optional request settings. + * @returns Readable byte stream for the file content. + * @throws WrennError subclasses for unsuccessful API responses. + */ + download( + id: string, + body: JsonBody<"downloadFile">, + opts?: RequestOptions, + ): Promise> { + return this.http.download( + `/v1/capsules/${encodePath(id)}/files/read`, + body, + opts, + ); + } + + /** + * Lists directory contents in a capsule. + * + * @param id - Capsule identifier. + * @param body - Directory listing request body. + * @param opts - Optional request settings. + * @returns Directory listing response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list( + id: string, + body: JsonBody<"listDir">, + opts?: RequestOptions, + ): Promise> { + return this.http.post( + `/v1/capsules/${encodePath(id)}/files/list`, + body, + opts, + ); + } + + /** + * Creates a directory in a capsule. + * + * @param id - Capsule identifier. + * @param body - Directory creation request body. + * @param opts - Optional request settings. + * @returns Directory creation response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + mkdir( + id: string, + body: JsonBody<"makeDir">, + opts?: RequestOptions, + ): Promise> { + return this.http.post( + `/v1/capsules/${encodePath(id)}/files/mkdir`, + body, + opts, + ); + } + + /** + * Removes a file or directory in a capsule. + * + * @param id - Capsule identifier. + * @param body - Remove request body. + * @param opts - Optional request settings. + * @returns Resolves when the path is removed. + * @throws WrennError subclasses for unsuccessful API responses. + */ + remove( + id: string, + body: JsonBody<"removePath">, + opts?: RequestOptions, + ): Promise { + return this.http.post( + `/v1/capsules/${encodePath(id)}/files/remove`, + body, + opts, + ); + } + + /** + * Uploads a file through the streaming upload endpoint. + * + * @param id - Capsule identifier. + * @param input - Destination path and file content. + * @param opts - Optional request settings. + * @returns Resolves when the upload completes. + * @throws WrennError subclasses for unsuccessful API responses. + */ + streamUpload( + id: string, + input: FileUploadInput, + opts?: RequestOptions, + ): Promise { + return this.http.upload( + `/v1/capsules/${encodePath(id)}/files/stream/write`, + createFileFormData(input), + opts, + ); + } + + /** + * Downloads a file through the streaming download endpoint. + * + * @param id - Capsule identifier. + * @param body - File read request body. + * @param opts - Optional request settings. + * @returns Readable byte stream for the file content. + * @throws WrennError subclasses for unsuccessful API responses. + */ + streamDownload( + id: string, + body: JsonBody<"streamDownloadFile">, + opts?: RequestOptions, + ): Promise> { + return this.http.download( + `/v1/capsules/${encodePath(id)}/files/stream/read`, + body, + opts, + ); + } +} + +/** Low-level snapshot template endpoints. */ +export class SnapshotsResource extends BaseResource { + /** + * Creates a snapshot template. + * + * @param body - Snapshot creation request body. + * @param params - Optional snapshot creation query parameters. + * @param opts - Optional request settings. + * @returns Created template. + * @throws WrennError subclasses for unsuccessful API responses. + */ + create( + body: JsonBody<"createSnapshot">, + params?: QueryParams<"createSnapshot">, + opts?: Omit, + ): Promise> { + return this.http.post( + "/v1/snapshots", + body, + withParams<"createSnapshot">(params, opts), + ); + } + + /** + * Lists snapshot templates. + * + * @param params - Optional template type filter. + * @param opts - Optional request settings. + * @returns Snapshot templates. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list( + params?: QueryParams<"listSnapshots">, + opts?: Omit, + ): Promise> { + return this.http.get( + "/v1/snapshots", + withParams<"listSnapshots">(params, opts), + ); + } + + /** + * Deletes a snapshot template. + * + * @param name - Snapshot template name. + * @param opts - Optional request settings. + * @returns Resolves when the template is deleted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + delete(name: string, opts?: RequestOptions): Promise { + return this.http.delete(`/v1/snapshots/${encodePath(name)}`, opts); + } +} + +/** + * Low-level host management and host-agent endpoints. + * + * Host management methods typically use bearer auth; `heartbeat` uses host-token + * auth when the client was configured with `hostToken`. + */ +export class HostsResource extends BaseResource { + /** + * Creates a host record. + * + * @param body - Host creation request body. + * @param opts - Optional request settings. + * @returns Created host response with registration token. + * @throws WrennError subclasses for unsuccessful API responses. + */ + create( + body: JsonBody<"createHost">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/hosts", body, opts); + } + + /** + * Lists hosts. + * + * @param opts - Optional request settings. + * @returns Host records visible to the current user/team. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list(opts?: RequestOptions): Promise> { + return this.http.get("/v1/hosts", opts); + } + + /** + * Gets host details. + * + * @param id - Host identifier. + * @param opts - Optional request settings. + * @returns Host details. + * @throws WrennError subclasses for unsuccessful API responses. + */ + get( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/hosts/${encodePath(id)}`, opts); + } + + /** + * Deletes a host. + * + * @param id - Host identifier. + * @param params - Optional deletion query parameters. + * @param opts - Optional request settings. + * @returns Resolves when the host is deleted. + * @throws HostHasCapsulesError when active capsules block deletion. + * @throws WrennError subclasses for other unsuccessful API responses. + */ + delete( + id: string, + params?: QueryParams<"deleteHost">, + opts?: Omit, + ): Promise { + return this.http.delete( + `/v1/hosts/${encodePath(id)}`, + withParams<"deleteHost">(params, opts), + ); + } + + /** + * Regenerates a pending host registration token. + * + * @param id - Host identifier. + * @param opts - Optional request settings. + * @returns New host registration response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + regenerateToken( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.post(`/v1/hosts/${encodePath(id)}/token`, undefined, opts); + } + + /** + * Registers a host agent. + * + * @param body - Host registration request body. + * @param opts - Optional request settings. + * @returns Host token and registration response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + register( + body: JsonBody<"registerHost">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/hosts/register", body, opts); + } + + /** + * Sends a host-agent heartbeat using host-token auth. + * + * @param id - Host identifier. + * @param opts - Optional request settings. + * @returns Resolves when the heartbeat is recorded. + * @throws WrennError subclasses for unsuccessful API responses. + */ + heartbeat(id: string, opts?: RequestOptions): Promise { + return this.http.post( + `/v1/hosts/${encodePath(id)}/heartbeat`, + undefined, + opts, + ); + } + + /** + * Refreshes a host token. + * + * @param body - Refresh token request body. + * @param opts - Optional request settings. + * @returns Rotated host token response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + refreshToken( + body: JsonBody<"refreshHostToken">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/hosts/auth/refresh", body, opts); + } + + /** + * Previews host deletion effects. + * + * @param id - Host identifier. + * @param opts - Optional request settings. + * @returns Host deletion preview. + * @throws WrennError subclasses for unsuccessful API responses. + */ + deletePreview( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/hosts/${encodePath(id)}/delete-preview`, opts); + } + + /** + * Lists host tags. + * + * @param id - Host identifier. + * @param opts - Optional request settings. + * @returns Host tag strings. + * @throws WrennError subclasses for unsuccessful API responses. + */ + listTags( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/hosts/${encodePath(id)}/tags`, opts); + } + + /** + * Adds a host tag. + * + * @param id - Host identifier. + * @param body - Tag request body. + * @param opts - Optional request settings. + * @returns Resolves when the tag is added. + * @throws WrennError subclasses for unsuccessful API responses. + */ + addTag( + id: string, + body: JsonBody<"addHostTag">, + opts?: RequestOptions, + ): Promise { + return this.http.post(`/v1/hosts/${encodePath(id)}/tags`, body, opts); + } + + /** + * Removes a host tag. + * + * @param id - Host identifier. + * @param tag - Tag value to remove. + * @param opts - Optional request settings. + * @returns Resolves when the tag is removed. + * @throws WrennError subclasses for unsuccessful API responses. + */ + removeTag(id: string, tag: string, opts?: RequestOptions): Promise { + return this.http.delete( + `/v1/hosts/${encodePath(id)}/tags/${encodePath(tag)}`, + opts, + ); + } +} + +/** Low-level notification channel endpoints. */ +export class ChannelsResource extends BaseResource { + /** + * Creates a notification channel. + * + * @param body - Channel creation request body. + * @param opts - Optional request settings. + * @returns Created channel response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + create( + body: JsonBody<"createChannel">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/channels", body, opts); + } + + /** + * Lists notification channels. + * + * @param opts - Optional request settings. + * @returns Notification channels. + * @throws WrennError subclasses for unsuccessful API responses. + */ + list(opts?: RequestOptions): Promise> { + return this.http.get("/v1/channels", opts); + } + + /** + * Tests a channel configuration without saving it. + * + * @param body - Test channel request body. + * @param opts - Optional request settings. + * @returns Test status response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + test( + body: JsonBody<"testChannel">, + opts?: RequestOptions, + ): Promise> { + return this.http.post("/v1/channels/test", body, opts); + } + + /** + * Gets a notification channel. + * + * @param id - Channel identifier. + * @param opts - Optional request settings. + * @returns Channel details. + * @throws WrennError subclasses for unsuccessful API responses. + */ + get( + id: string, + opts?: RequestOptions, + ): Promise> { + return this.http.get(`/v1/channels/${encodePath(id)}`, opts); + } + + /** + * Updates a notification channel. + * + * @param id - Channel identifier. + * @param body - Channel update request body. + * @param opts - Optional request settings. + * @returns Updated channel response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + update( + id: string, + body: JsonBody<"updateChannel">, + opts?: RequestOptions, + ): Promise> { + return this.http.patch(`/v1/channels/${encodePath(id)}`, body, opts); + } + + /** + * Deletes a notification channel. + * + * @param id - Channel identifier. + * @param opts - Optional request settings. + * @returns Resolves when the channel is deleted. + * @throws WrennError subclasses for unsuccessful API responses. + */ + delete(id: string, opts?: RequestOptions): Promise { + return this.http.delete(`/v1/channels/${encodePath(id)}`, opts); + } + + /** + * Rotates a channel provider configuration. + * + * @param id - Channel identifier. + * @param body - Replacement provider configuration body. + * @param opts - Optional request settings. + * @returns Updated channel response. + * @throws WrennError subclasses for unsuccessful API responses. + */ + rotateConfig( + id: string, + body: JsonBody<"rotateChannelConfig">, + opts?: RequestOptions, + ): Promise> { + return this.http.put(`/v1/channels/${encodePath(id)}/config`, body, opts); + } +} + +/** + * Low-level Wrenn API client composed of resource groups. + * + * `WrennClient` is a direct OpenAPI endpoint mapper. Higher-level capsule, + * command, file, and PTY ergonomics are layered on top in later modules. + */ +export class WrennClient { + /** Shared low-level HTTP client. */ + readonly http: HttpClient; + /** Auth endpoints. */ + readonly auth: AuthResource; + /** Account endpoints. */ + readonly account: AccountResource; + /** API key endpoints. */ + readonly apiKeys: APIKeysResource; + /** User lookup endpoints. */ + readonly users: UsersResource; + /** Team endpoints. */ + readonly teams: TeamsResource; + /** Capsule endpoints. */ + readonly capsules: CapsulesResource; + /** Capsule file endpoints. */ + readonly files: FilesResource; + /** Snapshot endpoints. */ + readonly snapshots: SnapshotsResource; + /** Host endpoints. */ + readonly hosts: HostsResource; + /** Notification channel endpoints. */ + readonly channels: ChannelsResource; + + /** + * Creates a low-level Wrenn API client. + * + * @param opts - Optional base URL and authentication configuration. + * @returns A client with resource groups bound to one shared HTTP client. + */ + constructor(opts?: ClientConfig) { + const config = resolveConfig(opts); + this.http = new HttpClient(config); + const resources: ConstructorParameters[0] = { + baseUrl: config.baseUrl, + http: this.http, + }; + if (config.apiKey) resources.apiKey = config.apiKey; + if (config.hostToken) resources.hostToken = config.hostToken; + this.auth = new AuthResource(resources); + this.account = new AccountResource(resources); + this.apiKeys = new APIKeysResource(resources); + this.users = new UsersResource(resources); + this.teams = new TeamsResource(resources); + this.capsules = new CapsulesResource(resources); + this.files = new FilesResource(resources); + this.snapshots = new SnapshotsResource(resources); + this.hosts = new HostsResource(resources); + this.channels = new ChannelsResource(resources); + } +} + +export type { + JsonBody as OperationJsonBody, + JsonRequestOptions as OperationRequestOptions, + JsonResponse as OperationJsonResponse, + QueryParams as OperationQueryParams, +}; diff --git a/src/index.ts b/src/index.ts index 7795d8d..372ac43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,26 @@ 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, diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..169dbf9 --- /dev/null +++ b/tests/client.test.ts @@ -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", + }); + }); +}); diff --git a/tests/integration/client.integration.test.ts b/tests/integration/client.integration.test.ts new file mode 100644 index 0000000..9748ac9 --- /dev/null +++ b/tests/integration/client.integration.test.ts @@ -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); + }); +}); diff --git a/tests/integration/foundation.integration.test.ts b/tests/integration/foundation.integration.test.ts deleted file mode 100644 index 7e052b4..0000000 --- a/tests/integration/foundation.integration.test.ts +++ /dev/null @@ -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 { - 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): Promise { - return new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - const address = server.address() as AddressInfo; - resolve(address.port); - }); - }); -} - -function closeHttpServer( - server: ReturnType, -): Promise { - return new Promise((resolve, reject) => { - server.close((error) => { - if (error) reject(error); - else resolve(); - }); - }); -} - -function closeWebSocketServer(server: WebSocketServer): Promise { - return new Promise((resolve, reject) => { - server.close((error) => { - if (error) reject(error); - else resolve(); - }); - }); -} - -describe("foundation integration", () => { - const cleanup: Array<() => Promise> = []; - - 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((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((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(); - }); -}); diff --git a/tests/integration/live-client.integration.test.ts b/tests/integration/live-client.integration.test.ts new file mode 100644 index 0000000..9748ac9 --- /dev/null +++ b/tests/integration/live-client.integration.test.ts @@ -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); + }); +});