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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll

## [Unreleased]

### Changed

- **`api-keys create --kind client`** — binds the key to the linked project in `.voicethere/config.json` when `--project-id` is omitted. `--project-id <uuid>` overrides that linked project.

## [0.14.5] - 2026-09-21

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ Example: [`.voicethere/config.json.example`](./.voicethere/config.json.example)
| `projects list` | List org projects |
| `projects create <name> [--slug <slug>]` | Create project; uses it (writes config) |
| `projects use [projectId]` | Use project (picker or existing config when omitted) |
| `api-keys create --name <name> [--kind client] [--project-id <uuid>]` | Create an API key. Client keys use the linked project unless `--project-id` overrides it |
| `projects show` | Print `.voicethere/config.json` |
| `projects delete [projectId] [--force] [--wait]` | Delete project + builds (type name to confirm, or `--force`; `--wait` polls async deletion) |
| `projects settings list` | set Runner pool settings (warm pool, scale-down) |
Expand Down
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,10 @@ async function main(): Promise<void> {
.description("Create an API key (plaintext shown once)")
.requiredOption("--name <name>", "Display name for the key")
.option("--kind <kind>", "admin or client", "admin")
.option("--project-id <id>", "Project UUID (required for client keys)")
.option(
"--project-id <id>",
"Project UUID for a client key (overrides the linked project)",
)
.option("--expires-in-days <days>", "Lifetime in days (max 180)", (value) =>
Number.parseInt(value, 10),
)
Expand Down
86 changes: 86 additions & 0 deletions src/commands/api-keys/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vi.mock("../../lib/config.js", () => ({
const listApiKeys = vi.fn();
const createApiKey = vi.fn();
const revokeApiKey = vi.fn();
const readProjectConfig = vi.fn();

vi.mock("../../lib/api.js", () => ({
createApi: vi.fn(() => ({
Expand All @@ -23,9 +24,19 @@ vi.mock("../../lib/api.js", () => ({
})),
}));

vi.mock("../../lib/project-config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../lib/project-config.js")>();
return {
...actual,
readProjectConfig: (...args: unknown[]) => readProjectConfig(...args),
};
});

describe("api-keys commands", () => {
beforeEach(() => {
vi.clearAllMocks();
readProjectConfig.mockResolvedValue(null);
});

it("lists API keys", async () => {
Expand Down Expand Up @@ -70,7 +81,82 @@ describe("api-keys commands", () => {
expires_in_days: undefined,
});
expect(logSpy).toHaveBeenCalledWith("vth_secret");
expect(readProjectConfig).not.toHaveBeenCalled();
logSpy.mockRestore();
});

it("rejects --project-id on admin keys", async () => {
await expect(
runApiKeysCreate({
name: "Dev CLI",
projectId: "11111111-1111-4111-8111-111111111111",
}),
).rejects.toThrow("--project-id is only valid for client API keys");
expect(createApiKey).not.toHaveBeenCalled();
expect(readProjectConfig).not.toHaveBeenCalled();
});

it("creates a client API key for the linked project", async () => {
readProjectConfig.mockResolvedValue({
path: "/repo/.voicethere/config.json",
config: { project_id: "22222222-2222-4222-8222-222222222222" },
});
createApiKey.mockResolvedValue({
id: "key-client",
kind: "client",
api_key: "vthc_secret",
project_id: "22222222-2222-4222-8222-222222222222",
});

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await runApiKeysCreate({ name: "Widget", kind: "client" });
expect(createApiKey).toHaveBeenCalledWith({
name: "Widget",
kind: "client",
project_id: "22222222-2222-4222-8222-222222222222",
expires_in_days: undefined,
});
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("22222222-2222-4222-8222-222222222222"),
);
logSpy.mockRestore();
errorSpy.mockRestore();
});

it("lets --project-id override the linked project", async () => {
readProjectConfig.mockResolvedValue({
path: "/repo/.voicethere/config.json",
config: { project_id: "22222222-2222-4222-8222-222222222222" },
});
createApiKey.mockResolvedValue({
id: "key-override",
kind: "client",
api_key: "vthc_override",
project_id: "33333333-3333-4333-8333-333333333333",
});

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runApiKeysCreate({
name: "Widget",
kind: "client",
projectId: "33333333-3333-4333-8333-333333333333",
});
expect(readProjectConfig).not.toHaveBeenCalled();
expect(createApiKey).toHaveBeenCalledWith({
name: "Widget",
kind: "client",
project_id: "33333333-3333-4333-8333-333333333333",
expires_in_days: undefined,
});
logSpy.mockRestore();
});

it("rejects a client API key when no project is linked or passed", async () => {
await expect(
runApiKeysCreate({ name: "Widget", kind: "client" }),
).rejects.toThrow(/Pass --project-id/);
expect(createApiKey).not.toHaveBeenCalled();
});

it("revokes API key", async () => {
Expand Down
56 changes: 49 additions & 7 deletions src/commands/api-keys/create.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
import { logStep, logVerbose } from "../../lib/command-log.js";
import { createApiFromCredentials } from "../../lib/control-plane-auth.js";
import {
logCommandInfo,
logResolvedProject,
logStep,
} from "../../lib/command-log.js";
import { requireCredentials } from "../../lib/config.js";
import { createApiFromCredentials } from "../../lib/control-plane-auth.js";
import {
readProjectConfig,
type ResolvedProjectId,
} from "../../lib/project-config.js";

export type ApiKeysCreateOptions = {
name: string;
kind?: "admin" | "client";
/** Overrides the linked project in `.voicethere/config.json`. */
projectId?: string;
expiresInDays?: number;
};

const CLIENT_KEY_NEEDS_PROJECT =
"Client API keys need a project. Pass --project-id <uuid>, or link one with: voicethere projects use <projectId>";

/**
* Client keys bind to `--project-id` when set, otherwise the linked project.
* Admin keys stay org-scoped and reject `--project-id`.
*/
async function resolveClientProjectId(
explicitProjectId: string | undefined,
): Promise<string> {
if (explicitProjectId) {
logCommandInfo(`project: ${explicitProjectId} (--project-id)`);
return explicitProjectId;
}

const linked = await readProjectConfig();
const projectId = linked?.config.project_id?.trim();
if (!linked || !projectId) {
throw new Error(CLIENT_KEY_NEEDS_PROJECT);
}

const resolved: ResolvedProjectId = {
projectId,
source: "config",
configPath: linked.path,
};
logResolvedProject(resolved);
return projectId;
}

export async function runApiKeysCreate(
options: ApiKeysCreateOptions,
): Promise<void> {
Expand All @@ -18,21 +57,24 @@ export async function runApiKeysCreate(
}

const kind = options.kind ?? "admin";
if (kind === "client" && !options.projectId?.trim()) {
throw new Error("--project-id is required for client API keys");
}
if (kind === "admin" && options.projectId?.trim()) {
const explicitProjectId = options.projectId?.trim() || undefined;
if (kind === "admin" && explicitProjectId) {
throw new Error("--project-id is only valid for client API keys");
}

const projectId =
kind === "client"
? await resolveClientProjectId(explicitProjectId)
: undefined;

logStep(`Creating ${kind} API key "${name}"`);

const credentials = await requireCredentials();
const api = createApiFromCredentials(credentials);
const created = await api.createApiKey({
name,
kind,
project_id: options.projectId?.trim(),
project_id: projectId,
expires_in_days: options.expiresInDays,
});

Expand Down
Loading