From d51a1b7cd19de15018dab2140cbc08ba646c9e2f Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Tue, 19 May 2026 11:41:43 +0100 Subject: [PATCH 01/13] feat(sdk,cli): add support for volumes --- .changeset/famous-pugs-scream.md | 5 + .../src/api-client/api-client.ts | 79 +++++- .../src/api-client/validators.ts | 21 ++ packages/vercel-sandbox/src/index.ts | 2 + packages/vercel-sandbox/src/sandbox.test.ts | 54 ++++ packages/vercel-sandbox/src/sandbox.ts | 29 ++ .../src/volume.serialize.test.ts | 146 ++++++++++ packages/vercel-sandbox/src/volume.test.ts | 105 ++++++++ packages/vercel-sandbox/src/volume.ts | 254 ++++++++++++++++++ 9 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 .changeset/famous-pugs-scream.md create mode 100644 packages/vercel-sandbox/src/volume.serialize.test.ts create mode 100644 packages/vercel-sandbox/src/volume.test.ts create mode 100644 packages/vercel-sandbox/src/volume.ts diff --git a/.changeset/famous-pugs-scream.md b/.changeset/famous-pugs-scream.md new file mode 100644 index 00000000..6df75b19 --- /dev/null +++ b/.changeset/famous-pugs-scream.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Add support for volumes via a new `Volume` class diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index bc1d54f5..b6cf9341 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -23,6 +23,8 @@ import { SandboxAndSessionResponse, SandboxesPaginationResponse, UpdateSandboxResponse, + VolumesResponse, + VolumeResponse, type CommandData, } from "./validators.js"; import { APIError, StreamError } from "./api-error.js"; @@ -36,12 +38,10 @@ import { Readable } from "stream"; import { normalizePath } from "../utils/normalizePath.js"; import { getVercelOidcToken } from "@vercel/oidc"; import { NetworkPolicy } from "../network-policy.js"; -import { - toAPINetworkPolicy, - fromAPINetworkPolicy, -} from "../utils/network-policy.js"; +import { toAPINetworkPolicy } from "../utils/network-policy.js"; import { getPrivateParams, WithPrivate } from "../utils/types.js"; import { RUNTIMES } from "../constants.js"; +import { BaseCreateSandboxParams } from "../sandbox.js"; interface Claims { owner_id: string; @@ -182,6 +182,7 @@ export class APIClient extends BaseClient { expiration?: number; deleteEvicted?: boolean; }; + mounts?: BaseCreateSandboxParams["mounts"]; signal?: AbortSignal; }>, ) { @@ -206,6 +207,7 @@ export class APIClient extends BaseClient { tags: params.tags, snapshotExpiration: params.snapshotExpiration, keepLastSnapshots: params.keepLastSnapshots, + mounts: params.mounts, ...privateParams, }), signal: params.signal, @@ -522,6 +524,57 @@ export class APIClient extends BaseClient { ); } + async listVolumes(params: { + projectId: string; + limit?: number; + cursor?: string | number; + since?: number | string; + until?: number | string; + sortBy?: "createdAt" | "updatedAt" | "name"; + sortOrder?: "asc" | "desc"; + namePrefix?: string; + signal?: AbortSignal; + }) { + return parseOrThrow( + VolumesResponse, + await this.request(`/v2/sandboxes/volumes`, { + query: { + projectId: params.projectId, + limit: params.limit, + cursor: params.cursor ?? params.until, + since: params.since, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + namePrefix: params.namePrefix, + }, + method: "GET", + signal: params.signal, + }), + ); + } + + async getOrCreateVolume(params: { + projectId: string; + name: string; + maxSizeBytes?: number; + signal?: AbortSignal; + }) { + return parseOrThrow( + VolumeResponse, + await this.request( + `/v2/sandboxes/volumes/${encodeURIComponent(params.name)}`, + { + method: "POST", + body: JSON.stringify({ + projectId: params.projectId, + maxSizeBytes: params.maxSizeBytes, + }), + signal: params.signal, + }, + ), + ); + } + async writeFiles(params: { sessionId: string; cwd: string; @@ -822,6 +875,24 @@ export class APIClient extends BaseClient { ); } + async deleteVolume(params: { + projectId: string; + name: string; + signal?: AbortSignal; + }) { + const url = `/v2/sandboxes/volumes/${encodeURIComponent(params.name)}`; + return parseOrThrow( + VolumeResponse, + await this.request(url, { + method: "DELETE", + query: { + projectId: params.projectId, + }, + signal: params.signal, + }), + ); + } + async updateSandbox(params: { name: string; projectId: string; diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index 96933c78..d372febb 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -242,6 +242,27 @@ export const SnapshotResponse = z.object({ snapshot: Snapshot, }); +export const Volume = z.object({ + name: z.string(), + projectId: z.string(), + maxSizeBytes: z.number().optional(), + currentSessionId: z.string().optional(), + currentSandboxName: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}); + +export type VolumeMetadata = z.infer; + +export const VolumesResponse = z.object({ + volumes: z.array(Volume), + pagination: CursorPagination, +}); + +export const VolumeResponse = z.object({ + volume: Volume, +}); + export const Sandbox = z.object({ name: z.string(), persistent: z.boolean(), diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 1035b4f6..74883148 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -13,6 +13,8 @@ export { export type { SerializedSandbox } from "./sandbox.js"; export { Snapshot } from "./snapshot.js"; export type { SerializedSnapshot } from "./snapshot.js"; +export { Volume } from "./volume.js"; +export type { SerializedVolume } from "./volume.js"; export type { SnapshotTreeNodeData } from "./api-client/validators.js"; export { Command, CommandFinished } from "./command.js"; export type { diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 045b3f51..f00786c9 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -390,6 +390,60 @@ describe("Sandbox.getOrCreate", () => { }); }); +describe("Sandbox.create mounts", () => { + it("sends mounts to the API", async () => { + const mockFetch = vi.fn( + async () => + new Response( + JSON.stringify({ + sandbox: makeSandboxMetadata(), + session: { + id: "sbx_123", + memory: 2048, + vcpus: 1, + region: "iad1", + runtime: "node24", + timeout: 300_000, + status: "running", + requestedAt: 1, + createdAt: 1, + cwd: "/", + updatedAt: 1, + }, + routes: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await Sandbox.create({ + token: "test-token", + teamId: "team_123", + projectId: "proj_123", + name: "my-sandbox", + mounts: { + "/mnt/storage": { + volume: "my-volume", + mode: "read-only", + }, + }, + fetch: mockFetch, + }); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + name: "my-sandbox", + projectId: "proj_123", + mounts: { + "/mnt/storage": { + volume: "my-volume", + mode: "read-only", + }, + }, + }); + }); +}); + describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Sandbox", () => { const PORTS = [3000, 4000]; const SNAPSHOT_EXPIRATION = ms("1d"); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index bb232f03..ef8eb887 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -109,6 +109,34 @@ export interface BaseCreateSandboxParams { */ tags?: Record; + /** + * List of volumes to attach to the sandbox, keyed by the desired mount path. + * The volume must be created beforehand with `Volume.getOrCreate`. + * + * The mount paths must be absolute and cannot overlap with each other. + * + * @example + * const volume = await Volume.getOrCreate({ name: "my-volume" }); + * const sandbox = await Sandbox.create({ + * mounts: { + * "/data": { volume: volume.name, mode: "read-write" }, + * }, + * }); + */ + mounts?: Record< + string, + { + /** + * The volume name to mount. + */ + volume: string; + /** + * Mount mode. Defaults to `read-write` if unspecified. + */ + mode?: "read-only" | "read-write"; + } + >; + /** * An AbortSignal to cancel sandbox creation. */ @@ -625,6 +653,7 @@ export class Sandbox { networkPolicy: params?.networkPolicy, env: params?.env, tags: params?.tags, + mounts: params?.mounts, snapshotExpiration: params?.snapshotExpiration, keepLastSnapshots: params?.keepLastSnapshots, signal: params?.signal, diff --git a/packages/vercel-sandbox/src/volume.serialize.test.ts b/packages/vercel-sandbox/src/volume.serialize.test.ts new file mode 100644 index 00000000..9e12894f --- /dev/null +++ b/packages/vercel-sandbox/src/volume.serialize.test.ts @@ -0,0 +1,146 @@ +import { registerSerializationClass } from "@workflow/core/class-serialization"; +import { + dehydrateStepReturnValue, + hydrateStepReturnValue, +} from "@workflow/core/serialization"; +import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { VolumeMetadata } from "./api-client"; +import { APIClient } from "./api-client"; +import { Volume, type SerializedVolume } from "./volume"; + +describe("Volume serialization", () => { + const mockVolumeMetadata: VolumeMetadata = { + name: "workspace", + projectId: "proj_test", + maxSizeBytes: 1073741824, + currentSessionId: "sess_test123", + currentSandboxName: "test-sandbox", + createdAt: 1775650621392, + updatedAt: 1775650621393, + }; + + const createMockVolume = ( + metadata: VolumeMetadata = mockVolumeMetadata, + ): Volume => { + const client = new APIClient({ + teamId: "team_test", + token: "test_token", + }); + + return new Volume({ + client, + volume: metadata, + projectId: "proj_test", + }); + }; + + const serializeVolume = (volume: Volume): SerializedVolume => { + return Volume[WORKFLOW_SERIALIZE](volume); + }; + + const deserializeVolume = (data: SerializedVolume): Volume => { + return Volume[WORKFLOW_DESERIALIZE](data); + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("WORKFLOW_SERIALIZE", () => { + it("serializes volume metadata", () => { + const volume = createMockVolume(); + const serialized = serializeVolume(volume); + + expect(serialized.volume.name).toBe("workspace"); + expect(serialized.volume.projectId).toBe("proj_test"); + expect(serialized.volume.maxSizeBytes).toBe(1073741824); + expect(serialized.projectId).toBe("proj_test"); + }); + + it("does not include the API client or credentials", () => { + const volume = createMockVolume(); + const serialized = serializeVolume(volume); + + expect(serialized).not.toHaveProperty("client"); + expect(serialized).not.toHaveProperty("_client"); + expect(JSON.stringify(serialized)).not.toContain("token"); + }); + }); + + describe("WORKFLOW_DESERIALIZE", () => { + it("returns synchronously", () => { + const volume = createMockVolume(); + const serialized = serializeVolume(volume); + + const result = deserializeVolume(serialized); + + expect(result).toBeInstanceOf(Volume); + expect(result).not.toBeInstanceOf(Promise); + }); + + it("reconstructs a metadata-backed instance", () => { + const volume = createMockVolume(); + const serialized = serializeVolume(volume); + + const result = deserializeVolume(serialized); + + expect(result.name).toBe("workspace"); + expect(result.projectId).toBe("proj_test"); + expect(result.project).toBe("proj_test"); + expect(result.maxSize).toBe(1073741824); + expect(result.currentSessionId).toBe("sess_test123"); + expect(result.currentSandboxName).toBe("test-sandbox"); + expect(result.createdAt).toEqual(new Date(1775650621392)); + expect(result.updatedAt).toEqual(new Date(1775650621393)); + }); + + it("does not require global credentials just to deserialize and read metadata", async () => { + vi.resetModules(); + const { Volume: FreshVolume } = await import("./volume"); + + const deserialized = FreshVolume[WORKFLOW_DESERIALIZE]({ + volume: mockVolumeMetadata, + projectId: "proj_test", + }) as Volume; + + expect(deserialized.name).toBe("workspace"); + expect(deserialized.maxSize).toBe(1073741824); + }); + + it("deserialized instance has no client until ensureClient() is called", async () => { + vi.resetModules(); + const { Volume: FreshVolume } = await import("./volume"); + + const deserialized = FreshVolume[WORKFLOW_DESERIALIZE]({ + volume: mockVolumeMetadata, + projectId: "proj_test", + }) as Volume; + + expect((deserialized as any)._client).toBeNull(); + }); + }); + + describe("workflow runtime integration", () => { + it("survives a step boundary roundtrip", async () => { + registerSerializationClass("Volume", Volume); + + const volume = createMockVolume(); + + const dehydrated = await dehydrateStepReturnValue( + volume, + "run_123", + undefined, + ); + const rehydrated = await hydrateStepReturnValue( + dehydrated, + "run_123", + undefined, + ); + + expect(rehydrated).toBeInstanceOf(Volume); + expect(rehydrated.name).toBe("workspace"); + expect(rehydrated.maxSize).toBe(1073741824); + }); + }); +}); diff --git a/packages/vercel-sandbox/src/volume.test.ts b/packages/vercel-sandbox/src/volume.test.ts new file mode 100644 index 00000000..1c7ce678 --- /dev/null +++ b/packages/vercel-sandbox/src/volume.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { Volume } from "./volume.js"; + +const CREDENTIALS = { + token: "test-token", + teamId: "team_123", + projectId: "proj_123", +}; + +const volumePayload = { + name: "workspace", + projectId: "proj_123", + maxSizeBytes: 1024, + currentSessionId: "sbx_123", + currentSandboxName: "my-sandbox", + createdAt: 1, + updatedAt: 2, +}; + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + +describe("Volume", () => { + it("gets or creates a volume", async () => { + const mockFetch = vi.fn(async () => + jsonResponse({ volume: volumePayload }), + ); + + const volume = await Volume.getOrCreate({ + ...CREDENTIALS, + name: "workspace", + maxSize: 1024, + fetch: mockFetch, + }); + + expect(volume.name).toBe("workspace"); + expect(volume.projectId).toBe("proj_123"); + expect(volume.project).toBe("proj_123"); + expect(volume.maxSize).toBe(1024); + expect(volume.currentSessionId).toBe("sbx_123"); + expect(volume.currentSandboxName).toBe("my-sandbox"); + expect(volume.createdAt).toEqual(new Date(1)); + + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/v2/sandboxes/volumes/workspace"); + expect(String(url)).toContain("teamId=team_123"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + projectId: "proj_123", + maxSizeBytes: 1024, + }); + }); + + it("lists volumes with pagination", async () => { + const mockFetch = vi.fn(async (input) => { + if (String(input).includes("cursor=next-page")) { + return jsonResponse({ + volumes: [{ ...volumePayload, name: "cache" }], + pagination: { count: 1, next: null }, + }); + } + + return jsonResponse({ + volumes: [volumePayload], + pagination: { count: 1, next: "next-page" }, + }); + }); + + const result = await Volume.list({ + ...CREDENTIALS, + limit: 1, + fetch: mockFetch, + }); + + expect(result.volumes[0]).toBeInstanceOf(Volume); + expect(result.volumes[0].name).toBe("workspace"); + await expect(result.toArray()).resolves.toEqual([ + expect.objectContaining({ name: "workspace" }), + expect.objectContaining({ name: "cache" }), + ]); + }); + + it("deletes a volume", async () => { + const mockFetch = vi.fn(async () => + jsonResponse({ + volume: { ...volumePayload, currentSessionId: undefined }, + }), + ); + const volume = await Volume.getOrCreate({ + ...CREDENTIALS, + name: "workspace", + fetch: mockFetch, + }); + + await volume.delete(); + + const [url, init] = mockFetch.mock.calls[1]; + expect(String(url)).toContain("/v2/sandboxes/volumes/workspace"); + expect(String(url)).toContain("projectId=proj_123"); + expect(init?.method).toBe("DELETE"); + }); +}); diff --git a/packages/vercel-sandbox/src/volume.ts b/packages/vercel-sandbox/src/volume.ts new file mode 100644 index 00000000..aff0c6f4 --- /dev/null +++ b/packages/vercel-sandbox/src/volume.ts @@ -0,0 +1,254 @@ +import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; +import type { WithFetchOptions } from "./api-client/api-client.js"; +import type { VolumeMetadata } from "./api-client/index.js"; +import { APIClient } from "./api-client/index.js"; +import { type Credentials, getCredentials } from "./utils/get-credentials.js"; +import { attachPaginator } from "./utils/paginator.js"; + +export interface SerializedVolume { + volume: VolumeMetadata; + projectId?: string; +} + +/** @inline */ +interface GetOrCreateVolumeParams { + /** + * The name of the volume to get or create. Must be unique within the project. + */ + name: string; + /** + * Maximum volume size in bytes. If omitted, a default of 100 GiB is used. + */ + maxSize?: number; + /** + * An AbortSignal to cancel the operation. + */ + signal?: AbortSignal; +} + +/** + * A Volume is a persistent, bottomless storage that can be attached and detached to Sandboxes. + * Volumes can be mounted as read-write or read-only, at a configurable path with `Sandbox.create()`. + * + * Use {@link Volume.getOrCreate} to construct. + * @hideconstructor + */ +export class Volume { + private _client: APIClient | null = null; + private volume: VolumeMetadata; + private readonly _projectId: string; + + /** + * Lazily resolve credentials and construct an API client. + * @internal + */ + private async ensureClient(): Promise { + "use step"; + if (this._client) return this._client; + const credentials = await getCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + }); + return this._client; + } + + /** + * The name of the volume. + */ + public get name(): string { + return this.volume.name; + } + + /** + * The project ID that owns the volume. + */ + public get projectId(): string { + return this.volume.projectId; + } + + /** + * The maximum volume size in bytes, if set. Default is 100 GiB if not set. + */ + public get maxSize(): number | undefined { + return this.volume.maxSizeBytes; + } + + /** + * Current session ID the volume is attached to, if any. + */ + public get currentSessionId(): string | undefined { + return this.volume.currentSessionId; + } + + /** + * Current sandbox name the volume is attached to, if any. + */ + public get currentSandboxName(): string | undefined { + return this.volume.currentSandboxName; + } + + /** + * Timestamp when the volume was created. + */ + public get createdAt(): Date { + return new Date(this.volume.createdAt); + } + + /** + * Timestamp when the volume was last updated. + */ + public get updatedAt(): Date { + return new Date(this.volume.updatedAt); + } + + /** + * Serialize a Volume instance to plain data for @workflow/serde. + * + * @param instance - The Volume instance to serialize + * @returns A plain object containing volume metadata + */ + static [WORKFLOW_SERIALIZE](instance: Volume): SerializedVolume { + return { + volume: instance.volume, + projectId: instance._projectId, + }; + } + + /** + * Deserialize a Volume from serialized data. + * + * The deserialized instance uses the serialized metadata synchronously and + * lazily creates an API client only when methods perform API requests. + * + * @param data - The serialized volume data + * @returns The reconstructed Volume instance + */ + static [WORKFLOW_DESERIALIZE](data: SerializedVolume): Volume { + return new Volume({ + volume: data.volume, + projectId: data.projectId, + }); + } + + constructor({ + client, + volume, + projectId, + }: { + client?: APIClient; + volume: VolumeMetadata; + projectId?: string; + }) { + this._client = client ?? null; + this.volume = volume; + this._projectId = projectId ?? volume.projectId; + } + + /** + * Allow to get a list of volumes for a team narrowed to the given params. + * It returns both the volumes and the pagination metadata to allow getting + * the next page of results. + * + * The returned object is async-iterable to auto-paginate through all pages: + * + * ```ts + * const result = await Volume.list({ limit: 10 }); + * for await (const volume of result) { ... } + * // or: await result.toArray(); + * // or: for await (const page of result.pages()) { ... } + * ``` + */ + static async list( + params?: Partial[0]> & + Partial & + WithFetchOptions, + ) { + "use step"; + const credentials = await getCredentials(params); + const client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params?.fetch, + }); + const fetchPage = async (cursor?: string | number) => { + const response = await client.listVolumes({ + ...credentials, + ...params, + ...(cursor !== undefined && { cursor }), + }); + return { + ...response.json, + volumes: response.json.volumes.map( + (volume) => + new Volume({ + client, + volume, + projectId: credentials.projectId, + }), + ), + }; + }; + const firstPage = await fetchPage(params?.cursor ?? params?.until); + return attachPaginator(firstPage, { + itemsKey: "volumes", + fetchNext: fetchPage, + signal: params?.signal, + }); + } + + /** + * Retrieve an existing volume, or create a new one if it doesn't exists. + * + * @param params - Get/create parameters and optional credentials. + * @returns A promise resolving to the {@link Volume}. + */ + static async getOrCreate( + params: ( + | GetOrCreateVolumeParams + | (GetOrCreateVolumeParams & Credentials) + ) & + WithFetchOptions, + ): Promise { + "use step"; + const credentials = await getCredentials(params); + const client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params.fetch, + }); + + const response = await client.getOrCreateVolume({ + projectId: credentials.projectId, + name: params.name, + maxSizeBytes: params.maxSize, + signal: params.signal, + }); + + return new Volume({ + client, + volume: response.json.volume, + projectId: credentials.projectId, + }); + } + + /** + * Delete this volume. The volume must not be attached to any sandbox. + * This operation is irreversible and will delete all data stored in the volume. + * + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves once the volume has been deleted. + */ + async delete(opts?: { signal?: AbortSignal }): Promise { + "use step"; + const client = await this.ensureClient(); + const response = await client.deleteVolume({ + projectId: this._projectId, + name: this.volume.name, + signal: opts?.signal, + }); + + this.volume = response.json.volume; + } +} From c82f557d913f33d4337b95fb45d494e5e31d5be1 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Tue, 19 May 2026 11:56:01 +0100 Subject: [PATCH 02/13] Fix tests --- packages/vercel-sandbox/src/volume.serialize.test.ts | 1 - packages/vercel-sandbox/src/volume.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/vercel-sandbox/src/volume.serialize.test.ts b/packages/vercel-sandbox/src/volume.serialize.test.ts index 9e12894f..39a598af 100644 --- a/packages/vercel-sandbox/src/volume.serialize.test.ts +++ b/packages/vercel-sandbox/src/volume.serialize.test.ts @@ -87,7 +87,6 @@ describe("Volume serialization", () => { expect(result.name).toBe("workspace"); expect(result.projectId).toBe("proj_test"); - expect(result.project).toBe("proj_test"); expect(result.maxSize).toBe(1073741824); expect(result.currentSessionId).toBe("sess_test123"); expect(result.currentSandboxName).toBe("test-sandbox"); diff --git a/packages/vercel-sandbox/src/volume.test.ts b/packages/vercel-sandbox/src/volume.test.ts index 1c7ce678..e250a735 100644 --- a/packages/vercel-sandbox/src/volume.test.ts +++ b/packages/vercel-sandbox/src/volume.test.ts @@ -38,7 +38,6 @@ describe("Volume", () => { expect(volume.name).toBe("workspace"); expect(volume.projectId).toBe("proj_123"); - expect(volume.project).toBe("proj_123"); expect(volume.maxSize).toBe(1024); expect(volume.currentSessionId).toBe("sbx_123"); expect(volume.currentSandboxName).toBe("my-sandbox"); From 4269917fd462ca0dcd1da1b0b42fb8c2d8dd9411 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Tue, 19 May 2026 14:00:10 +0100 Subject: [PATCH 03/13] Export types, add mounts type of Sandbox entity --- packages/vercel-sandbox/src/api-client/validators.ts | 8 ++++++++ packages/vercel-sandbox/src/index.ts | 2 ++ packages/vercel-sandbox/src/sandbox.ts | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index d372febb..c4bbb9f5 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -284,6 +284,14 @@ export const Sandbox = z.object({ statusUpdatedAt: z.number().optional(), cwd: z.string().optional(), tags: z.record(z.string()).optional(), + mounts: z + .record( + z.object({ + volume: z.string(), + mode: z.enum(["read-only", "read-write"]).optional(), + }), + ) + .optional(), snapshotExpiration: z.number().optional(), keepLastSnapshots: z .object({ diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 74883148..e0d18af9 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -3,6 +3,8 @@ export { type NetworkPolicyKeyValueMatcher, type NetworkPolicyMatch, type NetworkPolicyMatcher, + type SandboxMountMode, + type SandboxMounts, Sandbox, } from "./sandbox.js"; export { diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index ef8eb887..1c41a2ac 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -178,6 +178,9 @@ export interface BaseCreateSandboxParams { onResume?: (sandbox: Sandbox) => Promise; } +export type SandboxMounts = NonNullable; +export type SandboxMountMode = NonNullable; + export type CreateSandboxParams = | BaseCreateSandboxParams | (Omit & { @@ -478,6 +481,13 @@ export class Sandbox { return this.sandbox.tags; } + /** + * Volumes mounted on the sandbox, keyed by mount path. + */ + public get mounts(): SandboxMounts | undefined { + return this.sandbox.mounts; + } + /** * The default network policy of this sandbox. */ From c98b9378440088c9ef57d97b94bf6f6e8a5da0e8 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Tue, 19 May 2026 14:00:22 +0100 Subject: [PATCH 04/13] Add CLI support --- .changeset/famous-pugs-scream.md | 3 +- packages/sandbox/docs/index.md | 19 ++ packages/sandbox/scripts/print-usage.ts | 1 + packages/sandbox/src/app.ts | 2 + packages/sandbox/src/args/volume.ts | 92 +++++++++ packages/sandbox/src/client.ts | 16 +- packages/sandbox/src/commands/create.ts | 6 + packages/sandbox/src/commands/list.ts | 13 ++ packages/sandbox/src/commands/volumes.ts | 228 ++++++++++++++++++++++ packages/sandbox/test/args/volume.test.ts | 36 ++++ 10 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 packages/sandbox/src/args/volume.ts create mode 100644 packages/sandbox/src/commands/volumes.ts create mode 100644 packages/sandbox/test/args/volume.test.ts diff --git a/.changeset/famous-pugs-scream.md b/.changeset/famous-pugs-scream.md index 6df75b19..ed923d3b 100644 --- a/.changeset/famous-pugs-scream.md +++ b/.changeset/famous-pugs-scream.md @@ -1,5 +1,6 @@ --- "@vercel/sandbox": minor +"sandbox": minor --- -Add support for volumes via a new `Volume` class +Add support for volumes via a new `Volume` class and CLI commands. diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index 25aa0747..e86de00c 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -22,6 +22,7 @@ Commands: snapshot Take a snapshot of the filesystem of a sandbox snapshots Manage sandbox snapshots sessions Manage sandbox sessions + volumes Manage sandbox volumes login Log in to the Sandbox CLI logout Log out of the Sandbox CLI @@ -89,6 +90,7 @@ Options: --snapshot, -s Start the sandbox from a snapshot ID [optional] --env , -e= Environment variables to set for the command --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) + --mount Attach a volume to the sandbox. Format: "volume:/path[:read-only|read-write]". --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] --keep-last-snapshots Keep only the N most recent snapshots of this sandbox (1-10). [optional] --keep-last-snapshots-for Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] @@ -146,6 +148,7 @@ Options: --snapshot, -s Start the sandbox from a snapshot ID [optional] --env , -e= Default environment variables for sandbox commands --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) + --mount Attach a volume to the sandbox. Format: "volume:/path[:read-only|read-write]". --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] --keep-last-snapshots Keep only the N most recent snapshots of this sandbox (1-10). [optional] --keep-last-snapshots-for Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] @@ -400,6 +403,22 @@ Commands: rm | delete [...snapshot_id] Delete one or more snapshots. ``` +## `sandbox volumes` + +``` +sandbox volumes + +▲ sandbox volumes [options] + +For command help, run `sandbox volumes --help` + +Commands: + + ls | list List volumes for the specified account and project. + get-or-create Create a volume if it does not already exist, or retrieve it. + rm | delete [...name] Delete one or more volumes. +``` + ## `sandbox config` ``` diff --git a/packages/sandbox/scripts/print-usage.ts b/packages/sandbox/scripts/print-usage.ts index df447806..61d84b78 100644 --- a/packages/sandbox/scripts/print-usage.ts +++ b/packages/sandbox/scripts/print-usage.ts @@ -16,6 +16,7 @@ const docs = { "sandbox connect": "connect --help", "sandbox snapshot": "snapshot --help", "sandbox snapshots": "snapshots --help", + "sandbox volumes": "volumes --help", "sandbox config": "config --help", "sandbox login": "login --help", "sandbox logout": "logout --help", diff --git a/packages/sandbox/src/app.ts b/packages/sandbox/src/app.ts index 1b0e541b..f8b8be3e 100644 --- a/packages/sandbox/src/app.ts +++ b/packages/sandbox/src/app.ts @@ -15,6 +15,7 @@ import { snapshot } from "./commands/snapshot"; import { snapshots } from "./commands/snapshots"; import { sessions } from "./commands/sessions"; import { config } from "./commands/config"; +import { volumes } from "./commands/volumes"; export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => subcommands({ @@ -35,6 +36,7 @@ export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => snapshot, snapshots, sessions, + volumes, ...(!opts?.withoutAuth && { login, logout, diff --git a/packages/sandbox/src/args/volume.ts b/packages/sandbox/src/args/volume.ts new file mode 100644 index 00000000..8861c05d --- /dev/null +++ b/packages/sandbox/src/args/volume.ts @@ -0,0 +1,92 @@ +import * as cmd from "cmd-ts"; +import chalk from "chalk"; +import type { SandboxMountMode, SandboxMounts } from "@vercel/sandbox"; + +export interface VolumeMount { + volume: string; + path: string; + mode?: SandboxMountMode; +} + +export type VolumeMounts = SandboxMounts; + +export const volumeName = cmd.extendType(cmd.string, { + displayName: "name", + description: "The name of the volume", + async from(input) { + const value = input.trim(); + if (value.length === 0) { + throw new Error("Volume name cannot be empty."); + } + return value; + }, +}); + +export const volumeMount = cmd.extendType(cmd.string, { + displayName: "volume:path[:mode]", + description: + 'Volume mount in the format "volume:/path[:read-only|read-write]".', + async from(input) { + return parseVolumeMount(input); + }, +}); + +export const volumeMounts = cmd.extendType(cmd.array(volumeMount), { + async from(input): Promise { + const mounts: VolumeMounts = Object.create(null); + + for (const mount of input) { + mounts[mount.path] = { volume: mount.volume, mode: mount.mode }; + } + + return mounts; + }, +}); + +export const mounts = cmd.multioption({ + long: "mount", + type: volumeMounts, + description: + 'Attach a volume to the sandbox. Format: "volume:/path[:read-only|read-write]".', +}); + +export const volumeMaxSize = cmd.extendType(cmd.number, { + displayName: "BYTES", + async from(input) { + if (!Number.isInteger(input) || input < 1) { + throw new Error( + `Invalid max size: ${input}. Must be a positive integer number of bytes.`, + ); + } + return input; + }, +}); + +export function parseVolumeMount(input: string): VolumeMount { + const [volume, path, mode, ...rest] = input.split(":"); + const validModes: SandboxMountMode[] = ["read-only", "read-write"]; + + if (rest.length > 0 || !volume || path === undefined) { + throw new Error( + [ + `Invalid volume mount: ${input}.`, + `${chalk.bold("hint:")} Use "volume:/path" or "volume:/path:read-only".`, + ].join("\n"), + ); + } + + if (mode !== undefined && !validModes.includes(mode as SandboxMountMode)) { + throw new Error( + [ + `Invalid volume mount mode: ${mode}.`, + `${chalk.bold("hint:")} Valid modes are: ${validModes.join(", ")}`, + ].join("\n"), + ); + } + + return { + volume, + path, + mode: mode as SandboxMountMode | undefined, + }; +} diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index 0dcd4b8a..e19f41bf 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -1,4 +1,4 @@ -import { Sandbox, APIError, Snapshot } from "@vercel/sandbox"; +import { Sandbox, APIError, Snapshot, Volume } from "@vercel/sandbox"; import { version } from "./pkg"; import chalk from "chalk"; import { tmpdir } from "node:os"; @@ -46,6 +46,20 @@ export const snapshotClient: Pick< withErrorHandling(() => Snapshot.tree({ fetch: fetchWithUserAgent, ...params })), }; +export const volumeClient: Pick & { + delete(volume: Volume): Promise; +} = { + getOrCreate: (params) => + withErrorHandling(() => + Volume.getOrCreate({ fetch: fetchWithUserAgent, ...params }), + ), + list: (params) => + withErrorHandling(() => + Volume.list({ fetch: fetchWithUserAgent, ...params } as typeof params), + ), + delete: (volume) => withErrorHandling(() => volume.delete()), +}; + const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => { const headers = new Headers( init?.headers ?? diff --git a/packages/sandbox/src/commands/create.ts b/packages/sandbox/src/commands/create.ts index b0e1193a..24a009ad 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -16,6 +16,7 @@ import { buildNetworkPolicy } from "../util/network-policy"; import { ObjectFromKeyValue } from "../args/key-value-pair"; import { buildKeepLastSnapshotsPayload } from "../util/keep-last-snapshots"; import { printSandboxSummary } from "../util/print-sandbox-summary"; +import { mounts } from "../args/volume"; export const args = { name: cmd.option({ @@ -58,6 +59,7 @@ export const args = { type: ObjectFromKeyValue, description: "Key-value tags to associate with the sandbox (e.g. --tag env=staging)", }), + mounts, ...snapshotRetentionArgs, ...networkPolicyArgs, scope, @@ -86,6 +88,7 @@ export const create = cmd.command({ connect, envVars, tags, + mounts, snapshotExpiration, keepLastSnapshots, keepLastSnapshotsFor, @@ -111,6 +114,7 @@ export const create = cmd.command({ const persistent = !nonPersistent; const resources = vcpus ? { vcpus } : undefined; const tagsObj = Object.keys(tags).length > 0 ? tags : undefined; + const mountsObj = Object.keys(mounts).length > 0 ? mounts : undefined; const spinner = silent ? undefined : ora("Creating sandbox...").start(); const sandbox = snapshot ? await sandboxClient.create({ @@ -125,6 +129,7 @@ export const create = cmd.command({ networkPolicy, env: envVars, tags: tagsObj, + mounts: mountsObj, persistent, snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, keepLastSnapshots: keepLastSnapshotsPayload, @@ -142,6 +147,7 @@ export const create = cmd.command({ networkPolicy, env: envVars, tags: tagsObj, + mounts: mountsObj, persistent, snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, keepLastSnapshots: keepLastSnapshotsPayload, diff --git a/packages/sandbox/src/commands/list.ts b/packages/sandbox/src/commands/list.ts index a85ef2a8..93334c74 100644 --- a/packages/sandbox/src/commands/list.ts +++ b/packages/sandbox/src/commands/list.ts @@ -109,6 +109,7 @@ export const list = cmd.command({ value: (s) => s.timeout != null ? timeAgo(s.createdAt + s.timeout) : "-", }, SNAPSHOT: { value: (s) => s.currentSnapshotId ?? "-" }, + MOUNTS: { value: (s) => formatMounts(s.mounts) }, TAGS: { value: (s) => s.tags && Object.keys(s.tags).length > 0 ? Object.entries(s.tags).map(([k, v]) => `${k}:${v}`).join(", ") : "-" } }; if (all) { @@ -136,3 +137,15 @@ const SandboxStatusColor: Record = { snapshotting: chalk.blue, aborted: chalk.gray.dim, }; + +function formatMounts( + mounts: Record | undefined, +): string { + if (!mounts || Object.keys(mounts).length === 0) { + return "-"; + } + + return Object.entries(mounts) + .map(([path, mount]) => `${mount.volume}:${path}:${mount.mode ?? "read-write"}`) + .join(", "); +} diff --git a/packages/sandbox/src/commands/volumes.ts b/packages/sandbox/src/commands/volumes.ts new file mode 100644 index 00000000..08522ca2 --- /dev/null +++ b/packages/sandbox/src/commands/volumes.ts @@ -0,0 +1,228 @@ +import * as cmd from "cmd-ts"; +import { subcommands } from "cmd-ts"; +import { Listr } from "listr2"; +import chalk from "chalk"; +import ora from "ora"; +import type { Volume } from "@vercel/sandbox"; +import { scope } from "../args/scope"; +import { volumeMaxSize, volumeName } from "../args/volume"; +import { volumeClient } from "../client"; +import { acquireRelease } from "../util/disposables"; +import { formatBytes, formatNextCursorHint, table, timeAgo } from "../util/output"; + +const list = cmd.command({ + name: "list", + aliases: ["ls"], + description: "List volumes for the specified account and project.", + args: { + scope, + namePrefix: cmd.option({ + long: "name-prefix", + description: "Filter volumes by name prefix.", + type: cmd.optional(cmd.string), + }), + sortOrder: cmd.option({ + long: "sort-order", + description: "Sort order. Options: asc, desc (default).", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), + limit: cmd.option({ + long: "limit", + description: "Maximum number of volumes per page (default 50).", + type: cmd.optional(cmd.number), + }), + cursor: cmd.option({ + long: "cursor", + description: "Pagination cursor from a previous 'More results' hint.", + type: cmd.optional(cmd.string), + }), + }, + async handler({ + scope: { token, team, project }, + namePrefix, + sortOrder, + limit, + cursor, + }) { + const { volumes, pagination } = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching volumes...").start(), + (s) => s.stop(), + ); + + return volumeClient.list({ + token, + teamId: team, + projectId: project, + limit: limit ?? 50, + ...(cursor && { cursor }), + ...(namePrefix && { namePrefix, sortBy: "name" as const }), + ...(sortOrder && { sortOrder }), + }); + })(); + + printVolumes(volumes); + + if (pagination.next !== null) { + console.log(formatNextCursorHint(pagination.next)); + } + }, +}); + +const getOrCreate = cmd.command({ + name: "get-or-create", + description: "Create a volume if it does not already exist, or retrieve it.", + args: { + name: cmd.positional({ + type: volumeName, + description: "Volume name to create or retrieve", + }), + maxSize: cmd.option({ + long: "max-size", + description: "Maximum volume size in bytes. If omitted, a default of 100 GiB is used.", + type: cmd.optional(volumeMaxSize), + }), + scope, + }, + async handler({ scope: { token, team, project }, name, maxSize }) { + const volume = await (async () => { + using _spinner = acquireRelease( + () => ora("Creating volume...").start(), + (s) => s.stop(), + ); + + return volumeClient.getOrCreate({ + token, + teamId: team, + projectId: project, + name, + maxSize, + }); + })(); + + process.stderr.write("✅ Volume " + chalk.cyan(volume.name) + " ready.\n"); + process.stderr.write( + chalk.dim(" │ ") + + "max size: " + + chalk.cyan(formatVolumeSize(volume)) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + + "created: " + + chalk.cyan(timeAgo(volume.createdAt)) + + "\n", + ); + }, +}); + +const remove = cmd.command({ + name: "delete", + aliases: ["rm", "remove"], + description: "Delete one or more volumes.", + args: { + name: cmd.positional({ + type: volumeName, + description: "Volume name to delete", + }), + names: cmd.restPositionals({ + type: volumeName, + description: "More volume names to delete", + }), + scope, + }, + async handler({ scope: { token, team, project }, name, names }) { + const tasks = Array.from(new Set([name, ...names]), (volumeName) => { + return { + title: `Deleting volume ${volumeName}`, + async task() { + const volume = await getVolumeByName({ + token, + teamId: team, + projectId: project, + name: volumeName, + }); + + if (volume.currentSandboxName || volume.currentSessionId) { + throw new Error( + `Volume ${volumeName} is attached to a sandbox and cannot be deleted.`, + ); + } + + await volumeClient.delete(volume); + }, + }; + }); + + try { + await new Listr(tasks, { concurrent: true }).run(); + } catch { + // Listr already rendered the error; just set exit code. + process.exitCode = 1; + } + }, +}); + +export const volumes = subcommands({ + name: "volumes", + description: "Manage sandbox volumes", + cmds: { + list, + "get-or-create": getOrCreate, + delete: remove, + }, +}); + +function printVolumes(volumes: Volume[]) { + console.log( + table({ + rows: volumes, + columns: { + NAME: { value: (v) => v.name }, + CREATED: { value: (v) => timeAgo(v.createdAt) }, + UPDATED: { value: (v) => timeAgo(v.updatedAt) }, + SIZE: { value: formatVolumeSize }, + ["ATTACHED SANDBOX"]: { value: (v) => v.currentSandboxName ?? "-" }, + ["ATTACHED SESSION"]: { value: (v) => v.currentSessionId ?? "-" }, + }, + }), + ); +} + +function formatVolumeSize(volume: Volume): string { + return volume.maxSize === undefined ? "-" : formatBytes(volume.maxSize); +} + +async function getVolumeByName({ + token, + teamId, + projectId, + name, +}: { + token: string; + teamId: string; + projectId: string; + name: string; +}): Promise { + const { volumes } = await volumeClient.list({ + token, + teamId, + projectId, + namePrefix: name, + sortBy: "name", + sortOrder: "asc", + limit: 50, + }); + const volume = volumes.find((volume) => volume.name === name); + + if (!volume) { + throw new Error( + [ + `Volume ${name} was not found.`, + `${chalk.bold("hint:")} Create it with: sandbox volumes get-or-create ${name}`, + ].join("\n"), + ); + } + + return volume; +} diff --git a/packages/sandbox/test/args/volume.test.ts b/packages/sandbox/test/args/volume.test.ts new file mode 100644 index 00000000..03e34b6c --- /dev/null +++ b/packages/sandbox/test/args/volume.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { parseVolumeMount, volumeMounts } from "../../src/args/volume"; + +describe("volume arguments", () => { + test("parses read-write volume mounts", () => { + expect(parseVolumeMount("cache:/data")).toEqual({ + volume: "cache", + path: "/data", + mode: undefined, + }); + }); + + test("parses read-only volume mounts", () => { + expect(parseVolumeMount("cache:/data:read-only")).toEqual({ + volume: "cache", + path: "/data", + mode: "read-only", + }); + }); + + test("leaves mount path validation to the API", () => { + expect(parseVolumeMount("cache:data")).toEqual({ + volume: "cache", + path: "data", + mode: undefined, + }); + }); + + test("passes overlapping mount paths through to the API", async () => { + await expect(volumeMounts.from(["cache:/data", "nested-cache:/data/cache"])) + .resolves.toEqual({ + "/data": { volume: "cache", mode: undefined }, + "/data/cache": { volume: "nested-cache", mode: undefined }, + }); + }); +}); From 893dadb7acc333451c6ffabde5b2597a5a2bc4b7 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 21 May 2026 09:36:40 +0100 Subject: [PATCH 05/13] Update docs --- packages/sandbox/docs/index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index e86de00c..8fbb3468 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -400,7 +400,8 @@ Commands: ls | list List snapshots for the specified account and project. get Get details of a snapshot. tree Show the snapshot ancestry tree for a sandbox. - rm | delete [...snapshot_id] Delete one or more snapshots. + rm | remove [...snapshot_id] Delete one or more snapshots. +>>>>>>> c87efa5 (Update docs) ``` ## `sandbox volumes` @@ -416,7 +417,7 @@ Commands: ls | list List volumes for the specified account and project. get-or-create Create a volume if it does not already exist, or retrieve it. - rm | delete [...name] Delete one or more volumes. + rm | remove [...name] Delete one or more volumes. ``` ## `sandbox config` From 0460f6bf38c518bcac8cdd3b6f2207f9dbf9566c Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 21 May 2026 09:39:55 +0100 Subject: [PATCH 06/13] Limit to first volume --- packages/sandbox/src/commands/volumes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sandbox/src/commands/volumes.ts b/packages/sandbox/src/commands/volumes.ts index 08522ca2..6c82f045 100644 --- a/packages/sandbox/src/commands/volumes.ts +++ b/packages/sandbox/src/commands/volumes.ts @@ -211,9 +211,9 @@ async function getVolumeByName({ namePrefix: name, sortBy: "name", sortOrder: "asc", - limit: 50, + limit: 1, }); - const volume = volumes.find((volume) => volume.name === name); + const volume = volumes[0]; if (!volume) { throw new Error( From 695746709708dbe1ba875e0d05b736788e11f354 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 21 May 2026 09:42:41 +0100 Subject: [PATCH 07/13] Import type only --- packages/vercel-sandbox/src/api-client/api-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index b6cf9341..aa5b436a 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -41,7 +41,7 @@ import { NetworkPolicy } from "../network-policy.js"; import { toAPINetworkPolicy } from "../utils/network-policy.js"; import { getPrivateParams, WithPrivate } from "../utils/types.js"; import { RUNTIMES } from "../constants.js"; -import { BaseCreateSandboxParams } from "../sandbox.js"; +import { type BaseCreateSandboxParams } from "../sandbox.js"; interface Claims { owner_id: string; From 01a2988aaf555bcafa404f918091f7d0d0ae2297 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 21 May 2026 09:51:08 +0100 Subject: [PATCH 08/13] Revert "Limit to first volume" This reverts commit 7024f998704277b42eeb5b5f9ed8cd85febcbf9a. --- packages/sandbox/src/commands/volumes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sandbox/src/commands/volumes.ts b/packages/sandbox/src/commands/volumes.ts index 6c82f045..08522ca2 100644 --- a/packages/sandbox/src/commands/volumes.ts +++ b/packages/sandbox/src/commands/volumes.ts @@ -211,9 +211,9 @@ async function getVolumeByName({ namePrefix: name, sortBy: "name", sortOrder: "asc", - limit: 1, + limit: 50, }); - const volume = volumes[0]; + const volume = volumes.find((volume) => volume.name === name); if (!volume) { throw new Error( From 8442eee0708399270a285d40c261a8eb0f314834 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 21 May 2026 09:57:28 +0100 Subject: [PATCH 09/13] Use serialized project id --- packages/vercel-sandbox/src/volume.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vercel-sandbox/src/volume.ts b/packages/vercel-sandbox/src/volume.ts index aff0c6f4..59c73e7a 100644 --- a/packages/vercel-sandbox/src/volume.ts +++ b/packages/vercel-sandbox/src/volume.ts @@ -64,7 +64,7 @@ export class Volume { * The project ID that owns the volume. */ public get projectId(): string { - return this.volume.projectId; + return this._projectId; } /** From cbd4e6cd0926eb6abae7fc904bae9272b1df0f21 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 21 May 2026 16:18:58 +0100 Subject: [PATCH 10/13] maxSize is not optional --- packages/vercel-sandbox/src/api-client/validators.ts | 2 +- packages/vercel-sandbox/src/volume.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index c4bbb9f5..9f3a4786 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -245,7 +245,7 @@ export const SnapshotResponse = z.object({ export const Volume = z.object({ name: z.string(), projectId: z.string(), - maxSizeBytes: z.number().optional(), + maxSizeBytes: z.number(), currentSessionId: z.string().optional(), currentSandboxName: z.string().optional(), createdAt: z.number(), diff --git a/packages/vercel-sandbox/src/volume.ts b/packages/vercel-sandbox/src/volume.ts index 59c73e7a..f42b8513 100644 --- a/packages/vercel-sandbox/src/volume.ts +++ b/packages/vercel-sandbox/src/volume.ts @@ -68,9 +68,9 @@ export class Volume { } /** - * The maximum volume size in bytes, if set. Default is 100 GiB if not set. + * The maximum volume size in bytes. */ - public get maxSize(): number | undefined { + public get maxSize(): number { return this.volume.maxSizeBytes; } From 703437e513c5917d50621975b57f4b815706b457 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Tue, 26 May 2026 17:07:41 +0100 Subject: [PATCH 11/13] Rename to drive --- .changeset/famous-pugs-scream.md | 2 +- packages/sandbox/docs/index.md | 21 ++- packages/sandbox/scripts/print-usage.ts | 2 +- packages/sandbox/src/app.ts | 4 +- packages/sandbox/src/args/drive.ts | 92 +++++++++++++ packages/sandbox/src/args/volume.ts | 92 ------------- packages/sandbox/src/client.ts | 12 +- packages/sandbox/src/commands/create.ts | 2 +- .../src/commands/{volumes.ts => drives.ts} | 98 +++++++------- packages/sandbox/src/commands/list.ts | 4 +- packages/sandbox/test/args/drive.test.ts | 36 +++++ packages/sandbox/test/args/volume.test.ts | 36 ----- .../src/api-client/api-client.ts | 22 +-- .../src/api-client/validators.ts | 14 +- ...ialize.test.ts => drive.serialize.test.ts} | 80 +++++------ .../src/{volume.test.ts => drive.test.ts} | 48 +++---- .../src/{volume.ts => drive.ts} | 128 +++++++++--------- packages/vercel-sandbox/src/index.ts | 4 +- packages/vercel-sandbox/src/sandbox.test.ts | 4 +- packages/vercel-sandbox/src/sandbox.ts | 14 +- 20 files changed, 357 insertions(+), 358 deletions(-) create mode 100644 packages/sandbox/src/args/drive.ts delete mode 100644 packages/sandbox/src/args/volume.ts rename packages/sandbox/src/commands/{volumes.ts => drives.ts} (60%) create mode 100644 packages/sandbox/test/args/drive.test.ts delete mode 100644 packages/sandbox/test/args/volume.test.ts rename packages/vercel-sandbox/src/{volume.serialize.test.ts => drive.serialize.test.ts} (59%) rename packages/vercel-sandbox/src/{volume.test.ts => drive.test.ts} (61%) rename packages/vercel-sandbox/src/{volume.ts => drive.ts} (56%) diff --git a/.changeset/famous-pugs-scream.md b/.changeset/famous-pugs-scream.md index ed923d3b..a5d07dad 100644 --- a/.changeset/famous-pugs-scream.md +++ b/.changeset/famous-pugs-scream.md @@ -3,4 +3,4 @@ "sandbox": minor --- -Add support for volumes via a new `Volume` class and CLI commands. +Add support for drives via a new `Drive` class and CLI commands. diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index 8fbb3468..fdaae245 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -22,7 +22,7 @@ Commands: snapshot Take a snapshot of the filesystem of a sandbox snapshots Manage sandbox snapshots sessions Manage sandbox sessions - volumes Manage sandbox volumes + drives Manage sandbox drives login Log in to the Sandbox CLI logout Log out of the Sandbox CLI @@ -90,7 +90,7 @@ Options: --snapshot, -s Start the sandbox from a snapshot ID [optional] --env , -e= Environment variables to set for the command --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) - --mount Attach a volume to the sandbox. Format: "volume:/path[:read-only|read-write]". + --mount Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]". --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] --keep-last-snapshots Keep only the N most recent snapshots of this sandbox (1-10). [optional] --keep-last-snapshots-for Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] @@ -148,7 +148,7 @@ Options: --snapshot, -s Start the sandbox from a snapshot ID [optional] --env , -e= Default environment variables for sandbox commands --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) - --mount Attach a volume to the sandbox. Format: "volume:/path[:read-only|read-write]". + --mount Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]". --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] --keep-last-snapshots Keep only the N most recent snapshots of this sandbox (1-10). [optional] --keep-last-snapshots-for Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] @@ -401,23 +401,22 @@ Commands: get Get details of a snapshot. tree Show the snapshot ancestry tree for a sandbox. rm | remove [...snapshot_id] Delete one or more snapshots. ->>>>>>> c87efa5 (Update docs) ``` -## `sandbox volumes` +## `sandbox drives` ``` -sandbox volumes +sandbox drives -▲ sandbox volumes [options] +▲ sandbox drives [options] -For command help, run `sandbox volumes --help` +For command help, run `sandbox drives --help` Commands: - ls | list List volumes for the specified account and project. - get-or-create Create a volume if it does not already exist, or retrieve it. - rm | remove [...name] Delete one or more volumes. + ls | list List drives for the specified account and project. + get-or-create Create a drive if it does not already exist, or retrieve it. + rm | delete [...name] Delete one or more drives. ``` ## `sandbox config` diff --git a/packages/sandbox/scripts/print-usage.ts b/packages/sandbox/scripts/print-usage.ts index 61d84b78..e68d25f6 100644 --- a/packages/sandbox/scripts/print-usage.ts +++ b/packages/sandbox/scripts/print-usage.ts @@ -16,7 +16,7 @@ const docs = { "sandbox connect": "connect --help", "sandbox snapshot": "snapshot --help", "sandbox snapshots": "snapshots --help", - "sandbox volumes": "volumes --help", + "sandbox drives": "drives --help", "sandbox config": "config --help", "sandbox login": "login --help", "sandbox logout": "logout --help", diff --git a/packages/sandbox/src/app.ts b/packages/sandbox/src/app.ts index f8b8be3e..5efb2216 100644 --- a/packages/sandbox/src/app.ts +++ b/packages/sandbox/src/app.ts @@ -15,7 +15,7 @@ import { snapshot } from "./commands/snapshot"; import { snapshots } from "./commands/snapshots"; import { sessions } from "./commands/sessions"; import { config } from "./commands/config"; -import { volumes } from "./commands/volumes"; +import { drives } from "./commands/drives"; export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => subcommands({ @@ -36,7 +36,7 @@ export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => snapshot, snapshots, sessions, - volumes, + drives, ...(!opts?.withoutAuth && { login, logout, diff --git a/packages/sandbox/src/args/drive.ts b/packages/sandbox/src/args/drive.ts new file mode 100644 index 00000000..76025bfd --- /dev/null +++ b/packages/sandbox/src/args/drive.ts @@ -0,0 +1,92 @@ +import * as cmd from "cmd-ts"; +import chalk from "chalk"; +import type { SandboxMountMode, SandboxMounts } from "@vercel/sandbox"; + +export interface DriveMount { + drive: string; + path: string; + mode?: SandboxMountMode; +} + +export type DriveMounts = SandboxMounts; + +export const driveName = cmd.extendType(cmd.string, { + displayName: "name", + description: "The name of the drive", + async from(input) { + const value = input.trim(); + if (value.length === 0) { + throw new Error("Drive name cannot be empty."); + } + return value; + }, +}); + +export const driveMount = cmd.extendType(cmd.string, { + displayName: "drive:path[:mode]", + description: + 'Drive mount in the format "drive:/path[:read-only|read-write]".', + async from(input) { + return parseDriveMount(input); + }, +}); + +export const driveMounts = cmd.extendType(cmd.array(driveMount), { + async from(input): Promise { + const mounts: DriveMounts = Object.create(null); + + for (const mount of input) { + mounts[mount.path] = { drive: mount.drive, mode: mount.mode }; + } + + return mounts; + }, +}); + +export const mounts = cmd.multioption({ + long: "mount", + type: driveMounts, + description: + 'Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".', +}); + +export const driveMaxSize = cmd.extendType(cmd.number, { + displayName: "BYTES", + async from(input) { + if (!Number.isInteger(input) || input < 1) { + throw new Error( + `Invalid max size: ${input}. Must be a positive integer number of bytes.`, + ); + } + return input; + }, +}); + +export function parseDriveMount(input: string): DriveMount { + const [drive, path, mode, ...rest] = input.split(":"); + const validModes: SandboxMountMode[] = ["read-only", "read-write"]; + + if (rest.length > 0 || !drive || path === undefined) { + throw new Error( + [ + `Invalid drive mount: ${input}.`, + `${chalk.bold("hint:")} Use "drive:/path" or "drive:/path:read-only".`, + ].join("\n"), + ); + } + + if (mode !== undefined && !validModes.includes(mode as SandboxMountMode)) { + throw new Error( + [ + `Invalid drive mount mode: ${mode}.`, + `${chalk.bold("hint:")} Valid modes are: ${validModes.join(", ")}`, + ].join("\n"), + ); + } + + return { + drive, + path, + mode: mode as SandboxMountMode | undefined, + }; +} diff --git a/packages/sandbox/src/args/volume.ts b/packages/sandbox/src/args/volume.ts deleted file mode 100644 index 8861c05d..00000000 --- a/packages/sandbox/src/args/volume.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as cmd from "cmd-ts"; -import chalk from "chalk"; -import type { SandboxMountMode, SandboxMounts } from "@vercel/sandbox"; - -export interface VolumeMount { - volume: string; - path: string; - mode?: SandboxMountMode; -} - -export type VolumeMounts = SandboxMounts; - -export const volumeName = cmd.extendType(cmd.string, { - displayName: "name", - description: "The name of the volume", - async from(input) { - const value = input.trim(); - if (value.length === 0) { - throw new Error("Volume name cannot be empty."); - } - return value; - }, -}); - -export const volumeMount = cmd.extendType(cmd.string, { - displayName: "volume:path[:mode]", - description: - 'Volume mount in the format "volume:/path[:read-only|read-write]".', - async from(input) { - return parseVolumeMount(input); - }, -}); - -export const volumeMounts = cmd.extendType(cmd.array(volumeMount), { - async from(input): Promise { - const mounts: VolumeMounts = Object.create(null); - - for (const mount of input) { - mounts[mount.path] = { volume: mount.volume, mode: mount.mode }; - } - - return mounts; - }, -}); - -export const mounts = cmd.multioption({ - long: "mount", - type: volumeMounts, - description: - 'Attach a volume to the sandbox. Format: "volume:/path[:read-only|read-write]".', -}); - -export const volumeMaxSize = cmd.extendType(cmd.number, { - displayName: "BYTES", - async from(input) { - if (!Number.isInteger(input) || input < 1) { - throw new Error( - `Invalid max size: ${input}. Must be a positive integer number of bytes.`, - ); - } - return input; - }, -}); - -export function parseVolumeMount(input: string): VolumeMount { - const [volume, path, mode, ...rest] = input.split(":"); - const validModes: SandboxMountMode[] = ["read-only", "read-write"]; - - if (rest.length > 0 || !volume || path === undefined) { - throw new Error( - [ - `Invalid volume mount: ${input}.`, - `${chalk.bold("hint:")} Use "volume:/path" or "volume:/path:read-only".`, - ].join("\n"), - ); - } - - if (mode !== undefined && !validModes.includes(mode as SandboxMountMode)) { - throw new Error( - [ - `Invalid volume mount mode: ${mode}.`, - `${chalk.bold("hint:")} Valid modes are: ${validModes.join(", ")}`, - ].join("\n"), - ); - } - - return { - volume, - path, - mode: mode as SandboxMountMode | undefined, - }; -} diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index e19f41bf..e4484c66 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -1,4 +1,4 @@ -import { Sandbox, APIError, Snapshot, Volume } from "@vercel/sandbox"; +import { Sandbox, APIError, Snapshot, Drive } from "@vercel/sandbox"; import { version } from "./pkg"; import chalk from "chalk"; import { tmpdir } from "node:os"; @@ -46,18 +46,18 @@ export const snapshotClient: Pick< withErrorHandling(() => Snapshot.tree({ fetch: fetchWithUserAgent, ...params })), }; -export const volumeClient: Pick & { - delete(volume: Volume): Promise; +export const driveClient: Pick & { + delete(drive: Drive): Promise; } = { getOrCreate: (params) => withErrorHandling(() => - Volume.getOrCreate({ fetch: fetchWithUserAgent, ...params }), + Drive.getOrCreate({ fetch: fetchWithUserAgent, ...params }), ), list: (params) => withErrorHandling(() => - Volume.list({ fetch: fetchWithUserAgent, ...params } as typeof params), + Drive.list({ fetch: fetchWithUserAgent, ...params } as typeof params), ), - delete: (volume) => withErrorHandling(() => volume.delete()), + delete: (drive) => withErrorHandling(() => drive.delete()), }; const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => { diff --git a/packages/sandbox/src/commands/create.ts b/packages/sandbox/src/commands/create.ts index 24a009ad..72037591 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -16,7 +16,7 @@ import { buildNetworkPolicy } from "../util/network-policy"; import { ObjectFromKeyValue } from "../args/key-value-pair"; import { buildKeepLastSnapshotsPayload } from "../util/keep-last-snapshots"; import { printSandboxSummary } from "../util/print-sandbox-summary"; -import { mounts } from "../args/volume"; +import { mounts } from "../args/drive"; export const args = { name: cmd.option({ diff --git a/packages/sandbox/src/commands/volumes.ts b/packages/sandbox/src/commands/drives.ts similarity index 60% rename from packages/sandbox/src/commands/volumes.ts rename to packages/sandbox/src/commands/drives.ts index 08522ca2..80114ce9 100644 --- a/packages/sandbox/src/commands/volumes.ts +++ b/packages/sandbox/src/commands/drives.ts @@ -3,22 +3,22 @@ import { subcommands } from "cmd-ts"; import { Listr } from "listr2"; import chalk from "chalk"; import ora from "ora"; -import type { Volume } from "@vercel/sandbox"; +import type { Drive } from "@vercel/sandbox"; import { scope } from "../args/scope"; -import { volumeMaxSize, volumeName } from "../args/volume"; -import { volumeClient } from "../client"; +import { driveMaxSize, driveName } from "../args/drive"; +import { driveClient } from "../client"; import { acquireRelease } from "../util/disposables"; import { formatBytes, formatNextCursorHint, table, timeAgo } from "../util/output"; const list = cmd.command({ name: "list", aliases: ["ls"], - description: "List volumes for the specified account and project.", + description: "List drives for the specified account and project.", args: { scope, namePrefix: cmd.option({ long: "name-prefix", - description: "Filter volumes by name prefix.", + description: "Filter drives by name prefix.", type: cmd.optional(cmd.string), }), sortOrder: cmd.option({ @@ -28,7 +28,7 @@ const list = cmd.command({ }), limit: cmd.option({ long: "limit", - description: "Maximum number of volumes per page (default 50).", + description: "Maximum number of drives per page (default 50).", type: cmd.optional(cmd.number), }), cursor: cmd.option({ @@ -44,13 +44,13 @@ const list = cmd.command({ limit, cursor, }) { - const { volumes, pagination } = await (async () => { + const { drives, pagination } = await (async () => { using _spinner = acquireRelease( - () => ora("Fetching volumes...").start(), + () => ora("Fetching drives...").start(), (s) => s.stop(), ); - return volumeClient.list({ + return driveClient.list({ token, teamId: team, projectId: project, @@ -61,7 +61,7 @@ const list = cmd.command({ }); })(); - printVolumes(volumes); + printDrives(drives); if (pagination.next !== null) { console.log(formatNextCursorHint(pagination.next)); @@ -71,27 +71,27 @@ const list = cmd.command({ const getOrCreate = cmd.command({ name: "get-or-create", - description: "Create a volume if it does not already exist, or retrieve it.", + description: "Create a drive if it does not already exist, or retrieve it.", args: { name: cmd.positional({ - type: volumeName, - description: "Volume name to create or retrieve", + type: driveName, + description: "Drive name to create or retrieve", }), maxSize: cmd.option({ long: "max-size", - description: "Maximum volume size in bytes. If omitted, a default of 100 GiB is used.", - type: cmd.optional(volumeMaxSize), + description: "Maximum drive size in bytes. If omitted, a default of 100 GiB is used.", + type: cmd.optional(driveMaxSize), }), scope, }, async handler({ scope: { token, team, project }, name, maxSize }) { - const volume = await (async () => { + const drive = await (async () => { using _spinner = acquireRelease( - () => ora("Creating volume...").start(), + () => ora("Creating drive...").start(), (s) => s.stop(), ); - return volumeClient.getOrCreate({ + return driveClient.getOrCreate({ token, teamId: team, projectId: project, @@ -100,17 +100,17 @@ const getOrCreate = cmd.command({ }); })(); - process.stderr.write("✅ Volume " + chalk.cyan(volume.name) + " ready.\n"); + process.stderr.write("✅ Drive " + chalk.cyan(drive.name) + " ready.\n"); process.stderr.write( chalk.dim(" │ ") + "max size: " + - chalk.cyan(formatVolumeSize(volume)) + + chalk.cyan(formatDriveSize(drive)) + "\n", ); process.stderr.write( chalk.dim(" ╰ ") + "created: " + - chalk.cyan(timeAgo(volume.createdAt)) + + chalk.cyan(timeAgo(drive.createdAt)) + "\n", ); }, @@ -119,37 +119,37 @@ const getOrCreate = cmd.command({ const remove = cmd.command({ name: "delete", aliases: ["rm", "remove"], - description: "Delete one or more volumes.", + description: "Delete one or more drives.", args: { name: cmd.positional({ - type: volumeName, - description: "Volume name to delete", + type: driveName, + description: "Drive name to delete", }), names: cmd.restPositionals({ - type: volumeName, - description: "More volume names to delete", + type: driveName, + description: "More drive names to delete", }), scope, }, async handler({ scope: { token, team, project }, name, names }) { - const tasks = Array.from(new Set([name, ...names]), (volumeName) => { + const tasks = Array.from(new Set([name, ...names]), (driveName) => { return { - title: `Deleting volume ${volumeName}`, + title: `Deleting drive ${driveName}`, async task() { - const volume = await getVolumeByName({ + const drive = await getDriveByName({ token, teamId: team, projectId: project, - name: volumeName, + name: driveName, }); - if (volume.currentSandboxName || volume.currentSessionId) { + if (drive.currentSandboxName || drive.currentSessionId) { throw new Error( - `Volume ${volumeName} is attached to a sandbox and cannot be deleted.`, + `Drive ${driveName} is attached to a sandbox and cannot be deleted.`, ); } - await volumeClient.delete(volume); + await driveClient.delete(drive); }, }; }); @@ -163,9 +163,9 @@ const remove = cmd.command({ }, }); -export const volumes = subcommands({ - name: "volumes", - description: "Manage sandbox volumes", +export const drives = subcommands({ + name: "drives", + description: "Manage sandbox drives", cmds: { list, "get-or-create": getOrCreate, @@ -173,15 +173,15 @@ export const volumes = subcommands({ }, }); -function printVolumes(volumes: Volume[]) { +function printDrives(drives: Drive[]) { console.log( table({ - rows: volumes, + rows: drives, columns: { NAME: { value: (v) => v.name }, CREATED: { value: (v) => timeAgo(v.createdAt) }, UPDATED: { value: (v) => timeAgo(v.updatedAt) }, - SIZE: { value: formatVolumeSize }, + SIZE: { value: formatDriveSize }, ["ATTACHED SANDBOX"]: { value: (v) => v.currentSandboxName ?? "-" }, ["ATTACHED SESSION"]: { value: (v) => v.currentSessionId ?? "-" }, }, @@ -189,11 +189,11 @@ function printVolumes(volumes: Volume[]) { ); } -function formatVolumeSize(volume: Volume): string { - return volume.maxSize === undefined ? "-" : formatBytes(volume.maxSize); +function formatDriveSize(drive: Drive): string { + return drive.maxSize === undefined ? "-" : formatBytes(drive.maxSize); } -async function getVolumeByName({ +async function getDriveByName({ token, teamId, projectId, @@ -203,8 +203,8 @@ async function getVolumeByName({ teamId: string; projectId: string; name: string; -}): Promise { - const { volumes } = await volumeClient.list({ +}): Promise { + const { drives } = await driveClient.list({ token, teamId, projectId, @@ -213,16 +213,16 @@ async function getVolumeByName({ sortOrder: "asc", limit: 50, }); - const volume = volumes.find((volume) => volume.name === name); + const drive = drives.find((drive) => drive.name === name); - if (!volume) { + if (!drive) { throw new Error( [ - `Volume ${name} was not found.`, - `${chalk.bold("hint:")} Create it with: sandbox volumes get-or-create ${name}`, + `Drive ${name} was not found.`, + `${chalk.bold("hint:")} Create it with: sandbox drives get-or-create ${name}`, ].join("\n"), ); } - return volume; + return drive; } diff --git a/packages/sandbox/src/commands/list.ts b/packages/sandbox/src/commands/list.ts index 93334c74..aed6e6db 100644 --- a/packages/sandbox/src/commands/list.ts +++ b/packages/sandbox/src/commands/list.ts @@ -139,13 +139,13 @@ const SandboxStatusColor: Record = { }; function formatMounts( - mounts: Record | undefined, + mounts: Record | undefined, ): string { if (!mounts || Object.keys(mounts).length === 0) { return "-"; } return Object.entries(mounts) - .map(([path, mount]) => `${mount.volume}:${path}:${mount.mode ?? "read-write"}`) + .map(([path, mount]) => `${mount.drive}:${path}:${mount.mode ?? "read-write"}`) .join(", "); } diff --git a/packages/sandbox/test/args/drive.test.ts b/packages/sandbox/test/args/drive.test.ts new file mode 100644 index 00000000..7ba24dba --- /dev/null +++ b/packages/sandbox/test/args/drive.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { parseDriveMount, driveMounts } from "../../src/args/drive"; + +describe("drive arguments", () => { + test("parses read-write drive mounts", () => { + expect(parseDriveMount("cache:/data")).toEqual({ + drive: "cache", + path: "/data", + mode: undefined, + }); + }); + + test("parses read-only drive mounts", () => { + expect(parseDriveMount("cache:/data:read-only")).toEqual({ + drive: "cache", + path: "/data", + mode: "read-only", + }); + }); + + test("leaves mount path validation to the API", () => { + expect(parseDriveMount("cache:data")).toEqual({ + drive: "cache", + path: "data", + mode: undefined, + }); + }); + + test("passes overlapping mount paths through to the API", async () => { + await expect(driveMounts.from(["cache:/data", "nested-cache:/data/cache"])) + .resolves.toEqual({ + "/data": { drive: "cache", mode: undefined }, + "/data/cache": { drive: "nested-cache", mode: undefined }, + }); + }); +}); diff --git a/packages/sandbox/test/args/volume.test.ts b/packages/sandbox/test/args/volume.test.ts deleted file mode 100644 index 03e34b6c..00000000 --- a/packages/sandbox/test/args/volume.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { parseVolumeMount, volumeMounts } from "../../src/args/volume"; - -describe("volume arguments", () => { - test("parses read-write volume mounts", () => { - expect(parseVolumeMount("cache:/data")).toEqual({ - volume: "cache", - path: "/data", - mode: undefined, - }); - }); - - test("parses read-only volume mounts", () => { - expect(parseVolumeMount("cache:/data:read-only")).toEqual({ - volume: "cache", - path: "/data", - mode: "read-only", - }); - }); - - test("leaves mount path validation to the API", () => { - expect(parseVolumeMount("cache:data")).toEqual({ - volume: "cache", - path: "data", - mode: undefined, - }); - }); - - test("passes overlapping mount paths through to the API", async () => { - await expect(volumeMounts.from(["cache:/data", "nested-cache:/data/cache"])) - .resolves.toEqual({ - "/data": { volume: "cache", mode: undefined }, - "/data/cache": { volume: "nested-cache", mode: undefined }, - }); - }); -}); diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index aa5b436a..2210b111 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -23,8 +23,8 @@ import { SandboxAndSessionResponse, SandboxesPaginationResponse, UpdateSandboxResponse, - VolumesResponse, - VolumeResponse, + DrivesResponse, + DriveResponse, type CommandData, } from "./validators.js"; import { APIError, StreamError } from "./api-error.js"; @@ -524,7 +524,7 @@ export class APIClient extends BaseClient { ); } - async listVolumes(params: { + async listDrives(params: { projectId: string; limit?: number; cursor?: string | number; @@ -536,8 +536,8 @@ export class APIClient extends BaseClient { signal?: AbortSignal; }) { return parseOrThrow( - VolumesResponse, - await this.request(`/v2/sandboxes/volumes`, { + DrivesResponse, + await this.request(`/v2/sandboxes/drives`, { query: { projectId: params.projectId, limit: params.limit, @@ -553,16 +553,16 @@ export class APIClient extends BaseClient { ); } - async getOrCreateVolume(params: { + async getOrCreateDrive(params: { projectId: string; name: string; maxSizeBytes?: number; signal?: AbortSignal; }) { return parseOrThrow( - VolumeResponse, + DriveResponse, await this.request( - `/v2/sandboxes/volumes/${encodeURIComponent(params.name)}`, + `/v2/sandboxes/drives/${encodeURIComponent(params.name)}`, { method: "POST", body: JSON.stringify({ @@ -875,14 +875,14 @@ export class APIClient extends BaseClient { ); } - async deleteVolume(params: { + async deleteDrive(params: { projectId: string; name: string; signal?: AbortSignal; }) { - const url = `/v2/sandboxes/volumes/${encodeURIComponent(params.name)}`; + const url = `/v2/sandboxes/drives/${encodeURIComponent(params.name)}`; return parseOrThrow( - VolumeResponse, + DriveResponse, await this.request(url, { method: "DELETE", query: { diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index 9f3a4786..caa72972 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -242,7 +242,7 @@ export const SnapshotResponse = z.object({ snapshot: Snapshot, }); -export const Volume = z.object({ +export const Drive = z.object({ name: z.string(), projectId: z.string(), maxSizeBytes: z.number(), @@ -252,15 +252,15 @@ export const Volume = z.object({ updatedAt: z.number(), }); -export type VolumeMetadata = z.infer; +export type DriveMetadata = z.infer; -export const VolumesResponse = z.object({ - volumes: z.array(Volume), +export const DrivesResponse = z.object({ + drives: z.array(Drive), pagination: CursorPagination, }); -export const VolumeResponse = z.object({ - volume: Volume, +export const DriveResponse = z.object({ + drive: Drive, }); export const Sandbox = z.object({ @@ -287,7 +287,7 @@ export const Sandbox = z.object({ mounts: z .record( z.object({ - volume: z.string(), + drive: z.string(), mode: z.enum(["read-only", "read-write"]).optional(), }), ) diff --git a/packages/vercel-sandbox/src/volume.serialize.test.ts b/packages/vercel-sandbox/src/drive.serialize.test.ts similarity index 59% rename from packages/vercel-sandbox/src/volume.serialize.test.ts rename to packages/vercel-sandbox/src/drive.serialize.test.ts index 39a598af..159f4069 100644 --- a/packages/vercel-sandbox/src/volume.serialize.test.ts +++ b/packages/vercel-sandbox/src/drive.serialize.test.ts @@ -5,12 +5,12 @@ import { } from "@workflow/core/serialization"; import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { VolumeMetadata } from "./api-client"; +import type { DriveMetadata } from "./api-client"; import { APIClient } from "./api-client"; -import { Volume, type SerializedVolume } from "./volume"; +import { Drive, type SerializedDrive } from "./drive"; -describe("Volume serialization", () => { - const mockVolumeMetadata: VolumeMetadata = { +describe("Drive serialization", () => { + const mockDriveMetadata: DriveMetadata = { name: "workspace", projectId: "proj_test", maxSizeBytes: 1073741824, @@ -20,27 +20,27 @@ describe("Volume serialization", () => { updatedAt: 1775650621393, }; - const createMockVolume = ( - metadata: VolumeMetadata = mockVolumeMetadata, - ): Volume => { + const createMockDrive = ( + metadata: DriveMetadata = mockDriveMetadata, + ): Drive => { const client = new APIClient({ teamId: "team_test", token: "test_token", }); - return new Volume({ + return new Drive({ client, - volume: metadata, + drive: metadata, projectId: "proj_test", }); }; - const serializeVolume = (volume: Volume): SerializedVolume => { - return Volume[WORKFLOW_SERIALIZE](volume); + const serializeDrive = (drive: Drive): SerializedDrive => { + return Drive[WORKFLOW_SERIALIZE](drive); }; - const deserializeVolume = (data: SerializedVolume): Volume => { - return Volume[WORKFLOW_DESERIALIZE](data); + const deserializeDrive = (data: SerializedDrive): Drive => { + return Drive[WORKFLOW_DESERIALIZE](data); }; afterEach(() => { @@ -48,19 +48,19 @@ describe("Volume serialization", () => { }); describe("WORKFLOW_SERIALIZE", () => { - it("serializes volume metadata", () => { - const volume = createMockVolume(); - const serialized = serializeVolume(volume); + it("serializes drive metadata", () => { + const drive = createMockDrive(); + const serialized = serializeDrive(drive); - expect(serialized.volume.name).toBe("workspace"); - expect(serialized.volume.projectId).toBe("proj_test"); - expect(serialized.volume.maxSizeBytes).toBe(1073741824); + expect(serialized.drive.name).toBe("workspace"); + expect(serialized.drive.projectId).toBe("proj_test"); + expect(serialized.drive.maxSizeBytes).toBe(1073741824); expect(serialized.projectId).toBe("proj_test"); }); it("does not include the API client or credentials", () => { - const volume = createMockVolume(); - const serialized = serializeVolume(volume); + const drive = createMockDrive(); + const serialized = serializeDrive(drive); expect(serialized).not.toHaveProperty("client"); expect(serialized).not.toHaveProperty("_client"); @@ -70,20 +70,20 @@ describe("Volume serialization", () => { describe("WORKFLOW_DESERIALIZE", () => { it("returns synchronously", () => { - const volume = createMockVolume(); - const serialized = serializeVolume(volume); + const drive = createMockDrive(); + const serialized = serializeDrive(drive); - const result = deserializeVolume(serialized); + const result = deserializeDrive(serialized); - expect(result).toBeInstanceOf(Volume); + expect(result).toBeInstanceOf(Drive); expect(result).not.toBeInstanceOf(Promise); }); it("reconstructs a metadata-backed instance", () => { - const volume = createMockVolume(); - const serialized = serializeVolume(volume); + const drive = createMockDrive(); + const serialized = serializeDrive(drive); - const result = deserializeVolume(serialized); + const result = deserializeDrive(serialized); expect(result.name).toBe("workspace"); expect(result.projectId).toBe("proj_test"); @@ -96,12 +96,12 @@ describe("Volume serialization", () => { it("does not require global credentials just to deserialize and read metadata", async () => { vi.resetModules(); - const { Volume: FreshVolume } = await import("./volume"); + const { Drive: FreshDrive } = await import("./drive"); - const deserialized = FreshVolume[WORKFLOW_DESERIALIZE]({ - volume: mockVolumeMetadata, + const deserialized = FreshDrive[WORKFLOW_DESERIALIZE]({ + drive: mockDriveMetadata, projectId: "proj_test", - }) as Volume; + }) as Drive; expect(deserialized.name).toBe("workspace"); expect(deserialized.maxSize).toBe(1073741824); @@ -109,12 +109,12 @@ describe("Volume serialization", () => { it("deserialized instance has no client until ensureClient() is called", async () => { vi.resetModules(); - const { Volume: FreshVolume } = await import("./volume"); + const { Drive: FreshDrive } = await import("./drive"); - const deserialized = FreshVolume[WORKFLOW_DESERIALIZE]({ - volume: mockVolumeMetadata, + const deserialized = FreshDrive[WORKFLOW_DESERIALIZE]({ + drive: mockDriveMetadata, projectId: "proj_test", - }) as Volume; + }) as Drive; expect((deserialized as any)._client).toBeNull(); }); @@ -122,12 +122,12 @@ describe("Volume serialization", () => { describe("workflow runtime integration", () => { it("survives a step boundary roundtrip", async () => { - registerSerializationClass("Volume", Volume); + registerSerializationClass("Drive", Drive); - const volume = createMockVolume(); + const drive = createMockDrive(); const dehydrated = await dehydrateStepReturnValue( - volume, + drive, "run_123", undefined, ); @@ -137,7 +137,7 @@ describe("Volume serialization", () => { undefined, ); - expect(rehydrated).toBeInstanceOf(Volume); + expect(rehydrated).toBeInstanceOf(Drive); expect(rehydrated.name).toBe("workspace"); expect(rehydrated.maxSize).toBe(1073741824); }); diff --git a/packages/vercel-sandbox/src/volume.test.ts b/packages/vercel-sandbox/src/drive.test.ts similarity index 61% rename from packages/vercel-sandbox/src/volume.test.ts rename to packages/vercel-sandbox/src/drive.test.ts index e250a735..dbb3f795 100644 --- a/packages/vercel-sandbox/src/volume.test.ts +++ b/packages/vercel-sandbox/src/drive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { Volume } from "./volume.js"; +import { Drive } from "./drive.js"; const CREDENTIALS = { token: "test-token", @@ -7,7 +7,7 @@ const CREDENTIALS = { projectId: "proj_123", }; -const volumePayload = { +const drivePayload = { name: "workspace", projectId: "proj_123", maxSizeBytes: 1024, @@ -23,28 +23,28 @@ const jsonResponse = (body: unknown) => headers: { "content-type": "application/json" }, }); -describe("Volume", () => { - it("gets or creates a volume", async () => { +describe("Drive", () => { + it("gets or creates a drive", async () => { const mockFetch = vi.fn(async () => - jsonResponse({ volume: volumePayload }), + jsonResponse({ drive: drivePayload }), ); - const volume = await Volume.getOrCreate({ + const drive = await Drive.getOrCreate({ ...CREDENTIALS, name: "workspace", maxSize: 1024, fetch: mockFetch, }); - expect(volume.name).toBe("workspace"); - expect(volume.projectId).toBe("proj_123"); - expect(volume.maxSize).toBe(1024); - expect(volume.currentSessionId).toBe("sbx_123"); - expect(volume.currentSandboxName).toBe("my-sandbox"); - expect(volume.createdAt).toEqual(new Date(1)); + expect(drive.name).toBe("workspace"); + expect(drive.projectId).toBe("proj_123"); + expect(drive.maxSize).toBe(1024); + expect(drive.currentSessionId).toBe("sbx_123"); + expect(drive.currentSandboxName).toBe("my-sandbox"); + expect(drive.createdAt).toEqual(new Date(1)); const [url, init] = mockFetch.mock.calls[0]; - expect(String(url)).toContain("/v2/sandboxes/volumes/workspace"); + expect(String(url)).toContain("/v2/sandboxes/drives/workspace"); expect(String(url)).toContain("teamId=team_123"); expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body))).toEqual({ @@ -53,51 +53,51 @@ describe("Volume", () => { }); }); - it("lists volumes with pagination", async () => { + it("lists drives with pagination", async () => { const mockFetch = vi.fn(async (input) => { if (String(input).includes("cursor=next-page")) { return jsonResponse({ - volumes: [{ ...volumePayload, name: "cache" }], + drives: [{ ...drivePayload, name: "cache" }], pagination: { count: 1, next: null }, }); } return jsonResponse({ - volumes: [volumePayload], + drives: [drivePayload], pagination: { count: 1, next: "next-page" }, }); }); - const result = await Volume.list({ + const result = await Drive.list({ ...CREDENTIALS, limit: 1, fetch: mockFetch, }); - expect(result.volumes[0]).toBeInstanceOf(Volume); - expect(result.volumes[0].name).toBe("workspace"); + expect(result.drives[0]).toBeInstanceOf(Drive); + expect(result.drives[0].name).toBe("workspace"); await expect(result.toArray()).resolves.toEqual([ expect.objectContaining({ name: "workspace" }), expect.objectContaining({ name: "cache" }), ]); }); - it("deletes a volume", async () => { + it("deletes a drive", async () => { const mockFetch = vi.fn(async () => jsonResponse({ - volume: { ...volumePayload, currentSessionId: undefined }, + drive: { ...drivePayload, currentSessionId: undefined }, }), ); - const volume = await Volume.getOrCreate({ + const drive = await Drive.getOrCreate({ ...CREDENTIALS, name: "workspace", fetch: mockFetch, }); - await volume.delete(); + await drive.delete(); const [url, init] = mockFetch.mock.calls[1]; - expect(String(url)).toContain("/v2/sandboxes/volumes/workspace"); + expect(String(url)).toContain("/v2/sandboxes/drives/workspace"); expect(String(url)).toContain("projectId=proj_123"); expect(init?.method).toBe("DELETE"); }); diff --git a/packages/vercel-sandbox/src/volume.ts b/packages/vercel-sandbox/src/drive.ts similarity index 56% rename from packages/vercel-sandbox/src/volume.ts rename to packages/vercel-sandbox/src/drive.ts index f42b8513..34a350a9 100644 --- a/packages/vercel-sandbox/src/volume.ts +++ b/packages/vercel-sandbox/src/drive.ts @@ -1,23 +1,23 @@ import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; import type { WithFetchOptions } from "./api-client/api-client.js"; -import type { VolumeMetadata } from "./api-client/index.js"; +import type { DriveMetadata } from "./api-client/index.js"; import { APIClient } from "./api-client/index.js"; import { type Credentials, getCredentials } from "./utils/get-credentials.js"; import { attachPaginator } from "./utils/paginator.js"; -export interface SerializedVolume { - volume: VolumeMetadata; +export interface SerializedDrive { + drive: DriveMetadata; projectId?: string; } /** @inline */ -interface GetOrCreateVolumeParams { +interface GetOrCreateDriveParams { /** - * The name of the volume to get or create. Must be unique within the project. + * The name of the drive to get or create. Must be unique within the project. */ name: string; /** - * Maximum volume size in bytes. If omitted, a default of 100 GiB is used. + * Maximum drive size in bytes. If omitted, a default of 100 GiB is used. */ maxSize?: number; /** @@ -27,15 +27,15 @@ interface GetOrCreateVolumeParams { } /** - * A Volume is a persistent, bottomless storage that can be attached and detached to Sandboxes. - * Volumes can be mounted as read-write or read-only, at a configurable path with `Sandbox.create()`. + * A Drive is a persistent, bottomless storage that can be attached and detached to Sandboxes. + * Drives can be mounted as read-write or read-only, at a configurable path with `Sandbox.create()`. * - * Use {@link Volume.getOrCreate} to construct. + * Use {@link Drive.getOrCreate} to construct. * @hideconstructor */ -export class Volume { +export class Drive { private _client: APIClient | null = null; - private volume: VolumeMetadata; + private drive: DriveMetadata; private readonly _projectId: string; /** @@ -54,113 +54,113 @@ export class Volume { } /** - * The name of the volume. + * The name of the drive. */ public get name(): string { - return this.volume.name; + return this.drive.name; } /** - * The project ID that owns the volume. + * The project ID that owns the drive. */ public get projectId(): string { return this._projectId; } /** - * The maximum volume size in bytes. + * The maximum drive size in bytes. */ public get maxSize(): number { - return this.volume.maxSizeBytes; + return this.drive.maxSizeBytes; } /** - * Current session ID the volume is attached to, if any. + * Current session ID the drive is attached to, if any. */ public get currentSessionId(): string | undefined { - return this.volume.currentSessionId; + return this.drive.currentSessionId; } /** - * Current sandbox name the volume is attached to, if any. + * Current sandbox name the drive is attached to, if any. */ public get currentSandboxName(): string | undefined { - return this.volume.currentSandboxName; + return this.drive.currentSandboxName; } /** - * Timestamp when the volume was created. + * Timestamp when the drive was created. */ public get createdAt(): Date { - return new Date(this.volume.createdAt); + return new Date(this.drive.createdAt); } /** - * Timestamp when the volume was last updated. + * Timestamp when the drive was last updated. */ public get updatedAt(): Date { - return new Date(this.volume.updatedAt); + return new Date(this.drive.updatedAt); } /** - * Serialize a Volume instance to plain data for @workflow/serde. + * Serialize a Drive instance to plain data for @workflow/serde. * - * @param instance - The Volume instance to serialize - * @returns A plain object containing volume metadata + * @param instance - The Drive instance to serialize + * @returns A plain object containing drive metadata */ - static [WORKFLOW_SERIALIZE](instance: Volume): SerializedVolume { + static [WORKFLOW_SERIALIZE](instance: Drive): SerializedDrive { return { - volume: instance.volume, + drive: instance.drive, projectId: instance._projectId, }; } /** - * Deserialize a Volume from serialized data. + * Deserialize a Drive from serialized data. * * The deserialized instance uses the serialized metadata synchronously and * lazily creates an API client only when methods perform API requests. * - * @param data - The serialized volume data - * @returns The reconstructed Volume instance + * @param data - The serialized drive data + * @returns The reconstructed Drive instance */ - static [WORKFLOW_DESERIALIZE](data: SerializedVolume): Volume { - return new Volume({ - volume: data.volume, + static [WORKFLOW_DESERIALIZE](data: SerializedDrive): Drive { + return new Drive({ + drive: data.drive, projectId: data.projectId, }); } constructor({ client, - volume, + drive, projectId, }: { client?: APIClient; - volume: VolumeMetadata; + drive: DriveMetadata; projectId?: string; }) { this._client = client ?? null; - this.volume = volume; - this._projectId = projectId ?? volume.projectId; + this.drive = drive; + this._projectId = projectId ?? drive.projectId; } /** - * Allow to get a list of volumes for a team narrowed to the given params. - * It returns both the volumes and the pagination metadata to allow getting + * Allow to get a list of drives for a team narrowed to the given params. + * It returns both the drives and the pagination metadata to allow getting * the next page of results. * * The returned object is async-iterable to auto-paginate through all pages: * * ```ts - * const result = await Volume.list({ limit: 10 }); - * for await (const volume of result) { ... } + * const result = await Drive.list({ limit: 10 }); + * for await (const drive of result) { ... } * // or: await result.toArray(); * // or: for await (const page of result.pages()) { ... } * ``` */ static async list( - params?: Partial[0]> & + params?: Partial[0]> & Partial & WithFetchOptions, ) { @@ -172,18 +172,18 @@ export class Volume { fetch: params?.fetch, }); const fetchPage = async (cursor?: string | number) => { - const response = await client.listVolumes({ + const response = await client.listDrives({ ...credentials, ...params, ...(cursor !== undefined && { cursor }), }); return { ...response.json, - volumes: response.json.volumes.map( - (volume) => - new Volume({ + drives: response.json.drives.map( + (drive) => + new Drive({ client, - volume, + drive, projectId: credentials.projectId, }), ), @@ -191,25 +191,25 @@ export class Volume { }; const firstPage = await fetchPage(params?.cursor ?? params?.until); return attachPaginator(firstPage, { - itemsKey: "volumes", + itemsKey: "drives", fetchNext: fetchPage, signal: params?.signal, }); } /** - * Retrieve an existing volume, or create a new one if it doesn't exists. + * Retrieve an existing drive, or create a new one if it doesn't exists. * * @param params - Get/create parameters and optional credentials. - * @returns A promise resolving to the {@link Volume}. + * @returns A promise resolving to the {@link Drive}. */ static async getOrCreate( params: ( - | GetOrCreateVolumeParams - | (GetOrCreateVolumeParams & Credentials) + | GetOrCreateDriveParams + | (GetOrCreateDriveParams & Credentials) ) & WithFetchOptions, - ): Promise { + ): Promise { "use step"; const credentials = await getCredentials(params); const client = new APIClient({ @@ -218,37 +218,37 @@ export class Volume { fetch: params.fetch, }); - const response = await client.getOrCreateVolume({ + const response = await client.getOrCreateDrive({ projectId: credentials.projectId, name: params.name, maxSizeBytes: params.maxSize, signal: params.signal, }); - return new Volume({ + return new Drive({ client, - volume: response.json.volume, + drive: response.json.drive, projectId: credentials.projectId, }); } /** - * Delete this volume. The volume must not be attached to any sandbox. - * This operation is irreversible and will delete all data stored in the volume. + * Delete this drive. The drive must not be attached to any sandbox. + * This operation is irreversible and will delete all data stored in the drive. * * @param opts - Optional parameters. * @param opts.signal - An AbortSignal to cancel the operation. - * @returns A promise that resolves once the volume has been deleted. + * @returns A promise that resolves once the drive has been deleted. */ async delete(opts?: { signal?: AbortSignal }): Promise { "use step"; const client = await this.ensureClient(); - const response = await client.deleteVolume({ + const response = await client.deleteDrive({ projectId: this._projectId, - name: this.volume.name, + name: this.drive.name, signal: opts?.signal, }); - this.volume = response.json.volume; + this.drive = response.json.drive; } } diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index e0d18af9..9ea337c4 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -15,8 +15,8 @@ export { export type { SerializedSandbox } from "./sandbox.js"; export { Snapshot } from "./snapshot.js"; export type { SerializedSnapshot } from "./snapshot.js"; -export { Volume } from "./volume.js"; -export type { SerializedVolume } from "./volume.js"; +export { Drive } from "./drive.js"; +export type { SerializedDrive } from "./drive.js"; export type { SnapshotTreeNodeData } from "./api-client/validators.js"; export { Command, CommandFinished } from "./command.js"; export type { diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index f00786c9..21d64628 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -423,7 +423,7 @@ describe("Sandbox.create mounts", () => { name: "my-sandbox", mounts: { "/mnt/storage": { - volume: "my-volume", + drive: "my-drive", mode: "read-only", }, }, @@ -436,7 +436,7 @@ describe("Sandbox.create mounts", () => { projectId: "proj_123", mounts: { "/mnt/storage": { - volume: "my-volume", + drive: "my-drive", mode: "read-only", }, }, diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 1c41a2ac..aafd84b7 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -110,16 +110,16 @@ export interface BaseCreateSandboxParams { tags?: Record; /** - * List of volumes to attach to the sandbox, keyed by the desired mount path. - * The volume must be created beforehand with `Volume.getOrCreate`. + * List of drives to attach to the sandbox, keyed by the desired mount path. + * The drive must be created beforehand with `Drive.getOrCreate`. * * The mount paths must be absolute and cannot overlap with each other. * * @example - * const volume = await Volume.getOrCreate({ name: "my-volume" }); + * const drive = await Drive.getOrCreate({ name: "my-drive" }); * const sandbox = await Sandbox.create({ * mounts: { - * "/data": { volume: volume.name, mode: "read-write" }, + * "/data": { drive: drive.name, mode: "read-write" }, * }, * }); */ @@ -127,9 +127,9 @@ export interface BaseCreateSandboxParams { string, { /** - * The volume name to mount. + * The drive name to mount. */ - volume: string; + drive: string; /** * Mount mode. Defaults to `read-write` if unspecified. */ @@ -482,7 +482,7 @@ export class Sandbox { } /** - * Volumes mounted on the sandbox, keyed by mount path. + * Drives mounted on the sandbox, keyed by mount path. */ public get mounts(): SandboxMounts | undefined { return this.sandbox.mounts; From d669c0b0adea9314488350816c9af2b5287d26e2 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Mon, 1 Jun 2026 16:38:33 -0400 Subject: [PATCH 12/13] Add changeset pre --- .changeset/pre.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..21dba697 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,19 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "ai-example": "0.1.1", + "charts-python-example": "0.1.4", + "dev-server-example": "0.1.4", + "sandbox-filesystem-snapshots-example": "0.0.17", + "install-packages-example": "0.1.4", + "private-repo-example": "0.1.4", + "sandbox-basics-example": "0.1.4", + "workflow-code-runner-example": "0.1.6", + "@vercel/pty-tunnel": "2.0.3", + "@vercel/pty-tunnel-server": "0.0.2", + "sandbox": "3.0.0", + "@vercel/sandbox": "2.0.0" + }, + "changesets": [] +} From 4ffd2c176ea7f1dddc62c9d21e89083d234a388c Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Mon, 1 Jun 2026 16:51:45 -0400 Subject: [PATCH 13/13] Bump beta version --- .changeset/pre.json | 4 +++- packages/sandbox/CHANGELOG.md | 11 +++++++++++ packages/sandbox/docs/index.md | 4 ++-- packages/sandbox/package.json | 2 +- packages/vercel-sandbox/CHANGELOG.md | 6 ++++++ packages/vercel-sandbox/package.json | 2 +- packages/vercel-sandbox/src/version.ts | 2 +- 7 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 21dba697..f4d9c83a 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -15,5 +15,7 @@ "sandbox": "3.0.0", "@vercel/sandbox": "2.0.0" }, - "changesets": [] + "changesets": [ + "famous-pugs-scream" + ] } diff --git a/packages/sandbox/CHANGELOG.md b/packages/sandbox/CHANGELOG.md index d84d6345..70ece6e3 100644 --- a/packages/sandbox/CHANGELOG.md +++ b/packages/sandbox/CHANGELOG.md @@ -1,5 +1,16 @@ # sandbox +## 3.2.0-beta.0 + +### Minor Changes + +- Add support for drives via a new `Drive` class and CLI commands. ([#196](https://github.com/vercel/sandbox/pull/196)) + +### Patch Changes + +- Updated dependencies [[`d51a1b7cd19de15018dab2140cbc08ba646c9e2f`](https://github.com/vercel/sandbox/commit/d51a1b7cd19de15018dab2140cbc08ba646c9e2f)]: + - @vercel/sandbox@2.2.0-beta.0 + ## 3.1.0 ### Minor Changes diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index fdaae245..2b6b78e9 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -1,7 +1,7 @@ ## `sandbox --help` ``` -sandbox 3.1.0 +sandbox 3.2.0-beta.0 ▲ sandbox [options] @@ -400,7 +400,7 @@ Commands: ls | list List snapshots for the specified account and project. get Get details of a snapshot. tree Show the snapshot ancestry tree for a sandbox. - rm | remove [...snapshot_id] Delete one or more snapshots. + rm | delete [...snapshot_id] Delete one or more snapshots. ``` ## `sandbox drives` diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 53ded2c4..46d8d6fd 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "sandbox", "description": "Command line interface for Vercel Sandbox", - "version": "3.1.0", + "version": "3.2.0-beta.0", "scripts": { "clean": "rm -rf node_modules dist", "sandbox": "ts-node ./src/sandbox.ts", diff --git a/packages/vercel-sandbox/CHANGELOG.md b/packages/vercel-sandbox/CHANGELOG.md index 9a757b38..ba64b529 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -1,5 +1,11 @@ # @vercel/sandbox +## 2.2.0-beta.0 + +### Minor Changes + +- Add support for drives via a new `Drive` class and CLI commands. ([#196](https://github.com/vercel/sandbox/pull/196)) + ## 2.1.0 ### Minor Changes diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index f08fa9ab..b26dd58f 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/sandbox", - "version": "2.1.0", + "version": "2.2.0-beta.0", "description": "Software Development Kit for Vercel Sandbox", "type": "module", "main": "dist/index.cjs", diff --git a/packages/vercel-sandbox/src/version.ts b/packages/vercel-sandbox/src/version.ts index 3758a100..513d68c1 100644 --- a/packages/vercel-sandbox/src/version.ts +++ b/packages/vercel-sandbox/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by inject-version.ts -export const VERSION = "2.1.0"; +export const VERSION = "2.2.0-beta.0";