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); + }); +});