Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uuid>` 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
Expand Down
42 changes: 41 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,19 @@ 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";
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";
Expand Down Expand Up @@ -1059,6 +1064,29 @@ async function main(): Promise<void> {
await runBuildPromote({ buildId });
});

build
.command("download")
.description("Download a compiled agent bundle as JavaScript")
.requiredOption("-o, --output <path>", "Write bundle to this path")
.option("--project <id>", "Project UUID (default: .voicethere/config.json)")
.option(
"--build-id <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");
Expand Down Expand Up @@ -1297,6 +1325,18 @@ async function main(): Promise<void> {
await runSourcePull();
});

source
.command("download")
.description("Download saved Code workspace as a zip file")
.requiredOption("-o, --output <path>", "Write zip to this path")
.option("--project <id>", "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")
Expand Down
186 changes: 186 additions & 0 deletions src/commands/build/download.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../lib/project-config.js")>();
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 });
});
});
79 changes: 79 additions & 0 deletions src/commands/build/download.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
const output = options.output?.trim();
if (!output) {
throw new Error(
"Output path required. Use: voicethere build download -o <path>",
);
}

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