diff --git a/CHANGELOG.md b/CHANGELOG.md index fde0169..923b2ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll ## [Unreleased] +### Added + +- **`build upload --print-id`** — print only the new build UUID on stdout (progress stays on stderr) so CI can `BUILD_ID=$(voicethere build upload --print-id)` and pass `"$BUILD_ID"` to `build promote` or `deploy --build-id`. +- **`voicethere init --template`** — accept every product template from the installed `@voicethere/agent` registry (including `webhooks` and `webhooks-redis`) plus `blank`. `init --help` lists the live ids and links to https://github.com/voicethere/agent/tree/main/templates. + +### Changed + +- README CI / release examples capture `$BUILD_ID` with `--print-id` instead of `` placeholders (those are stdin redirects in bash). +- `build validate` reports success on stderr so `--print-id` stays a single-line UUID even when local verify runs. + ## [0.14.3] - 2026-09-20 ### Changed diff --git a/README.md b/README.md index bf1a59a..54a8f43 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ npm install -g @voicethere/cli Or run without a global install: ```bash -npx @voicethere/cli +npx @voicethere/cli --help ``` ## Debugging @@ -121,11 +121,35 @@ npm run verify | Flag | Behavior | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--template ` | Platform template (default: `echo`). Also: `blank`, `voice-starter`, `echo-dc`, `voice-showcase`, `world-sync`, `world-sync-binary`, `game-sync`, `recording-consent`, `positional-tts`, `spatial-showcase` | +| `--template ` | Product template from the installed `@voicethere/agent` package (default: `echo`), or `blank` for a minimal stub. Run `voicethere init --help` for the live list. | | `--local-only` | Write files on disk only — no login, cloud project, or source upload | | `--no-install` | Skip `npm install` after scaffolding | | `--force` | Overwrite when `package.json` already exists | +#### Templates + +`init --template` follows the product registry in `@voicethere/agent` (plus CLI-only `blank`). When the CLI bumps its agent dependency, new product templates become available without a hardcoded allow-list. + +Sources: [github.com/voicethere/agent/tree/main/templates](https://github.com/voicethere/agent/tree/main/templates) + +| Id | Summary | +| -- | ------- | +| `blank` | Minimal stub (`agent.ts`) — CLI only, not in the agent package | +| `echo` | Voice + chat echo with TTS playback | +| `echo-dc` | Data-channel echo, no TTS | +| `voice-starter` | Every speech event — customize `onUserSpeechFinal` for your LLM | +| `world-sync` | JSON pose broadcast, one in-memory agent, no Redis | +| `world-sync-binary` | Binary pose `ArrayBuffer` via `onDataChannelBinary`, no Redis | +| `game-sync` | Authoritative sim with binary snapshots; Redis when `AGENT_REDIS_URL` is set | +| `voice-showcase` | Conversational landing demo | +| `recording-consent` | Recording consent flow | +| `positional-tts` | Orbiting TTS with per-client `setTtsPose` | +| `spatial-showcase` | Orbit / soundboard / proximity via DataChannel commands | +| `webhooks` | Inbound HMAC webhook handler, then DataChannel + speak fan-out | +| `webhooks-redis` | Webhooks plus a Redis atomic counter | + +E2e-only templates live in the same folder (`echo-smoke`, `crash`, `game-sync-smoke`, `redis-sync`, `mix-smoke`) and are **not** accepted by `init`. + Sync edits with the dashboard **Code** tab: ```bash @@ -163,33 +187,32 @@ voicethere build upload -m "Add Spanish greeting and fix barge-in" **Promote** sets the **active** build in the VoiceThere control plane only. To roll out to cloud runners, run **`voicethere deploy --wait`** (promote + cluster rollout in one step). -Pass the build UUID from **`build upload`** or **`build list`**, or omit it in an interactive terminal to pick from a list: +Pass the build UUID from **`build upload --print-id`** or **`build list`**, or omit it in an interactive terminal to pick from a list: ```bash voicethere build list -voicethere build promote -# interactive: voicethere build promote +voicethere build promote +# interactive picker when stdout is a TTY and you omit the id ``` Typical release loop: ```bash npx @voicethere/agent build -voicethere build upload -m "v0.2 — shorter silence timeout" -voicethere build promote +BUILD_ID=$(voicethere build upload -m "v0.2 — shorter silence timeout" --print-id) +voicethere build promote "$BUILD_ID" ``` ### 6. Clone an existing repo (config already in git) ```bash -git clone -cd +git clone "$AGENT_REPO_URL" +cd my-agent voicethere login --api-key "$VOICETHERE_API_KEY" npx @voicethere/agent build -voicethere build upload -voicethere build list -voicethere build promote +BUILD_ID=$(voicethere build upload --print-id) +voicethere build promote "$BUILD_ID" ``` No `projects use` needed — the active project travels with the repo. @@ -198,7 +221,7 @@ No `projects use` needed — the active project travels with the repo. ```bash voicethere projects list -voicethere projects use --slug my-agent --bundle dist/agent.js +voicethere projects use "$PROJECT_ID" --slug my-agent --bundle dist/agent.js git add .voicethere/config.json && git commit -m "chore: use VoiceThere project" ``` @@ -212,13 +235,21 @@ voicethere projects show ### 8. CI / automation +`--print-id` writes **only** the new build UUID to stdout. Progress stays on stderr, so command substitution is safe: + +```bash +BUILD_ID=$(voicethere build upload --print-id) +voicethere build promote "$BUILD_ID" +# or: voicethere deploy --wait --build-id "$BUILD_ID" +``` + **With committed `.voicethere/config.json`** (typical agent repo): ```bash voicethere login --api-key "$VOICETHERE_API_KEY" npx @voicethere/agent build -voicethere build upload -m "$GITHUB_SHA — $GITHUB_REF_NAME" --skip-validate -voicethere build promote +BUILD_ID=$(voicethere build upload -m "$GITHUB_SHA — $GITHUB_REF_NAME" --skip-validate --print-id) +voicethere build promote "$BUILD_ID" ``` No `projects use` step — the config file is the active project. @@ -227,10 +258,10 @@ No `projects use` step — the config file is the active project. ```bash voicethere login --api-key "$VOICETHERE_API_KEY" -voicethere projects use +voicethere projects use "$PROJECT_ID" npx @voicethere/agent build -voicethere build upload --skip-validate -voicethere build promote +BUILD_ID=$(voicethere build upload --skip-validate --print-id) +voicethere build promote "$BUILD_ID" ``` Other CI notes: @@ -245,9 +276,8 @@ Split upload and promote across jobs if you want a human approval gate between t `voicethere deploy --wait` **promotes the build (when needed) and rolls out to cloud runners**, blocking until the deployment is active (or failed). ```bash -voicethere build upload -m "v0.2 — shorter silence timeout" -voicethere deploy --wait -# or pin a build: voicethere deploy --wait --build-id +BUILD_ID=$(voicethere build upload -m "v0.2 — shorter silence timeout" --print-id) +voicethere deploy --wait --build-id "$BUILD_ID" ``` Use **`build promote`** alone when you only need to update the control plane (e.g. smoke tests); use **`deploy --wait`** for anything that must run on staging runners. @@ -296,7 +326,7 @@ Example: [`.voicethere/config.json.example`](./.voicethere/config.json.example) | `projects voice catalog` | show STT/TTS vendors and models | | `build list` | Builds for the active project | | `build validate [file]` | Sandbox verify (default bundle from config) | -| `build upload [file] [-m ]` | Upload to active project | +| `build upload [file] [-m ] [--skip-validate] [--print-id]` | Upload to active project; `--print-id` prints only the build UUID | | `build promote [buildId]` | Promote on active project (picker when omitted in TTY) | | `deploy [--wait] [--build-id]` | Promote (if needed) + cloud rollout; `--wait` blocks | @@ -324,6 +354,12 @@ voicethere -v build upload # …plus [voicethere:verbose] api: https://… # [voicethere:verbose] POST /projects/…/builds # [voicethere:verbose] response: 201 (842ms) + +BUILD_ID=$(voicethere build upload --skip-validate --print-id) +# stdout: 550e8400-e29b-41d4-a716-446655440000 +# stderr: [voicethere] Uploading agent bundle +# [voicethere] project: … +# [voicethere] bundle: … ``` Global `-v` works on any subcommand: `voicethere -v projects list`. @@ -397,7 +433,7 @@ Override the CDN origin with `VOICETHERE_WIDGET_CDN_BASE` or `--cdn-base` when n List recent voice sessions (orchestrator session id, status, billable seconds): ```bash -voicethere sessions list --start 0 --end 50 +voicethere sessions list "$PROJECT_ID" --start 0 --end 50 # or with .voicethere/config.json: voicethere sessions list --start 0 --end 50 ``` @@ -407,27 +443,27 @@ The API returns `{ sessions, start, end, count }` (max 50 rows per page). The CL After a call ends (runner keep-alive billing), fetch billable duration: ```bash -voicethere sessions billing --project -voicethere sessions billing --json +voicethere sessions billing "$SESSION_ID" --project "$PROJECT_ID" +voicethere sessions billing "$SESSION_ID" --json ``` Download session audio recording (poll until ready, then fetch signed `play_url`; storage is Opus/Ogg — use `--format` or the output extension to write WAV, MP3, or raw Opus): ```bash -voicethere sessions recording --project --wait --output ./recording.wav -voicethere sessions recording --wait --output ./recording.mp3 --format mp3 -voicethere sessions recording --wait --output ./recording.opus --format opus -voicethere sessions recording --wait --json +voicethere sessions recording "$SESSION_ID" --project "$PROJECT_ID" --wait --output ./recording.wav +voicethere sessions recording "$SESSION_ID" --wait --output ./recording.mp3 --format mp3 +voicethere sessions recording "$SESSION_ID" --wait --output ./recording.opus --format opus +voicethere sessions recording "$SESSION_ID" --wait --json ``` WAV/MP3 conversion uses a bundled `ffmpeg` binary shipped with the CLI. Override with `FFMPEG_PATH` if needed. JSON metadata always reports the API storage format (`opus`). -`voicethere sessions recording get ` is equivalent (get is the default subcommand). +`voicethere sessions recording get "$SESSION_ID"` is equivalent (get is the default subcommand). Delete a session recording: ```bash -voicethere sessions recording delete --project +voicethere sessions recording delete "$SESSION_ID" --project "$PROJECT_ID" ``` `--output` requires `--wait`. Default wait timeout is 120s (`--timeout-ms` or `VOICETHERE_SESSION_RECORDING_TIMEOUT_MS`). diff --git a/src/cli.ts b/src/cli.ts index 79c6f16..2ace369 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,6 +78,7 @@ 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 { runSessionsBilling } from "./commands/sessions/billing.js"; import { runSessionsList } from "./commands/sessions/list.js"; @@ -1023,18 +1024,24 @@ async function main(): Promise { .argument("[file]", "Bundle path (default: config bundle or dist/agent.js)") .option("-m, --message ", "Build label (like a git commit message)") .option("--skip-validate", "Upload without local sandbox verify") + .option( + "--print-id", + "Print only the new build UUID on stdout (for scripts / CI)", + ) .action( async ( file: string | undefined, options: { message?: string; skipValidate?: boolean; + printId?: boolean; }, ) => { await runBuildUpload({ file, message: options.message, skipValidate: options.skipValidate, + printId: options.printId, }); }, ); @@ -1238,7 +1245,7 @@ async function main(): Promise { ) .option( "--template ", - "Platform template (default: echo). Use blank for a minimal stub.", + "Product template from @voicethere/agent, or blank (default: echo)", "echo", ) .option( @@ -1247,6 +1254,7 @@ async function main(): Promise { ) .option("--no-install", "Skip npm install in the target directory") .option("--force", "Overwrite when package.json already exists") + .addHelpText("after", () => formatInitTemplateHelp()) .action( async ( dir: string, diff --git a/src/commands/build/upload.test.ts b/src/commands/build/upload.test.ts new file mode 100644 index 0000000..a62a230 --- /dev/null +++ b/src/commands/build/upload.test.ts @@ -0,0 +1,120 @@ +import { mkdir, 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 { runBuildUpload } from "./upload.js"; +import { runBuildValidate } from "./validate.js"; + +const uploadBuild = vi.fn(); +const requireCredentials = vi.fn(); +const resolveProjectId = vi.fn(); +const resolveBundlePathDetailed = vi.fn(); +const assertBundleExists = vi.fn(); + +vi.mock("../../lib/control-plane-auth.js", () => ({ + createApiFromCredentials: vi.fn(() => ({ + uploadBuild, + })), +})); + +vi.mock("../../lib/config.js", () => ({ + requireCredentials: (...args: unknown[]) => requireCredentials(...args), +})); + +vi.mock("../../lib/project-config.js", () => ({ + resolveProjectId: (...args: unknown[]) => resolveProjectId(...args), + resolveBundlePathDetailed: (...args: unknown[]) => + resolveBundlePathDetailed(...args), + assertBundleExists: (...args: unknown[]) => assertBundleExists(...args), +})); + +vi.mock("./validate.js", () => ({ + runBuildValidate: vi.fn(), +})); + +describe("runBuildUpload", () => { + let bundlePath: string; + + beforeEach(async () => { + uploadBuild.mockReset(); + requireCredentials.mockReset(); + resolveProjectId.mockReset(); + resolveBundlePathDetailed.mockReset(); + assertBundleExists.mockReset(); + vi.mocked(runBuildValidate).mockReset(); + + bundlePath = join( + tmpdir(), + `voicethere-upload-${Date.now()}-${Math.random().toString(16).slice(2)}.js`, + ); + await mkdir(tmpdir(), { recursive: true }); + await writeFile(bundlePath, "export {};\n", "utf8"); + + requireCredentials.mockResolvedValue({ + api_key: "vth_test", + api_base: "https://app.voicethere.io/api/v1", + }); + resolveProjectId.mockResolvedValue({ + projectId: "proj-1", + source: "config", + configPath: "/tmp/.voicethere/config.json", + }); + resolveBundlePathDetailed.mockResolvedValue({ + absolutePath: bundlePath, + relativePath: "dist/agent.js", + cwd: "/tmp", + source: "config", + }); + assertBundleExists.mockResolvedValue(undefined); + uploadBuild.mockResolvedValue({ + id: "550e8400-e29b-41d4-a716-446655440000", + project_id: "proj-1", + message: "Add Spanish greeting", + created_at: "2026-09-21T12:00:00Z", + }); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("prints a human-readable summary including the promote command", async () => { + await runBuildUpload({ skipValidate: true }); + + const stdout = vi.mocked(console.log).mock.calls.map((call) => call[0]); + expect(stdout).toContain( + "Uploaded build 550e8400-e29b-41d4-a716-446655440000", + ); + expect(stdout).toContain( + " voicethere build promote 550e8400-e29b-41d4-a716-446655440000", + ); + }); + + it("prints only the build UUID when printId is set", async () => { + await runBuildUpload({ skipValidate: true, printId: true }); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(vi.mocked(console.log).mock.calls[0]?.[0]).not.toMatch(/\s/); + }); + + it("still validates unless skipValidate is set, without extra stdout for printId", async () => { + vi.mocked(runBuildValidate).mockResolvedValue(undefined); + + await runBuildUpload({ printId: true }); + + expect(runBuildValidate).toHaveBeenCalledWith({ + file: undefined, + logContext: false, + }); + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440000", + ); + }); +}); diff --git a/src/commands/build/upload.ts b/src/commands/build/upload.ts index f0fd3d9..1770d6e 100644 --- a/src/commands/build/upload.ts +++ b/src/commands/build/upload.ts @@ -18,6 +18,8 @@ import { runBuildValidate, type BuildValidateOptions } from "./validate.js"; export interface BuildUploadOptions extends BuildValidateOptions { message?: string; skipValidate?: boolean; + /** Print only the build UUID on stdout (progress stays on stderr). */ + printId?: boolean; } export async function runBuildUpload( @@ -49,6 +51,11 @@ export async function runBuildUpload( options.message, ); + if (options.printId) { + console.log(build.id); + return; + } + console.log(`Uploaded build ${build.id}`); if (build.message) { console.log(`Message: ${build.message}`); diff --git a/src/commands/build/validate.test.ts b/src/commands/build/validate.test.ts index f760631..884fcf0 100644 --- a/src/commands/build/validate.test.ts +++ b/src/commands/build/validate.test.ts @@ -90,4 +90,20 @@ describe("runBuildValidate", () => { ]); expect(args.join(" ")).not.toContain("npx"); }); + + it("reports success on stderr so stdout stays scriptable", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await runBuildValidate({ file: "dist/agent.js", logContext: false }); + + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + "[voicethere] Bundle validated: /proj/dist/agent.js", + ); + } finally { + vi.restoreAllMocks(); + } + }); }); diff --git a/src/commands/build/validate.ts b/src/commands/build/validate.ts index bb756fb..50b87f7 100644 --- a/src/commands/build/validate.ts +++ b/src/commands/build/validate.ts @@ -64,7 +64,7 @@ export async function runBuildValidate( logStep("Running @voicethere/agent verify on bundle"); logVerbose(`verify bundle: ${bundle.absolutePath}`); await spawnAgentVerify(bundle.absolutePath); - console.log(`Bundle validated: ${bundle.absolutePath}`); + logStep(`Bundle validated: ${bundle.absolutePath}`); } async function spawnAgentVerify(bundlePath: string): Promise { diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 2978244..4825ba0 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -162,7 +162,46 @@ describe("runInit", () => { localOnly: true, noInstall: true, }), - ).rejects.toThrow(/Unknown template "redis-sync"/); + ).rejects.toThrow(/e2e-only template/); + }); + + it("writes webhooks and webhooks-redis product templates", async () => { + const webhooksDir = join(tempDir, "webhooks-local"); + await runInit({ + dir: webhooksDir, + template: "webhooks", + localOnly: true, + noInstall: true, + }); + await expect( + access(join(webhooksDir, "webhooks", "agent.ts")), + ).resolves.toBeUndefined(); + + const redisDir = join(tempDir, "webhooks-redis-local"); + await runInit({ + dir: redisDir, + template: "webhooks-redis", + localOnly: true, + noInstall: true, + }); + const packageJson = JSON.parse( + await readFile(join(redisDir, "package.json"), "utf8"), + ) as { dependencies: Record }; + expect(packageJson.dependencies.ioredis).toBe("^5.11.1"); + }); + + it("scaffolds every product template from the installed agent registry", async () => { + const { listTemplates } = await import("@voicethere/agent/templates"); + for (const template of listTemplates({ kind: "product" })) { + const target = join(tempDir, `all-${template.id}`); + await runInit({ + dir: target, + template: template.id, + localOnly: true, + noInstall: true, + }); + await expect(access(join(target, template.entry))).resolves.toBeUndefined(); + } }); it("writes blank stub with agent.ts entry", async () => { diff --git a/src/lib/customer-package-json.ts b/src/lib/customer-package-json.ts index 3dd78d3..fd6bd77 100644 --- a/src/lib/customer-package-json.ts +++ b/src/lib/customer-package-json.ts @@ -1,11 +1,8 @@ +import { templateNpmDependencies } from "./project-templates.js"; + /** Customer workspace pin — may differ from the CLI runtime `@voicethere/agent` dependency. */ export const CUSTOMER_AGENT_VERSION = "0.8.0"; -/** Extra npm deps for platform create templates (not in every agent registry release). */ -const EXTRA_TEMPLATE_NPM_DEPS: Record> = { - "game-sync": { ioredis: "^5.11.1" }, -}; - export interface BuildCustomerPackageJsonOptions { templateId: string; /** Agent entry relative to project root (e.g. `echo/agent.ts`, `voice-showcase/agent.ts`). */ @@ -28,7 +25,7 @@ export function buildCustomerPackageJson( const name = options.packageName ?? defaultCustomerPackageName(templateId); const dependencies: Record = { "@voicethere/agent": `^${CUSTOMER_AGENT_VERSION}`, - ...(EXTRA_TEMPLATE_NPM_DEPS[templateId] ?? {}), + ...templateNpmDependencies(templateId), }; const pkg = { diff --git a/src/lib/project-templates.test.ts b/src/lib/project-templates.test.ts index ac6afab..412c28b 100644 --- a/src/lib/project-templates.test.ts +++ b/src/lib/project-templates.test.ts @@ -1,37 +1,44 @@ import { describe, expect, it } from "vitest"; +import { listTemplates } from "@voicethere/agent/templates"; import { + AGENT_TEMPLATES_TREE_URL, assertPlatformCreateTemplateId, + formatInitTemplateHelp, + listInitTemplateIds, + listProductTemplateIds, loadTemplateWorkspaceSources, PLATFORM_CREATE_TEMPLATE_IDS, resolveTemplateEntryPath, + templateNpmDependencies, } from "./project-templates.js"; describe("project templates", () => { - it("lists dashboard create templates including 0.8.0 world-sync ids", () => { - expect(PLATFORM_CREATE_TEMPLATE_IDS).toEqual([ - "blank", - "voice-starter", - "echo-dc", - "echo", - "voice-showcase", - "world-sync", - "world-sync-binary", - "game-sync", - "recording-consent", - "positional-tts", - "spatial-showcase", - ]); + it("follows the installed @voicethere/agent product registry plus blank", () => { + const productIds = listTemplates({ kind: "product" }).map( + (template) => template.id, + ); + expect(listProductTemplateIds()).toEqual(productIds); + expect(listInitTemplateIds()).toEqual(["blank", ...productIds]); + expect(PLATFORM_CREATE_TEMPLATE_IDS).toEqual(listInitTemplateIds()); + expect(listInitTemplateIds()).toContain("echo"); + expect(listInitTemplateIds()).toContain("webhooks"); + expect(listInitTemplateIds()).toContain("webhooks-redis"); + }); + + it("does not treat e2e templates as init ids", () => { + const initIds = new Set(listInitTemplateIds()); + for (const template of listTemplates({ kind: "e2e" })) { + expect(initIds.has(template.id)).toBe(false); + } }); - it("resolves folder entries from @voicethere/agent 0.8.0", () => { + it("resolves folder entries from the installed agent package", () => { expect(resolveTemplateEntryPath("blank")).toBe("agent.ts"); expect(resolveTemplateEntryPath("echo")).toBe("echo/agent.ts"); expect(resolveTemplateEntryPath("world-sync-binary")).toBe( "world-sync-binary/agent.ts", ); - expect(resolveTemplateEntryPath("spatial-showcase")).toBe( - "spatial-showcase/agent.ts", - ); + expect(resolveTemplateEntryPath("webhooks")).toBe("webhooks/agent.ts"); }); it("loads binary world-sync sources from the installed agent package", () => { @@ -43,9 +50,43 @@ describe("project templates", () => { expect(files[0]?.content).toContain("onDataChannelBinary"); }); - it("rejects unknown create template ids", () => { + it("loads webhook template sources from the installed agent package", () => { + const files = loadTemplateWorkspaceSources("webhooks"); + expect(files.map((file) => file.path)).toEqual(["webhooks/agent.ts"]); + expect(files[0]?.content).toContain("onWebhook"); + }); + + it("reads extra npm deps from the agent registry", () => { + expect(templateNpmDependencies("blank")).toEqual({}); + expect(templateNpmDependencies("echo")).toEqual({}); + expect(templateNpmDependencies("game-sync")).toMatchObject({ + ioredis: "^5.11.1", + }); + expect(templateNpmDependencies("webhooks-redis")).toMatchObject({ + ioredis: "^5.11.1", + }); + }); + + it("rejects e2e-only templates with a distinct error", () => { expect(() => assertPlatformCreateTemplateId("redis-sync")).toThrow( - /Unknown template "redis-sync"/, + /e2e-only template/, + ); + }); + + it("rejects unknown create template ids", () => { + expect(() => assertPlatformCreateTemplateId("not-a-template")).toThrow( + /Unknown template "not-a-template"/, ); }); + + it("lists every product template and the GitHub permalink in --help text", () => { + const help = formatInitTemplateHelp(); + expect(help).toContain(AGENT_TEMPLATES_TREE_URL); + expect(help).toContain("blank"); + for (const template of listTemplates({ kind: "product" })) { + expect(help).toContain(template.id); + } + expect(help).toContain("E2e-only"); + expect(help).toContain("echo-smoke"); + }); }); diff --git a/src/lib/project-templates.ts b/src/lib/project-templates.ts index b948c80..127ab19 100644 --- a/src/lib/project-templates.ts +++ b/src/lib/project-templates.ts @@ -1,30 +1,15 @@ import { getTemplate, + listTemplates, loadTemplateWorkspaceSources as loadAgentTemplateWorkspaceSources, type TemplateSourceFile, } from "@voicethere/agent/templates"; -/** Templates accepted by POST /projects (`template` field). */ -export const PLATFORM_CREATE_TEMPLATE_IDS = [ - "blank", - "voice-starter", - "echo-dc", - "echo", - "voice-showcase", - "world-sync", - "world-sync-binary", - "game-sync", - "recording-consent", - "positional-tts", - "spatial-showcase", -] as const; - -export type PlatformCreateTemplateId = - (typeof PLATFORM_CREATE_TEMPLATE_IDS)[number]; - -const PLATFORM_CREATE_TEMPLATE_SET = new Set( - PLATFORM_CREATE_TEMPLATE_IDS, -); +export const BLANK_TEMPLATE_ID = "blank"; + +/** Permalink to the agent template sources on GitHub. */ +export const AGENT_TEMPLATES_TREE_URL = + "https://github.com/voicethere/agent/tree/main/templates"; export const BLANK_AGENT_ENTRY = "agent.ts"; @@ -37,22 +22,45 @@ defineAgent({ }); `; -export function isPlatformCreateTemplateId( - id: string, -): id is PlatformCreateTemplateId { - return PLATFORM_CREATE_TEMPLATE_SET.has(id); +export function listProductTemplateIds(): string[] { + return listTemplates({ kind: "product" }).map((template) => template.id); +} + +/** Ids accepted by `voicethere init --template` (blank + live product registry). */ +export function listInitTemplateIds(): string[] { + return [BLANK_TEMPLATE_ID, ...listProductTemplateIds()]; +} + +/** Live list from the installed `@voicethere/agent` package at module load. */ +export const PLATFORM_CREATE_TEMPLATE_IDS = listInitTemplateIds(); + +export type PlatformCreateTemplateId = string; + +export function isPlatformCreateTemplateId(id: string): boolean { + return listInitTemplateIds().includes(id); } export function assertPlatformCreateTemplateId(id: string): void { - if (!isPlatformCreateTemplateId(id)) { + if (isPlatformCreateTemplateId(id)) { + return; + } + + const e2eMatch = listTemplates({ kind: "e2e" }).find( + (template) => template.id === id, + ); + if (e2eMatch) { throw new Error( - `Unknown template "${id}". Choose one of: ${PLATFORM_CREATE_TEMPLATE_IDS.join(", ")}`, + `"${id}" is an e2e-only template. Choose a product template or blank: ${listInitTemplateIds().join(", ")}`, ); } + + throw new Error( + `Unknown template "${id}". Choose one of: ${listInitTemplateIds().join(", ")}`, + ); } export function resolveTemplateEntryPath(templateId: string): string { - if (templateId === "blank") { + if (templateId === BLANK_TEMPLATE_ID) { return BLANK_AGENT_ENTRY; } @@ -63,9 +71,41 @@ export function resolveTemplateEntryPath(templateId: string): string { export function loadTemplateWorkspaceSources( templateId: string, ): TemplateSourceFile[] { - if (templateId === "blank") { + if (templateId === BLANK_TEMPLATE_ID) { return [{ path: BLANK_AGENT_ENTRY, content: BLANK_AGENT_SOURCE }]; } return loadAgentTemplateWorkspaceSources(templateId); } + +export function templateNpmDependencies( + templateId: string, +): Record { + if (templateId === BLANK_TEMPLATE_ID) { + return {}; + } + + return getTemplate(templateId).npmDependencies ?? {}; +} + +export function formatInitTemplateHelp(): string { + const product = listTemplates({ kind: "product" }); + const e2e = listTemplates({ kind: "e2e" }); + const idWidth = Math.max( + BLANK_TEMPLATE_ID.length, + ...product.map((template) => template.id.length), + ); + + return [ + "", + "Templates (from the installed @voicethere/agent package, plus blank):", + ` ${BLANK_TEMPLATE_ID.padEnd(idWidth)} Minimal stub (agent.ts) — CLI only`, + ...product.map( + (template) => + ` ${template.id.padEnd(idWidth)} ${template.description}`, + ), + "", + `E2e-only (not accepted by init): ${e2e.map((template) => template.id).join(", ")}`, + `Sources: ${AGENT_TEMPLATES_TREE_URL}`, + ].join("\n"); +}