From 57185946392c0dcf1e5e5317a18534efe73620d7 Mon Sep 17 00:00:00 2001 From: akirilyuk Date: Sun, 27 Sep 2026 12:23:26 +0700 Subject: [PATCH] feat(api-keys): bind client keys to the linked project Client keys use .voicethere/config.json when --project-id is omitted. --project-id still overrides that linked project. --- CHANGELOG.md | 4 ++ README.md | 1 + src/cli.ts | 5 +- src/commands/api-keys/commands.test.ts | 86 ++++++++++++++++++++++++++ src/commands/api-keys/create.ts | 56 ++++++++++++++--- 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3e914..158299f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` overrides that linked project. + ## [0.14.5] - 2026-09-21 ### Added diff --git a/README.md b/README.md index 54a8f43..b7222de 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ Example: [`.voicethere/config.json.example`](./.voicethere/config.json.example) | `projects list` | List org projects | | `projects create [--slug ]` | Create project; uses it (writes config) | | `projects use [projectId]` | Use project (picker or existing config when omitted) | +| `api-keys create --name [--kind client] [--project-id ]` | 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) | diff --git a/src/cli.ts b/src/cli.ts index 206d68a..f099d93 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1103,7 +1103,10 @@ async function main(): Promise { .description("Create an API key (plaintext shown once)") .requiredOption("--name ", "Display name for the key") .option("--kind ", "admin or client", "admin") - .option("--project-id ", "Project UUID (required for client keys)") + .option( + "--project-id ", + "Project UUID for a client key (overrides the linked project)", + ) .option("--expires-in-days ", "Lifetime in days (max 180)", (value) => Number.parseInt(value, 10), ) diff --git a/src/commands/api-keys/commands.test.ts b/src/commands/api-keys/commands.test.ts index ce4e7d7..7c74094 100644 --- a/src/commands/api-keys/commands.test.ts +++ b/src/commands/api-keys/commands.test.ts @@ -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(() => ({ @@ -23,9 +24,19 @@ vi.mock("../../lib/api.js", () => ({ })), })); +vi.mock("../../lib/project-config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readProjectConfig: (...args: unknown[]) => readProjectConfig(...args), + }; +}); + describe("api-keys commands", () => { beforeEach(() => { vi.clearAllMocks(); + readProjectConfig.mockResolvedValue(null); }); it("lists API keys", async () => { @@ -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 () => { diff --git a/src/commands/api-keys/create.ts b/src/commands/api-keys/create.ts index 7cdba91..f85a0f3 100644 --- a/src/commands/api-keys/create.ts +++ b/src/commands/api-keys/create.ts @@ -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 , or link one with: voicethere projects use "; + +/** + * 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 { + 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 { @@ -18,13 +57,16 @@ 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(); @@ -32,7 +74,7 @@ export async function runApiKeysCreate( const created = await api.createApiKey({ name, kind, - project_id: options.projectId?.trim(), + project_id: projectId, expires_in_days: options.expiresInDays, });