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
40 changes: 23 additions & 17 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { runUndeploy } from "./commands/undeploy.js";
import { runSessionsBilling } from "./commands/sessions/billing.js";
import { runSessionsList } from "./commands/sessions/list.js";
import { configureLogging } from "./lib/command-log.js";
import { formatCliError } from "./lib/api.js";
import { DEFAULT_API_BASE } from "./lib/config.js";

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -464,7 +465,9 @@ async function main(): Promise<void> {

usage
.command("show")
.description("Show usage credits (project default, or org rollup with --org)")
.description(
"Show usage credits (project default, or org rollup with --org)",
)
.option("--project <id>", "Project UUID (default: .voicethere/config.json)")
.option("--org", "Organization rollup instead of a single project")
.option("--period <period>", "24h, 7d, 30d, or utc_month")
Expand Down Expand Up @@ -571,7 +574,10 @@ async function main(): Promise<void> {
)
.option("--project <id>", "Project UUID")
.option("--limit <n>", "Max rows when listing project logs", "20")
.option("--session <id>", "Filter to one orchestrator session id (one conversation)")
.option(
"--session <id>",
"Filter to one orchestrator session id (one conversation)",
)
.option("--q <text>", "Search log messages")
.option("--level <level>", "Filter by level (debug|info|warn|error)")
.option(
Expand All @@ -595,17 +601,9 @@ async function main(): Promise<void> {
sessionId: options.session,
q: options.q,
level: options.level as
| "debug"
| "info"
| "warn"
| "error"
| undefined,
"debug" | "info" | "warn" | "error" | undefined,
severity: options.severity as
| "debug"
| "info"
| "warn"
| "error"
| undefined,
"debug" | "info" | "warn" | "error" | undefined,
json: options.json,
});
},
Expand Down Expand Up @@ -708,9 +706,18 @@ async function main(): Promise<void> {
.description("Export conversation transcripts to a downloadable JSON file")
.option("--project <id>", "Project UUID")
.option("--session <id>", "Export one session by orchestrator session id")
.option("--q <text>", "Export sessions matching transcript text or session id")
.option("--all", "Export all conversations (optionally within a time window)")
.option("--period <period>", "24h, 7d, 30d, or utc_month (filter/all modes)")
.option(
"--q <text>",
"Export sessions matching transcript text or session id",
)
.option(
"--all",
"Export all conversations (optionally within a time window)",
)
.option(
"--period <period>",
"24h, 7d, 30d, or utc_month (filter/all modes)",
)
.option("--from <iso>", "Custom range start (ISO-8601)")
.option("--to <iso>", "Custom range end (ISO-8601)")
.option("--wait", "Poll until the export job completes or fails")
Expand Down Expand Up @@ -1038,7 +1045,6 @@ async function main(): Promise<void> {
}

main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
console.error(formatCliError(error));
process.exitCode = 1;
});
3 changes: 3 additions & 0 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import * as httpRetry from "../lib/http-retry.js";

import { ApiError } from "../lib/api.js";
import {
evaluateExistingCredentials,
Expand Down Expand Up @@ -198,6 +200,7 @@ describe("runLogin", () => {
}),
"utf8",
);
vi.spyOn(httpRetry, "withHttpRetries").mockImplementation((fn) => fn());
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: { message: "down" } }), {
status: 503,
Expand Down
90 changes: 86 additions & 4 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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, VoicethereApi } from "./api.js";
import { ApiError, formatCliError, VoicethereApi } from "./api.js";
import {
DEFAULT_API_BASE,
getCredentialsPath,
Expand Down Expand Up @@ -133,12 +133,39 @@ describe("slugifyName", () => {
});
});

describe("formatCliError", () => {
it("formats ApiError with error_id and request_id", () => {
const error = new ApiError(400, "bad input", {
error: {
message: "bad input",
request_id: "req-abc",
error_id: "err-xyz",
},
});

expect(formatCliError(error)).toBe(
"Error: bad input\nerror_id: err-xyz\nrequest_id: req-abc",
);
});

it("omits missing ApiError metadata lines", () => {
const error = new ApiError(500, "oops");
expect(formatCliError(error)).toBe("Error: oops");
});

it("formats generic errors on one line", () => {
expect(formatCliError(new Error("boom"))).toBe("Error: boom");
expect(formatCliError("plain")).toBe("Error: plain");
});
});

describe("VoicethereApi", () => {
const apiKey = "vth_dev_test";
const apiBase = "https://app.voicethere.dev/api/v1";

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it("lists projects with bearer auth", async () => {
Expand Down Expand Up @@ -179,9 +206,11 @@ describe("VoicethereApi", () => {
});

it("sends x-voicethere-org-id for personal user keys", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ projects: [] }), { status: 200 }),
);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ projects: [] }), { status: 200 }),
);

const api = new VoicethereApi("vthu_personal", apiBase, {
orgId: "org-header",
Expand Down Expand Up @@ -230,6 +259,7 @@ describe("VoicethereApi", () => {
code: "NWRTC_UNAUTHORIZED",
message: "Invalid API key",
request_id: "req-1",
error_id: "err-uuid-1",
},
}),
{ status: 401 },
Expand All @@ -243,9 +273,61 @@ describe("VoicethereApi", () => {
code: "NWRTC_UNAUTHORIZED",
message: "Invalid API key",
requestId: "req-1",
errorId: "err-uuid-1",
} satisfies Partial<ApiError>);
});

it("retries on gateway status then throws ApiError", async () => {
vi.useFakeTimers();
const body = JSON.stringify({
error: {
code: "GATEWAY",
message: "upstream unavailable",
request_id: "req-gw",
error_id: "err-gw",
},
});
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockImplementation(() =>
Promise.resolve(new Response(body, { status: 503 })),
);

const api = new VoicethereApi(apiKey, apiBase);
const assertion = expect(api.listProjects()).rejects.toMatchObject({
name: "ApiError",
status: 503,
message: "upstream unavailable",
requestId: "req-gw",
errorId: "err-gw",
});

await vi.runAllTimersAsync();
await assertion;

expect(fetchMock).toHaveBeenCalledTimes(8);
vi.useRealTimers();
});

it("retries on network error then succeeds", async () => {
vi.useFakeTimers();
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new TypeError("fetch failed"))
.mockResolvedValueOnce(
new Response(JSON.stringify({ projects: [] }), { status: 200 }),
);

const api = new VoicethereApi(apiKey, apiBase);
const promise = api.listProjects();
await vi.runAllTimersAsync();
const projects = await promise;

expect(projects).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});

it("uploads a build with optional message field", async () => {
const bundleDir = join(
tmpdir(),
Expand Down
Loading
Loading