diff --git a/.changeset/pre.json b/.changeset/pre.json index 0327cfb..feb5dc8 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -30,6 +30,7 @@ "light-results-change", "open-planets-joke", "public-ears-heal", + "rich-areas-pick", "seven-olives-mate", "slick-colts-learn", "some-numbers-arrive", diff --git a/.changeset/rich-areas-pick.md b/.changeset/rich-areas-pick.md new file mode 100644 index 0000000..e75ddd4 --- /dev/null +++ b/.changeset/rich-areas-pick.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Support new keepLastSnapshots parameter for CLI and SDK diff --git a/packages/sandbox/CHANGELOG.md b/packages/sandbox/CHANGELOG.md index 1b04b0b..e588b56 100644 --- a/packages/sandbox/CHANGELOG.md +++ b/packages/sandbox/CHANGELOG.md @@ -1,5 +1,16 @@ # sandbox +## 3.0.0-beta.18 + +### Minor Changes + +- Support new keepLastSnapshots parameter for CLI and SDK + +### Patch Changes + +- Updated dependencies []: + - @vercel/sandbox@2.0.0-beta.16 + ## 3.0.0-beta.17 ### Minor Changes diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index 8fe62dc..426afa5 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -1,7 +1,7 @@ ## `sandbox --help` ``` -sandbox 3.0.0-beta.17 +sandbox 3.0.0-beta.18 ▲ sandbox [options] @@ -78,23 +78,26 @@ Create and run a command in a sandbox Options: - --name A user-chosen name for the sandbox. It must be unique per project. [optional] - --runtime One of 'node22', 'node24', 'python3.13' [default: node24] - --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] - --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] - --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run - --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) - --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] - --network-policy Network policy mode: "allow-all" or "deny-all" + --name A user-chosen name for the sandbox. It must be unique per project. [optional] + --runtime One of 'node22', 'node24', 'python3.13' [default: node24] + --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] + --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] + --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run + --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) + --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] + --delete-evicted-snapshots When "true" (the default), evicted snapshots are deleted immediately; when "false", they keep the default expiration. [optional] + --network-policy Network policy mode: "allow-all" or "deny-all" - allow-all: sandbox can access any website/domain - deny-all: sandbox has no network access Omit this option and use --allowed-domain / --allowed-cidr / --denied-cidr for custom policies. [optional] - --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. - --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. - --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. - --workdir, -w The working directory to run the command in [optional] + --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. + --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. + --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. + --workdir, -w The working directory to run the command in [optional] Flags: @@ -132,22 +135,25 @@ Create a sandbox in the specified account and project. Options: - --name A user-chosen name for the sandbox. It must be unique per project. [optional] - --runtime One of 'node22', 'node24', 'python3.13' [default: node24] - --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] - --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] - --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run - --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) - --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] - --network-policy Network policy mode: "allow-all" or "deny-all" + --name A user-chosen name for the sandbox. It must be unique per project. [optional] + --runtime One of 'node22', 'node24', 'python3.13' [default: node24] + --timeout The maximum duration a sandbox can run for. Example: 5m, 30m [default: 5 minutes] + --vcpus Number of vCPUs to allocate (each vCPU includes 2048 MB of memory) [optional] + --publish-port , -p= Publish sandbox port(s) to DOMAIN.vercel.run + --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) + --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] + --delete-evicted-snapshots When "true" (the default), evicted snapshots are deleted immediately; when "false", they keep the default expiration. [optional] + --network-policy Network policy mode: "allow-all" or "deny-all" - allow-all: sandbox can access any website/domain - deny-all: sandbox has no network access Omit this option and use --allowed-domain / --allowed-cidr / --denied-cidr for custom policies. [optional] - --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. - --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. - --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. + --allowed-domain Domain to allow traffic to (creates a custom network policy). Supports "*" for wildcards for a segment (e.g. '*.vercel.com', 'www.*.com'). If used as the first segment, will match any subdomain. + --allowed-cidr CIDR to allow traffic to (creates a custom network policy). Takes precedence over 'allowed-domain'. + --denied-cidr CIDR to deny traffic to (creates a custom network policy). Takes precedence over allowed domains/CIDRs. Flags: @@ -348,6 +354,7 @@ Commands: persistent Enable or disable automatic restore of the filesystem between sessions network-policy Update the network policy of a sandbox snapshot-expiration Update the default snapshot expiration of a sandbox + keep-last-snapshots [COUNT] Update the snapshot retention policy (keep only the N most recent snapshots) of a sandbox current-snapshot Update the current snapshot of a sandbox tags Update the tags of a sandbox. Replaces all existing tags with the provided tags. ``` diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 3b730e4..cde5a26 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.0.0-beta.17", + "version": "3.0.0-beta.18", "scripts": { "clean": "rm -rf node_modules dist", "sandbox": "ts-node ./src/sandbox.ts", diff --git a/packages/sandbox/src/commands/config.ts b/packages/sandbox/src/commands/config.ts index 2de7c55..5756c4d 100644 --- a/packages/sandbox/src/commands/config.ts +++ b/packages/sandbox/src/commands/config.ts @@ -202,6 +202,147 @@ const snapshotExpirationCommand = cmd.command({ }, }); +const keepLastCountType = cmd.extendType(cmd.number, { + displayName: "COUNT", + async from(n) { + if (!Number.isInteger(n) || n < 1 || n > 10) { + throw new Error( + `Invalid count: ${n}. Must be an integer between 1 and 10.`, + ); + } + return n; + }, +}); + +const keepLastSnapshotsCommand = cmd.command({ + name: "keep-last-snapshots", + description: + "Update the snapshot retention policy (keep only the N most recent snapshots) of a sandbox", + args: { + sandbox: cmd.positional({ + type: sandboxName, + description: "Sandbox name to update", + }), + count: cmd.positional({ + type: cmd.optional(keepLastCountType), + description: + "Number of most recent snapshots to keep (1-10). Omit with --clear to remove the policy.", + }), + keepLastSnapshotsFor: cmd.option({ + long: "keep-last-snapshots-for", + type: cmd.optional(SnapshotExpiration), + description: + 'Expiration for kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d', + }), + deleteEvictedSnapshots: cmd.option({ + long: "delete-evicted-snapshots", + type: cmd.optional({ + ...cmd.oneOf(["true", "false"]), + displayName: "true|false", + }), + description: + 'When "true" (the default), evicted snapshots are deleted immediately; when "false", they keep the default expiration.', + }), + clear: cmd.flag({ + long: "clear", + description: + "Remove the snapshot retention policy from this sandbox.", + }), + scope, + }, + async handler({ + scope: { token, team, project }, + sandbox: name, + count, + keepLastSnapshotsFor, + deleteEvictedSnapshots, + clear, + }) { + if (clear && count !== undefined) { + throw new Error( + "Cannot combine --clear with a argument. Pass one or the other.", + ); + } + if (!clear && count === undefined) { + throw new Error( + [ + "Missing argument.", + `${chalk.bold("hint:")} Pass a count between 1 and 10, or --clear to remove the policy.`, + ].join("\n"), + ); + } + if ( + clear && + (keepLastSnapshotsFor !== undefined || + deleteEvictedSnapshots !== undefined) + ) { + throw new Error( + "--keep-last-snapshots-for and --delete-evicted-snapshots cannot be combined with --clear.", + ); + } + + const sandbox = await sandboxClient.get({ + name, + projectId: project, + teamId: team, + token, + }); + + const spinner = ora("Updating sandbox configuration...").start(); + try { + if (clear) { + await sandbox.update({ keepLastSnapshots: null }); + } else { + await sandbox.update({ + keepLastSnapshots: { + count: count!, + expiration: + keepLastSnapshotsFor !== undefined + ? ms(keepLastSnapshotsFor) + : undefined, + deleteEvicted: + deleteEvictedSnapshots !== undefined + ? deleteEvictedSnapshots === "true" + : undefined, + }, + }); + } + spinner.stop(); + + process.stderr.write( + "✅ Configuration updated for sandbox " + chalk.cyan(name) + "\n", + ); + if (clear) { + process.stderr.write( + chalk.dim(" ╰ ") + + "keep-last-snapshots: " + + chalk.cyan("cleared") + + "\n", + ); + } else { + const parts: string[] = [`count=${count}`]; + if (keepLastSnapshotsFor !== undefined) { + const displayExp = + ms(keepLastSnapshotsFor) === 0 ? "none" : keepLastSnapshotsFor; + parts.push(`for=${displayExp}`); + } + if (deleteEvictedSnapshots === "false") { + parts.push("delete-evicted-snapshots=false"); + } + process.stderr.write( + chalk.dim(" ╰ ") + + "keep-last-snapshots: " + + chalk.cyan(parts.join(", ")) + + "\n", + ); + } + } catch (error) { + spinner.stop(); + throw error; + } + }, +}); + const currentSnapshotCommand = cmd.command({ name: "current-snapshot", description: "Update the current snapshot of a sandbox", @@ -291,6 +432,7 @@ const listCommand = cmd.command({ { field: "Persistent", value: String(sandbox.persistent) }, { field: "Network policy", value: String(networkPolicy) }, { field: "Snapshot expiration", value: sandbox.snapshotExpiration != null && sandbox.snapshotExpiration > 0 ? ms(sandbox.snapshotExpiration, { long: true }) : sandbox.snapshotExpiration === 0 ? "none" : "-" }, + { field: "Keep last snapshots", value: formatKeepLastSnapshots(sandbox.keepLastSnapshots) }, { field: "Current snapshot", value: sandbox.currentSnapshotId ?? "-" }, { field: "Tags", value: tagsDisplay }, ]; @@ -432,6 +574,24 @@ const tagsCommand = cmd.command({ }, }); +function formatKeepLastSnapshots( + keepLastSnapshots: + | { count: number; expiration?: number; deleteEvicted: boolean } + | undefined, +): string { + if (!keepLastSnapshots) return "-"; + const parts = [`count=${keepLastSnapshots.count}`]; + if (keepLastSnapshots.expiration !== undefined) { + parts.push( + `for=${keepLastSnapshots.expiration === 0 ? "none" : ms(keepLastSnapshots.expiration, { long: true })}`, + ); + } + if (!keepLastSnapshots.deleteEvicted) { + parts.push("delete-evicted-snapshots=false"); + } + return parts.join(", "); +} + export const config = cmd.subcommands({ name: "config", description: "View and update sandbox configuration", @@ -442,6 +602,7 @@ export const config = cmd.subcommands({ persistent: persistentCommand, "network-policy": networkPolicyCommand, "snapshot-expiration": snapshotExpirationCommand, + "keep-last-snapshots": keepLastSnapshotsCommand, "current-snapshot": currentSnapshotCommand, tags: tagsCommand, }, diff --git a/packages/sandbox/src/commands/create.ts b/packages/sandbox/src/commands/create.ts index f555725..9f1697c 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -81,6 +81,39 @@ export const args = { type: cmd.optional(SnapshotExpiration), description: 'Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d', }), + keepLastSnapshots: cmd.option({ + long: "keep-last-snapshots", + type: cmd.optional( + cmd.extendType(cmd.number, { + displayName: "COUNT", + async from(n) { + if (!Number.isInteger(n) || n < 1 || n > 10) { + throw new Error( + `Invalid --keep-last-snapshots value: ${n}. Must be an integer between 1 and 10.`, + ); + } + return n; + }, + }), + ), + description: + "Keep only the N most recent snapshots of this sandbox (1-10).", + }), + keepLastSnapshotsFor: cmd.option({ + long: "keep-last-snapshots-for", + type: cmd.optional(SnapshotExpiration), + description: + 'Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d', + }), + deleteEvictedSnapshots: cmd.option({ + long: "delete-evicted-snapshots", + type: cmd.optional({ + ...cmd.oneOf(["true", "false"]), + displayName: "true|false", + }), + description: + 'When "true" (the default), evicted snapshots are deleted immediately; when "false", they keep the default expiration.', + }), ...networkPolicyArgs, scope, } as const; @@ -109,6 +142,9 @@ export const create = cmd.command({ envVars, tags, snapshotExpiration, + keepLastSnapshots, + keepLastSnapshotsFor, + deleteEvictedSnapshots, networkPolicy: networkPolicyMode, allowedDomains, allowedCIDRs, @@ -121,6 +157,34 @@ export const create = cmd.command({ deniedCIDRs, }); + if ( + keepLastSnapshots === undefined && + (keepLastSnapshotsFor !== undefined || + deleteEvictedSnapshots !== undefined) + ) { + throw new Error( + [ + "--keep-last-snapshots-for and --delete-evicted-snapshots require --keep-last-snapshots.", + `${chalk.bold("hint:")} Pass --keep-last-snapshots to enable the retention policy.`, + ].join("\n"), + ); + } + + const keepLastSnapshotsPayload = + keepLastSnapshots !== undefined + ? { + count: keepLastSnapshots, + expiration: + keepLastSnapshotsFor !== undefined + ? ms(keepLastSnapshotsFor) + : undefined, + deleteEvicted: + deleteEvictedSnapshots !== undefined + ? deleteEvictedSnapshots === "true" + : undefined, + } + : undefined; + const persistent = !nonPersistent const resources = vcpus ? { vcpus } : undefined; const tagsObj = Object.keys(tags).length > 0 ? tags : undefined; @@ -140,6 +204,7 @@ export const create = cmd.command({ tags: tagsObj, persistent, snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, + keepLastSnapshots: keepLastSnapshotsPayload, __interactive: true, }) : await sandboxClient.create({ @@ -156,6 +221,7 @@ export const create = cmd.command({ tags: tagsObj, persistent, snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, + keepLastSnapshots: keepLastSnapshotsPayload, __interactive: true, }); spinner?.stop(); diff --git a/packages/sandbox/src/commands/snapshots.ts b/packages/sandbox/src/commands/snapshots.ts index 599e17b..5c5a37b 100644 --- a/packages/sandbox/src/commands/snapshots.ts +++ b/packages/sandbox/src/commands/snapshots.ts @@ -124,9 +124,20 @@ const remove = cmd.command({ type: snapshotId, description: "More snapshots IDs to delete", }), + force: cmd.flag({ + long: "force", + short: "f", + description: + "Delete even if the snapshot is currently set as a sandbox's most recent snapshot. Skips the pre-flight status check.", + }), scope, }, - async handler({ scope: { team, token, project }, snapshotId, snapshotIds }) { + async handler({ + scope: { team, token, project }, + snapshotId, + snapshotIds, + force, + }) { const tasks = Array.from( new Set([snapshotId, ...snapshotIds]), (snapshotId) => { @@ -144,7 +155,7 @@ const remove = cmd.command({ `Snapshot ${snapshotId} is in status "${snapshot.status}" and cannot be deleted.`, ); } - await snapshot.delete(); + await snapshot.delete({ forceDelete: force }); }, }; }, diff --git a/packages/vercel-sandbox/CHANGELOG.md b/packages/vercel-sandbox/CHANGELOG.md index b4cba36..b1acc63 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -1,5 +1,11 @@ # @vercel/sandbox +## 2.0.0-beta.16 + +### Minor Changes + +- Support new keepLastSnapshots parameter for CLI and SDK + ## 2.0.0-beta.15 ### Minor Changes diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 5650819..d132566 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/sandbox", - "version": "2.0.0-beta.15", + "version": "2.0.0-beta.16", "description": "Software Development Kit for Vercel Sandbox", "type": "module", "main": "dist/index.cjs", diff --git a/packages/vercel-sandbox/src/api-client/api-client.test.ts b/packages/vercel-sandbox/src/api-client/api-client.test.ts index 84f954e..84680c0 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.test.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.test.ts @@ -966,4 +966,225 @@ describe("APIClient", () => { ); }); }); + + describe("deleteSnapshot", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const snapshotResponse = () => + new Response( + JSON.stringify({ + snapshot: { + id: "snap_123", + sourceSessionId: "sbx_123", + region: "iad1", + status: "deleted", + sizeBytes: 1024, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }), + { headers: { "content-type": "application/json" } }, + ); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("does not send forceDelete query param by default", async () => { + mockFetch.mockResolvedValue(snapshotResponse()); + + await client.deleteSnapshot({ snapshotId: "snap_123" }); + + const [url, opts] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/v2/sandboxes/snapshots/snap_123"); + expect(String(url)).not.toContain("forceDelete"); + expect(opts.method).toBe("DELETE"); + }); + + it("appends forceDelete=true when forceDelete is set", async () => { + mockFetch.mockResolvedValue(snapshotResponse()); + + await client.deleteSnapshot({ + snapshotId: "snap_123", + forceDelete: true, + }); + + const [url] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("forceDelete=true"); + }); + + it("omits forceDelete when explicitly false", async () => { + mockFetch.mockResolvedValue(snapshotResponse()); + + await client.deleteSnapshot({ + snapshotId: "snap_123", + forceDelete: false, + }); + + const [url] = mockFetch.mock.calls[0]; + expect(String(url)).not.toContain("forceDelete"); + }); + }); + + describe("createSandbox with keepLastSnapshots", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const sandboxResponse = () => + new Response( + JSON.stringify({ + sandbox: { + name: "my-sandbox", + persistent: true, + region: "iad1", + vcpus: 1, + memory: 2048, + runtime: "node24", + timeout: 300000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running", + currentSessionId: "sbx_123", + keepLastSnapshots: { + count: 3, + expiration: 604800000, + deleteEvicted: true, + }, + }, + session: { + id: "sbx_123", + memory: 2048, + vcpus: 1, + region: "iad1", + runtime: "node24", + timeout: 300000, + status: "running", + requestedAt: Date.now(), + createdAt: Date.now(), + cwd: "/", + updatedAt: Date.now(), + }, + routes: [], + }), + { headers: { "content-type": "application/json" } }, + ); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("forwards keepLastSnapshots in the request body", async () => { + mockFetch.mockResolvedValue(sandboxResponse()); + + await client.createSandbox({ + projectId: "proj_123", + keepLastSnapshots: { + count: 3, + expiration: 604800000, + deleteEvicted: true, + }, + }); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.keepLastSnapshots).toEqual({ + count: 3, + expiration: 604800000, + deleteEvicted: true, + }); + }); + + it("omits keepLastSnapshots when not provided", async () => { + mockFetch.mockResolvedValue(sandboxResponse()); + + await client.createSandbox({ projectId: "proj_123" }); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body).not.toHaveProperty("keepLastSnapshots"); + }); + }); + + describe("updateSandbox with keepLastSnapshots", () => { + let client: APIClient; + let mockFetch: ReturnType; + + const makeSandboxMetadata = () => ({ + name: "my-sandbox", + persistent: true, + region: "iad1", + vcpus: 2, + memory: 4096, + runtime: "node24", + timeout: 600000, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "running" as const, + currentSessionId: "sbx_123", + }); + + beforeEach(() => { + mockFetch = vi.fn(); + client = new APIClient({ + teamId: "team_123", + token: "1234", + fetch: mockFetch, + }); + }); + + it("forwards keepLastSnapshots in the PATCH body", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ sandbox: makeSandboxMetadata() }), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.updateSandbox({ + name: "my-sandbox", + projectId: "proj_123", + keepLastSnapshots: { count: 5, expiration: 0, deleteEvicted: false }, + }); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.keepLastSnapshots).toEqual({ + count: 5, + expiration: 0, + deleteEvicted: false, + }); + }); + + it("sends null to clear keepLastSnapshots", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ sandbox: makeSandboxMetadata() }), { + headers: { "content-type": "application/json" }, + }), + ); + + await client.updateSandbox({ + name: "my-sandbox", + projectId: "proj_123", + keepLastSnapshots: null, + }); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + // Presence of the key with null — not undefined/missing — is the signal. + expect(Object.prototype.hasOwnProperty.call(body, "keepLastSnapshots")).toBe( + true, + ); + expect(body.keepLastSnapshots).toBeNull(); + }); + }); }); diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index a1d325a..3e0dcfd 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -176,6 +176,11 @@ export class APIClient extends BaseClient { env?: Record; tags?: Record; snapshotExpiration?: number; + keepLastSnapshots?: { + count: number; + expiration?: number; + deleteEvicted?: boolean; + }; signal?: AbortSignal; }>, ) { @@ -199,6 +204,7 @@ export class APIClient extends BaseClient { env: params.env, tags: params.tags, snapshotExpiration: params.snapshotExpiration, + keepLastSnapshots: params.keepLastSnapshots, ...privateParams, }), signal: params.signal, @@ -690,12 +696,19 @@ export class APIClient extends BaseClient { async deleteSnapshot(params: { snapshotId: string; + forceDelete?: boolean; signal?: AbortSignal; }): Promise>> { const url = `/v2/sandboxes/snapshots/${params.snapshotId}`; return parseOrThrow( SnapshotResponse, - await this.request(url, { method: "DELETE", signal: params.signal }), + await this.request(url, { + method: "DELETE", + query: { + forceDelete: params.forceDelete ? "true" : undefined, + }, + signal: params.signal, + }), ); } @@ -771,6 +784,11 @@ export class APIClient extends BaseClient { networkPolicy?: NetworkPolicy; tags?: Record; snapshotExpiration?: number; + keepLastSnapshots?: { + count: number; + expiration?: number; + deleteEvicted?: boolean; + } | null; currentSnapshotId?: string; signal?: AbortSignal; }) { @@ -791,6 +809,7 @@ export class APIClient extends BaseClient { : undefined, tags: params.tags, snapshotExpiration: params.snapshotExpiration, + keepLastSnapshots: params.keepLastSnapshots, currentSnapshotId: params.currentSnapshotId, }), signal: params.signal, diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index e48d660..41766d6 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -185,6 +185,13 @@ export const Sandbox = z.object({ cwd: z.string().optional(), tags: z.record(z.string()).optional(), snapshotExpiration: z.number().optional(), + keepLastSnapshots: z + .object({ + count: z.number(), + expiration: z.number().optional(), + deleteEvicted: z.boolean(), + }) + .optional(), }); export type SandboxMetaData = z.infer; diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 38b5425..1a8b6a7 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -416,8 +416,17 @@ for (const port of ports) { }); it("reflects updated resources after update", async () => { - const sandbox = await Sandbox.create({ timeout: 60_000, persistent: true, snapshotExpiration: 7 * 86400000 }); + const sandbox = await Sandbox.create({ + timeout: 60_000, + persistent: true, + snapshotExpiration: 7 * 86400000, + keepLastSnapshots: { count: 3 }, + }); expect(sandbox.snapshotExpiration).toBe(7 * 86400000); + expect(sandbox.keepLastSnapshots).toMatchObject({ + count: 3, + deleteEvicted: true, + }); await sandbox.stop(); const { snapshotId } = await sandbox.snapshot(); @@ -427,6 +436,11 @@ for (const port of ports) { timeout: 30_000, persistent: false, snapshotExpiration: 2 * 86400000, + keepLastSnapshots: { + count: 5, + expiration: 3 * 86400000, + deleteEvicted: false, + }, currentSnapshotId: snapshotId, }); @@ -439,9 +453,59 @@ for (const port of ports) { expect(updated.timeout).toBe(30_000); expect(updated.persistent).toBe(false); expect(updated.snapshotExpiration).toBe(2 * 86400000); + expect(updated.keepLastSnapshots).toEqual({ + count: 5, + expiration: 3 * 86400000, + deleteEvicted: false, + }); expect(updated.currentSnapshotId).toBe(snapshotId); }); + it("clears keepLastSnapshots when updated with null", async () => { + const sandbox = await Sandbox.create({ + persistent: true, + keepLastSnapshots: { + count: 2, + expiration: 7 * 86400000, + deleteEvicted: true, + }, + }); + expect(sandbox.keepLastSnapshots).toMatchObject({ count: 2 }); + + await sandbox.update({ keepLastSnapshots: null }); + + const cleared = await Sandbox.get({ + name: sandbox.name, + resume: false, + }); + expect(cleared.keepLastSnapshots).toBeUndefined(); + }); + + it("rejects snapshot deletion when the snapshot is in use, unless forceDelete is set", async () => { + const sandbox = await Sandbox.create({ + name: `force-delete-${Date.now().toString(36)}`, + persistent: true, + }); + try { + const snapshot = await sandbox.snapshot(); + // The snapshot is now the sandbox's currentSnapshotId — deletion must fail. + await expect(snapshot.delete()).rejects.toMatchObject({ + response: { status: 400 }, + json: { + error: { + message: expect.stringMatching(/in use/i), + }, + }, + }); + + // With forceDelete the same call succeeds. + await snapshot.delete({ forceDelete: true }); + expect(snapshot.status).toBe("deleted"); + } finally { + await sandbox.delete(); + } + }); + it("appears in the sandbox list after creation", async () => { const sandbox = await Sandbox.create(); await sandbox.stop(); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 8fcd6fb..00cc71b 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -112,6 +112,26 @@ export interface BaseCreateSandboxParams { * Use `0` for no expiration. */ snapshotExpiration?: number; + /** + * Retention policy that keeps only the N most recent snapshots of this + * sandbox. Older snapshots are evicted when a new one is created. + */ + keepLastSnapshots?: { + /** + * Number of snapshots to keep (1-10). + */ + count: number; + /** + * Expiration in milliseconds applied to kept snapshots. + * Use `0` for no expiration. Falls back to `snapshotExpiration` when omitted. + */ + expiration?: number; + /** + * When `true` (the default), evicted snapshots are deleted immediately; + * when `false`, they keep the default expiration. + */ + deleteEvicted?: boolean; + }; /** * Called when the sandbox session is resumed (e.g., after a snapshot restore). * Use this to re-warm caches, restore transient state, or run other setup logic. @@ -394,6 +414,16 @@ export class Sandbox { return this.sandbox.snapshotExpiration; } + /** + * The snapshot retention policy (`keep-last-snapshots`) currently configured + * on this sandbox, if any. + */ + public get keepLastSnapshots(): + | { count: number; expiration?: number; deleteEvicted: boolean } + | undefined { + return this.sandbox.keepLastSnapshots; + } + /** * The amount of CPU used by the session. Only reported once the VM is stopped. */ @@ -512,6 +542,7 @@ export class Sandbox { env: params?.env, tags: params?.tags, snapshotExpiration: params?.snapshotExpiration, + keepLastSnapshots: params?.keepLastSnapshots, signal: params?.signal, name: params?.name, persistent: params?.persistent, @@ -1009,6 +1040,11 @@ export class Sandbox { networkPolicy?: NetworkPolicy; tags?: Record; snapshotExpiration?: number; + keepLastSnapshots?: { + count: number; + expiration?: number; + deleteEvicted?: boolean; + } | null; currentSnapshotId?: string; }, opts?: { signal?: AbortSignal }, @@ -1033,6 +1069,7 @@ export class Sandbox { networkPolicy: params.networkPolicy, tags: params.tags, snapshotExpiration: params.snapshotExpiration, + keepLastSnapshots: params.keepLastSnapshots, currentSnapshotId: params.currentSnapshotId, signal: opts?.signal, }); diff --git a/packages/vercel-sandbox/src/snapshot.ts b/packages/vercel-sandbox/src/snapshot.ts index a90b7e5..55e44e7 100644 --- a/packages/vercel-sandbox/src/snapshot.ts +++ b/packages/vercel-sandbox/src/snapshot.ts @@ -190,14 +190,21 @@ export class Snapshot { * Delete this snapshot. * * @param opts - Optional parameters. + * @param opts.forceDelete - Delete the snapshot even if it is currently set + * as the `currentSnapshotId` of a sandbox. By default the server rejects + * the deletion in that case. * @param opts.signal - An AbortSignal to cancel the operation. * @returns A promise that resolves once the snapshot has been deleted. */ - async delete(opts?: { signal?: AbortSignal }): Promise { + async delete(opts?: { + forceDelete?: boolean; + signal?: AbortSignal; + }): Promise { "use step"; const client = await this.ensureClient(); const response = await client.deleteSnapshot({ snapshotId: this.snapshot.id, + forceDelete: opts?.forceDelete, signal: opts?.signal, }); diff --git a/packages/vercel-sandbox/src/version.ts b/packages/vercel-sandbox/src/version.ts index 7374627..fc70779 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.0.0-beta.15"; +export const VERSION = "2.0.0-beta.16";