diff --git a/CHANGELOG.md b/CHANGELOG.md index 868ee65..3824261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll ## [Unreleased] +### Added + +- **`source download -o`** — download the saved Code workspace as a zip. `--project ` skips a linked `.voicethere/config.json` (same flag as `deploy`). +- **`build download -o`** — download a compiled `agent.js`. Defaults to the active build, else the newest passed build; `--build-id` and `--project` override. + ## [0.14.4] - 2026-09-21 ### Added diff --git a/src/cli.ts b/src/cli.ts index 2ace369..206d68a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -72,6 +72,7 @@ import { runProjectsList } from "./commands/projects/list.js"; import { runProjectsShow } from "./commands/projects/show.js"; import { runProjectsUse } from "./commands/projects/use.js"; import { runBuildPromote } from "./commands/build/promote.js"; +import { runBuildDownload } from "./commands/build/download.js"; import { runBuildList } from "./commands/build/list.js"; import { runBuildUpload } from "./commands/build/upload.js"; import { runBuildValidate } from "./commands/build/validate.js"; @@ -79,7 +80,11 @@ import { runDeploy } from "./commands/deploy.js"; import { runUndeploy } from "./commands/undeploy.js"; import { runInit } from "./commands/init.js"; import { formatInitTemplateHelp } from "./lib/project-templates.js"; -import { runSourcePull, runSourcePush } from "./commands/source.js"; +import { + runSourceDownload, + runSourcePull, + runSourcePush, +} from "./commands/source.js"; import { runSessionsBilling } from "./commands/sessions/billing.js"; import { runSessionsList } from "./commands/sessions/list.js"; import { runSessionsRecording } from "./commands/sessions/recording.js"; @@ -1059,6 +1064,29 @@ async function main(): Promise { await runBuildPromote({ buildId }); }); + build + .command("download") + .description("Download a compiled agent bundle as JavaScript") + .requiredOption("-o, --output ", "Write bundle to this path") + .option("--project ", "Project UUID (default: .voicethere/config.json)") + .option( + "--build-id ", + "Build UUID (default: active or newest passed build)", + ) + .action( + async (options: { + output: string; + project?: string; + buildId?: string; + }) => { + await runBuildDownload({ + output: options.output, + projectId: options.project, + buildId: options.buildId, + }); + }, + ); + const apiKeys = program .command("api-keys") .description("Manage organization API keys"); @@ -1297,6 +1325,18 @@ async function main(): Promise { await runSourcePull(); }); + source + .command("download") + .description("Download saved Code workspace as a zip file") + .requiredOption("-o, --output ", "Write zip to this path") + .option("--project ", "Project UUID (default: .voicethere/config.json)") + .action(async (options: { output: string; project?: string }) => { + await runSourceDownload({ + output: options.output, + projectId: options.project, + }); + }); + program .command("deploy") .description("Promote (if needed) and roll out to cloud runners") diff --git a/src/commands/build/download.test.ts b/src/commands/build/download.test.ts new file mode 100644 index 0000000..139a910 --- /dev/null +++ b/src/commands/build/download.test.ts @@ -0,0 +1,186 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../lib/api.js"; +import { resolveDefaultBuildDownloadId, runBuildDownload } from "./download.js"; + +const getProject = vi.fn(); +const listBuilds = vi.fn(); +const getProjectBuildDownload = vi.fn(); +const requireCredentials = vi.fn(); + +vi.mock("../../lib/control-plane-auth.js", () => ({ + createApiFromCredentials: vi.fn(() => ({ + getProject, + listBuilds, + getProjectBuildDownload, + })), +})); + +vi.mock("../../lib/config.js", () => ({ + requireCredentials: (...args: unknown[]) => requireCredentials(...args), +})); + +vi.mock("../../lib/project-config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveProjectId: vi.fn(async () => ({ + projectId: "proj-dl", + source: "config", + configPath: "", + })), + }; +}); + +describe("build download", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = join( + tmpdir(), + `voicethere-build-dl-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(tempDir, { recursive: true }); + + const { resolveProjectId } = await import("../../lib/project-config.js"); + vi.mocked(resolveProjectId).mockClear(); + + getProject.mockReset(); + listBuilds.mockReset(); + getProjectBuildDownload.mockReset(); + requireCredentials.mockResolvedValue({ + api_key: "vth_test", + api_base: "https://app.voicethere.io/api/v1", + }); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("uses explicit project id when provided with --build-id", async () => { + const { resolveProjectId } = await import("../../lib/project-config.js"); + const js = Buffer.from("export default {};\n"); + getProjectBuildDownload.mockResolvedValue({ + bytes: js, + filename: "demo.js", + }); + + const outPath = join(tempDir, "agent.js"); + await runBuildDownload({ + output: outPath, + projectId: "proj-explicit", + buildId: "b-explicit", + }); + + expect(resolveProjectId).not.toHaveBeenCalled(); + expect(getProjectBuildDownload).toHaveBeenCalledWith( + "proj-explicit", + "b-explicit", + ); + expect(getProject).not.toHaveBeenCalled(); + expect(await readFile(outPath)).toEqual(js); + }); + + it("writes bundle bytes for an explicit build id", async () => { + const js = Buffer.from("export default {};\n"); + getProjectBuildDownload.mockResolvedValue({ + bytes: js, + filename: "demo-build-b1.js", + }); + + const outPath = join(tempDir, "agent.js"); + await runBuildDownload({ output: outPath, buildId: "b1" }); + + expect(getProjectBuildDownload).toHaveBeenCalledWith("proj-dl", "b1"); + expect(getProject).not.toHaveBeenCalled(); + expect(await readFile(outPath)).toEqual(js); + }); + + it("defaults to active_build_id when --build-id is omitted", async () => { + getProject.mockResolvedValue({ active_build_id: "active-1" }); + listBuilds.mockResolvedValue([ + { + id: "newer-failed", + validation_status: "failed", + created_at: "2026-09-21T12:00:00.000Z", + }, + ]); + getProjectBuildDownload.mockResolvedValue({ + bytes: Buffer.from("active"), + filename: null, + }); + + const outPath = join(tempDir, "bundle.js"); + await runBuildDownload({ output: outPath }); + + expect(getProjectBuildDownload).toHaveBeenCalledWith("proj-dl", "active-1"); + }); + + it("falls back to newest passed build when no active build", async () => { + getProject.mockResolvedValue({ active_build_id: null }); + listBuilds.mockResolvedValue([ + { + id: "build-new", + validation_status: "passed", + created_at: "2026-09-21T12:00:00.000Z", + }, + { + id: "build-old", + validation_status: "passed", + created_at: "2026-09-20T12:00:00.000Z", + }, + ]); + getProjectBuildDownload.mockResolvedValue({ + bytes: Buffer.from("passed"), + filename: null, + }); + + const outPath = join(tempDir, "bundle.js"); + await runBuildDownload({ output: outPath }); + + expect(getProjectBuildDownload).toHaveBeenCalledWith( + "proj-dl", + "build-new", + ); + }); + + it("resolveDefaultBuildDownloadId errors when no active or passed build", async () => { + const { createApiFromCredentials } = + await import("../../lib/control-plane-auth.js"); + const api = createApiFromCredentials({ + api_key: "vth_test", + api_base: "https://app.voicethere.io/api/v1", + }); + getProject.mockResolvedValue({ active_build_id: null }); + listBuilds.mockResolvedValue([ + { + id: "build-pending", + validation_status: "pending", + created_at: "2026-09-21T12:00:00.000Z", + }, + ]); + + await expect(resolveDefaultBuildDownloadId(api, "proj-dl")).rejects.toThrow( + /No build available/, + ); + }); + + it("surfaces ApiError on 404 from download API", async () => { + getProjectBuildDownload.mockRejectedValue( + new ApiError(404, "Build not found"), + ); + + const outPath = join(tempDir, "bundle.js"); + await expect( + runBuildDownload({ output: outPath, buildId: "missing" }), + ).rejects.toMatchObject({ name: "ApiError", status: 404 }); + }); +}); diff --git a/src/commands/build/download.ts b/src/commands/build/download.ts new file mode 100644 index 0000000..0822b0e --- /dev/null +++ b/src/commands/build/download.ts @@ -0,0 +1,79 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { type VoicethereApi } from "../../lib/api.js"; +import { logCommandInfo, logStep, logVerbose } from "../../lib/command-log.js"; +import { requireCredentials } from "../../lib/config.js"; +import { createApiFromCredentials } from "../../lib/control-plane-auth.js"; +import { resolveProjectId } from "../../lib/project-config.js"; + +export interface BuildDownloadOptions { + output: string; + projectId?: string; + buildId?: string; + startDir?: string; +} + +export async function resolveDefaultBuildDownloadId( + api: VoicethereApi, + projectId: string, +): Promise { + logVerbose(`resolving build id for download (project ${projectId})`); + const [project, builds] = await Promise.all([ + api.getProject(projectId), + api.listBuilds(projectId), + ]); + + if (project.active_build_id) { + return project.active_build_id; + } + + const newestPassed = builds.find( + (build) => build.validation_status === "passed", + ); + if (!newestPassed) { + throw new Error( + "No build available to download. Upload and validate a bundle, or pass --build-id.", + ); + } + + return newestPassed.id; +} + +export async function runBuildDownload( + options: BuildDownloadOptions, +): Promise { + const output = options.output?.trim(); + if (!output) { + throw new Error( + "Output path required. Use: voicethere build download -o ", + ); + } + + const credentials = await requireCredentials(); + const api = createApiFromCredentials(credentials); + const explicitId = options.projectId?.trim(); + const project = explicitId + ? { projectId: explicitId } + : await resolveProjectId( + options.startDir ? { startDir: options.startDir } : undefined, + ); + + const buildId = + options.buildId?.trim() || + (await resolveDefaultBuildDownloadId(api, project.projectId)); + + logStep(`Downloading compiled bundle ${buildId}`); + const { bytes, filename } = await api.getProjectBuildDownload( + project.projectId, + buildId, + ); + + const outputPath = resolve(output); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, bytes); + + const nameHint = filename ? ` (${filename})` : ""; + logCommandInfo(`wrote ${bytes.length} byte(s) to ${outputPath}${nameHint}`); + console.log(outputPath); +} diff --git a/src/commands/source.test.ts b/src/commands/source.test.ts index bd177f8..2d3b44e 100644 --- a/src/commands/source.test.ts +++ b/src/commands/source.test.ts @@ -2,15 +2,17 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runSourcePull, runSourcePush } from "./source.js"; +import { runSourceDownload, runSourcePull, runSourcePush } from "./source.js"; const getProjectSource = vi.fn(); +const getProjectSourceDownload = vi.fn(); const putProjectSource = vi.fn(); const requireCredentials = vi.fn(); vi.mock("../lib/control-plane-auth.js", () => ({ createApiFromCredentials: vi.fn(() => ({ getProjectSource, + getProjectSourceDownload, putProjectSource, })), })); @@ -25,6 +27,11 @@ vi.mock("../lib/project-config.js", async (importOriginal) => { return { ...actual, requireProjectId: vi.fn(async () => "proj-src"), + resolveProjectId: vi.fn(async () => ({ + projectId: "proj-src", + source: "config", + configPath: "", + })), readProjectConfig: vi.fn(async () => ({ config: { project_id: "proj-src" }, path: "", @@ -70,7 +77,11 @@ describe("source push/pull", () => { path: configPath, }); + const { resolveProjectId } = await import("../lib/project-config.js"); + vi.mocked(resolveProjectId).mockClear(); + getProjectSource.mockReset(); + getProjectSourceDownload.mockReset(); putProjectSource.mockReset(); requireCredentials.mockReset(); requireCredentials.mockResolvedValue({ @@ -150,4 +161,43 @@ describe("source push/pull", () => { "export const x = 1;\n", ); }); + + it("source download writes zip bytes to --output", async () => { + const zip = Buffer.from("PK\x03\x04workspace"); + getProjectSourceDownload.mockResolvedValue({ + bytes: zip, + filename: "demo-source-r1.zip", + }); + + const outPath = join(tempDir, "out", "workspace.zip"); + const previousCwd = process.cwd(); + process.chdir(tempDir); + try { + await runSourceDownload({ output: outPath }); + } finally { + process.chdir(previousCwd); + } + + expect(getProjectSourceDownload).toHaveBeenCalledWith("proj-src"); + expect(await readFile(outPath)).toEqual(zip); + }); + + it("source download uses explicit project id when provided", async () => { + const { resolveProjectId } = await import("../lib/project-config.js"); + const zip = Buffer.from("PK\x03\x04explicit"); + getProjectSourceDownload.mockResolvedValue({ + bytes: zip, + filename: "other.zip", + }); + + const outPath = join(tempDir, "explicit.zip"); + await runSourceDownload({ + output: outPath, + projectId: "proj-explicit", + }); + + expect(resolveProjectId).not.toHaveBeenCalled(); + expect(getProjectSourceDownload).toHaveBeenCalledWith("proj-explicit"); + expect(await readFile(outPath)).toEqual(zip); + }); }); diff --git a/src/commands/source.ts b/src/commands/source.ts index 0ca7196..ac8c01d 100644 --- a/src/commands/source.ts +++ b/src/commands/source.ts @@ -1,5 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { createApiFromCredentials } from "../lib/control-plane-auth.js"; import { requireCredentials } from "../lib/config.js"; @@ -8,6 +8,7 @@ import { readProjectConfig, repoRootFromConfigPath, requireProjectId, + resolveProjectId, } from "../lib/project-config.js"; import { collectWorkspaceSourceFiles, @@ -64,3 +65,42 @@ export async function runSourcePull(): Promise { `pulled ${remote.files.length} file(s) at revision ${remote.revision}`, ); } + +export interface SourceDownloadOptions { + output: string; + projectId?: string; + startDir?: string; +} + +export async function runSourceDownload( + options: SourceDownloadOptions, +): Promise { + const output = options.output?.trim(); + if (!output) { + throw new Error( + "Output path required. Use: voicethere source download -o ", + ); + } + + const credentials = await requireCredentials(); + const api = createApiFromCredentials(credentials); + const explicitId = options.projectId?.trim(); + const project = explicitId + ? { projectId: explicitId } + : await resolveProjectId( + options.startDir ? { startDir: options.startDir } : undefined, + ); + + logStep("Downloading Code workspace zip from VoiceThere"); + const { bytes, filename } = await api.getProjectSourceDownload( + project.projectId, + ); + + const outputPath = resolve(output); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, bytes); + + const nameHint = filename ? ` (${filename})` : ""; + logCommandInfo(`wrote ${bytes.length} byte(s) to ${outputPath}${nameHint}`); + console.log(outputPath); +} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index a3e59e2..97caa98 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -2,7 +2,12 @@ import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ApiError, formatCliError, VoicethereApi } from "./api.js"; +import { + ApiError, + formatCliError, + parseContentDispositionFilename, + VoicethereApi, +} from "./api.js"; import { DEFAULT_API_BASE, getCredentialsPath, @@ -154,6 +159,24 @@ describe("slugifyName", () => { }); }); +describe("parseContentDispositionFilename", () => { + it("parses quoted filename", () => { + expect( + parseContentDispositionFilename( + 'attachment; filename="demo-source-r3.zip"', + ), + ).toBe("demo-source-r3.zip"); + }); + + it("parses RFC 5987 filename*", () => { + expect( + parseContentDispositionFilename( + "attachment; filename*=UTF-8''my%20build.js", + ), + ).toBe("my build.js"); + }); +}); + describe("formatCliError", () => { it("formats ApiError with error_id and request_id", () => { const error = new ApiError(400, "bad input", { @@ -487,6 +510,71 @@ describe("VoicethereApi", () => { expect(init.body).toBe(JSON.stringify({ build_id: "build-1" })); }); + it("downloads project source zip as binary", async () => { + const zipBytes = Buffer.from("PK\x03\x04fake-zip"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(zipBytes, { + status: 200, + headers: { + "Content-Disposition": 'attachment; filename="echo-source-r2.zip"', + "Content-Type": "application/zip", + }, + }), + ); + + const api = new VoicethereApi(apiKey, apiBase); + const result = await api.getProjectSourceDownload("proj-1"); + + expect(result.bytes.equals(zipBytes)).toBe(true); + expect(result.filename).toBe("echo-source-r2.zip"); + const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe(`${apiBase}/projects/proj-1/source/download`); + expect(init.method).toBe("GET"); + }); + + it("downloads project build bundle as binary", async () => { + const js = "export default function agent() {}"; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(js, { + status: 200, + headers: { + "Content-Disposition": 'attachment; filename="echo-build-build-9.js"', + "Content-Type": "application/javascript", + }, + }), + ); + + const api = new VoicethereApi(apiKey, apiBase); + const result = await api.getProjectBuildDownload("proj-1", "build-9"); + + expect(result.bytes.toString("utf8")).toBe(js); + expect(result.filename).toBe("echo-build-build-9.js"); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe( + `${apiBase}/projects/proj-1/builds/build-9/download`, + ); + }); + + it("surfaces ApiError when source download returns 404 JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + error: { message: "Project not found", code: "not_found" }, + }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ), + ); + + const api = new VoicethereApi(apiKey, apiBase); + await expect(api.getProjectSourceDownload("missing")).rejects.toMatchObject( + { + name: "ApiError", + status: 404, + message: "Project not found", + }, + ); + }); + it("lists project environment variables", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( diff --git a/src/lib/api.ts b/src/lib/api.ts index 922c56e..c6c3422 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -65,6 +65,44 @@ function formatHttpRetryLog(error: unknown): string { return String(error); } +export interface BinaryDownloadResult { + bytes: Buffer; + filename: string | null; +} + +/** Parse `Content-Disposition` attachment filename (quoted or RFC 5987). */ +export function parseContentDispositionFilename( + header: string | null, +): string | null { + if (!header) { + return null; + } + + const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header); + if (starMatch) { + const raw = starMatch[1].trim().replace(/^"|"$/, ""); + const utf8Match = /^UTF-8''(.+)$/i.exec(raw); + const encoded = utf8Match ? utf8Match[1] : raw; + try { + return decodeURIComponent(encoded); + } catch { + return encoded; + } + } + + const quoted = /filename\s*=\s*"([^"]+)"/i.exec(header); + if (quoted) { + return quoted[1]; + } + + const unquoted = /filename\s*=\s*([^;\s]+)/i.exec(header); + if (unquoted) { + return unquoted[1].replace(/^"|"$/, ""); + } + + return null; +} + export function throwApiErrorFromResponse( method: string, pathname: string, @@ -858,6 +896,22 @@ export class VoicethereApi { ); } + async getProjectSourceDownload( + projectId: string, + ): Promise { + return this.requestBinary("GET", `/projects/${projectId}/source/download`); + } + + async getProjectBuildDownload( + projectId: string, + buildId: string, + ): Promise { + return this.requestBinary( + "GET", + `/projects/${projectId}/builds/${encodeURIComponent(buildId)}/download`, + ); + } + async getProject(projectId: string): Promise { return this.request("GET", `/projects/${projectId}`); } @@ -1363,6 +1417,73 @@ export class VoicethereApi { ); } + private async requestBinary( + method: string, + path: string, + ): Promise { + const url = new URL( + path.replace(/^\//, ""), + `${this.apiBase.replace(/\/$/, "")}/`, + ); + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + }; + if (this.orgId && isUserApiKeyToken(this.apiKey)) { + headers[USER_ORG_ID_HEADER] = this.orgId; + } + + const pathWithQuery = `${url.pathname}${url.search}`; + logVerbose(`${method} ${pathWithQuery}`); + + return withHttpRetries( + async () => { + const started = performance.now(); + const response = await fetch(url, { method, headers }); + logVerbose( + `response: ${response.status} (${Math.round(performance.now() - started)}ms)`, + ); + + if (!response.ok && isRetryableHttpStatus(response.status)) { + const text = await response.text(); + throw new RetryableHttpStatusError(response.status, text); + } + + if (!response.ok) { + const text = await response.text(); + throwApiErrorFromResponse( + method, + url.pathname, + response.status, + text, + ); + } + + const arrayBuffer = await response.arrayBuffer(); + const filename = parseContentDispositionFilename( + response.headers.get("Content-Disposition"), + ); + return { bytes: Buffer.from(arrayBuffer), filename }; + }, + { + onRetry: ({ attempt, maxAttempts, delayMs, error }) => { + logVerbose( + `retrying after ${delayMs}ms (attempt ${attempt}/${maxAttempts}): ${formatHttpRetryLog(error)}`, + ); + }, + }, + ).catch((error: unknown) => { + if (error instanceof RetryableHttpStatusError) { + throwApiErrorFromResponse( + method, + url.pathname, + error.status, + error.bodyText, + ); + } + throw error; + }); + } + private async request( method: string, path: string,