diff --git a/CHANGELOG.md b/CHANGELOG.md index ba34a7b..b67b2c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll ## [Unreleased] +## [0.14.0] - 2026-09-10 + +### Added + +- **`voicethere init [dir]`** — scaffold a local npm agent workspace from a platform template (default `echo`), optionally create a linked cloud project, write `.voicethere/config.json`, and seed the dashboard Code workspace. Flags: `--name`, `--slug`, `--template`, `--local-only`, `--no-install`, `--force`. +- **`voicethere source push`** / **`source pull`** — upload or download Code workspace sources (`.ts`, `.tsx`, `.js`, `.json`, `.d.ts`) with optimistic revision on push. +- **`createProject` API client** — optional `template` field on `POST /projects`. + ## [0.13.2] - 2026-09-03 ### Added diff --git a/README.md b/README.md index cc8d33c..478da6e 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,32 @@ After browser login, if those env vars are set, the CLI prints a warning that th More detail: [CLI login guide](https://app.voicethere.io/docs/cli-login) on the VoiceThere docs site. -### 2. New agent repo — create project and commit config +### 2. New agent project — `voicethere init` + +Scaffold a local npm workspace (sources, `package.json`, `.gitignore`) and optionally create a linked VoiceThere project in one step: + +```bash +voicethere init my-agent --template echo +cd my-agent +npm install # skipped when you pass --no-install on init +npm run verify +``` + +| Flag | Behavior | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--template ` | Platform template (default: `echo`). Also: `blank`, `voice-starter`, `echo-dc`, `voice-showcase`, `game-sync`, `recording-consent`, `positional-tts` | +| `--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 | + +Sync edits with the dashboard **Code** tab: + +```bash +voicethere source push # upload local `.ts` / `.json` sources +voicethere source pull # download cloud workspace (does not delete extra local files) +``` + +### 3. New agent repo — create project and commit config (manual) From your agent project root (where you build `dist/agent.js`): @@ -123,7 +148,7 @@ git add .voicethere/config.json git commit -m "chore: use VoiceThere project" ``` -### 3. Upload a build (store artifact) +### 4. Upload a build (store artifact) Upload **stores** a new immutable build in history — it does **not** go live yet. @@ -134,7 +159,7 @@ voicethere build upload -m "Add Spanish greeting and fix barge-in" `-m` / `--message` is like a git commit message: a short label so you can tell builds apart in `build list` and the dashboard. -### 4. Promote a build (set active in control plane) +### 5. Promote a build (set active in control plane) **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). @@ -154,7 +179,7 @@ voicethere build upload -m "v0.2 — shorter silence timeout" voicethere build promote ``` -### 5. Clone an existing repo (config already in git) +### 6. Clone an existing repo (config already in git) ```bash git clone @@ -169,7 +194,7 @@ voicethere build promote No `projects use` needed — the active project travels with the repo. -### 6. Use a different cloud project +### 7. Use a different cloud project ```bash voicethere projects list @@ -185,7 +210,7 @@ Inspect the selection anytime: voicethere projects show ``` -### 7. CI / automation +### 8. CI / automation **With committed `.voicethere/config.json`** (typical agent repo): @@ -215,7 +240,7 @@ Other CI notes: Split upload and promote across jobs if you want a human approval gate between them. -### 8. Deploy to cloud runners +### 9. Deploy to cloud runners `voicethere deploy --wait` **promotes the build (when needed) and rolls out to cloud runners**, blocking until the deployment is active (or failed). diff --git a/package.json b/package.json index 32d07a5..9404a04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@voicethere/cli", - "version": "0.13.2", + "version": "0.14.0", "description": "VoiceThere cloud CLI — login, projects, and agent bundle upload", "type": "module", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 81d8ba0..b90d632 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -77,6 +77,8 @@ 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 { 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"; @@ -1217,6 +1219,73 @@ async function main(): Promise { }); }); + program + .command("init") + .description( + "Scaffold a local npm agent workspace and optionally create a linked cloud project", + ) + .argument("[dir]", "Target directory (default: current directory)", ".") + .option( + "--name ", + "Cloud and package display name (default: directory name)", + ) + .option( + "--slug ", + "URL-safe project slug (derived from name when omitted)", + ) + .option( + "--template ", + "Platform template (default: echo). Use blank for a minimal stub.", + "echo", + ) + .option( + "--local-only", + "Write files locally only — no login, cloud project, or source upload", + ) + .option("--no-install", "Skip npm install in the target directory") + .option("--force", "Overwrite when package.json already exists") + .action( + async ( + dir: string, + options: { + name?: string; + slug?: string; + template?: string; + localOnly?: boolean; + noInstall?: boolean; + force?: boolean; + }, + ) => { + await runInit({ + dir, + name: options.name, + slug: options.slug, + template: options.template, + localOnly: options.localOnly, + noInstall: options.noInstall, + force: options.force, + }); + }, + ); + + const source = program + .command("source") + .description("Sync agent source with the dashboard Code workspace"); + + source + .command("push") + .description("Upload local workspace sources to the cloud project") + .action(async () => { + await runSourcePush(); + }); + + source + .command("pull") + .description("Download cloud workspace sources to the local repo") + .action(async () => { + await runSourcePull(); + }); + program .command("deploy") .description("Promote (if needed) and roll out to cloud runners") diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts new file mode 100644 index 0000000..9a7a52c --- /dev/null +++ b/src/commands/init.test.ts @@ -0,0 +1,206 @@ +import { access, mkdir, readFile, rm, 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 * as initModule from "./init.js"; +import { runInit } from "./init.js"; + +const createProject = vi.fn(); +const getProjectSource = vi.fn(); +const putProjectSource = vi.fn(); +const requireCredentials = vi.fn(); +const writeProjectConfig = vi.fn(); + +vi.mock("../lib/control-plane-auth.js", () => ({ + createApiFromCredentials: vi.fn(() => ({ + createProject, + getProjectSource, + putProjectSource, + })), +})); + +vi.mock("../lib/config.js", () => ({ + requireCredentials: (...args: unknown[]) => requireCredentials(...args), +})); + +vi.mock("../lib/project-config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + writeProjectConfig: (...args: unknown[]) => writeProjectConfig(...args), + }; +}); + +describe("runInit", () => { + let tempDir: string; + let runNpmInstallSpy: ReturnType; + + beforeEach(async () => { + tempDir = join( + tmpdir(), + `voicethere-init-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await mkdir(tempDir, { recursive: true }); + + createProject.mockReset(); + getProjectSource.mockReset(); + putProjectSource.mockReset(); + requireCredentials.mockReset(); + writeProjectConfig.mockReset(); + runNpmInstallSpy = vi + .spyOn(initModule, "runNpmInstall") + .mockResolvedValue(undefined); + + vi.spyOn(console, "error").mockImplementation(() => {}); + + requireCredentials.mockResolvedValue({ + api_key: "vth_test", + api_base: "https://app.voicethere.io/api/v1", + }); + createProject.mockResolvedValue({ + id: "proj-init", + org_id: "org-1", + name: "My Agent", + slug: "my-agent", + active_build_id: null, + created_at: "2026-01-01T00:00:00Z", + }); + getProjectSource.mockResolvedValue({ + project_id: "proj-init", + entry_path: "", + files: [], + revision: 0, + }); + putProjectSource.mockResolvedValue({ + project_id: "proj-init", + entry_path: "echo.ts", + files: [], + revision: 1, + }); + writeProjectConfig.mockResolvedValue( + join(tempDir, ".voicethere", "config.json"), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("writes echo workspace locally without cloud calls", async () => { + const target = join(tempDir, "echo-local"); + await runInit({ + dir: target, + name: "Echo Local", + template: "echo", + localOnly: true, + noInstall: true, + }); + + expect(createProject).not.toHaveBeenCalled(); + expect(writeProjectConfig).not.toHaveBeenCalled(); + expect(runNpmInstallSpy).not.toHaveBeenCalled(); + + const packageJson = JSON.parse( + await readFile(join(target, "package.json"), "utf8"), + ) as { + dependencies: Record; + scripts: Record; + }; + expect(packageJson.dependencies["@voicethere/agent"]).toBe("^0.7.7"); + expect(packageJson.scripts.verify).toContain("echo.ts"); + expect(packageJson.scripts.upload).toBe("voicethere build upload"); + expect(packageJson.scripts["source:push"]).toBe("voicethere source push"); + + await expect(access(join(target, "echo.ts"))).resolves.toBeUndefined(); + await expect(access(join(target, ".gitignore"))).resolves.toBeUndefined(); + await expect( + access(join(target, ".voicethere", "config.json")), + ).rejects.toThrow(); + }); + + it("adds ioredis for game-sync template", async () => { + const target = join(tempDir, "game-sync-local"); + await runInit({ + dir: target, + template: "game-sync", + localOnly: true, + noInstall: true, + }); + + const packageJson = JSON.parse( + await readFile(join(target, "package.json"), "utf8"), + ) as { dependencies: Record }; + expect(packageJson.dependencies.ioredis).toBe("^5.11.1"); + }); + + it("writes blank stub with agent.ts entry", async () => { + const target = join(tempDir, "blank-local"); + await runInit({ + dir: target, + template: "blank", + localOnly: true, + noInstall: true, + }); + + const agentSource = await readFile(join(target, "agent.ts"), "utf8"); + expect(agentSource).toContain("defineAgent"); + + const packageJson = JSON.parse( + await readFile(join(target, "package.json"), "utf8"), + ) as { scripts: Record }; + expect(packageJson.scripts.build).toContain("--entry agent.ts"); + }); + + it("creates cloud project, config, and seeds source", async () => { + const target = join(tempDir, "cloud-echo"); + getProjectSource.mockResolvedValueOnce({ + project_id: "proj-init", + entry_path: "echo.ts", + files: [{ path: "echo.ts", content: "seed" }], + revision: 1, + }); + + await runInit({ + dir: target, + name: "Cloud Echo", + template: "echo", + noInstall: true, + }); + + expect(createProject).toHaveBeenCalledWith( + "Cloud Echo", + "cloud-echo", + "echo", + ); + expect(writeProjectConfig).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "proj-init", + bundle: "dist/agent.js", + }), + { startDir: target }, + ); + expect(getProjectSource).toHaveBeenCalledWith("proj-init"); + expect(putProjectSource).toHaveBeenCalledWith( + "proj-init", + expect.objectContaining({ + entry_path: "echo.ts", + revision: 1, + files: expect.arrayContaining([ + expect.objectContaining({ path: "package.json" }), + ]), + }), + ); + }); + + it("refuses existing package.json without --force", async () => { + const target = join(tempDir, "existing"); + await mkdir(target, { recursive: true }); + await writeFile(join(target, "package.json"), "{}\n", "utf8"); + + await expect( + runInit({ dir: target, localOnly: true, noInstall: true }), + ).rejects.toThrow(/already exists/); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..77ec7b4 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,179 @@ +import { spawn } from "node:child_process"; +import { access, mkdir, stat, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +import { slugifyName } from "./projects/create.js"; +import { createApiFromCredentials } from "../lib/control-plane-auth.js"; +import { requireCredentials } from "../lib/config.js"; +import { logCommandInfo, logStep, logVerbose } from "../lib/command-log.js"; +import { + buildCustomerPackageJson, + CUSTOMER_GITIGNORE, + defaultCustomerPackageName, +} from "../lib/customer-package-json.js"; +import { + assertPlatformCreateTemplateId, + loadTemplateWorkspaceSources, + resolveTemplateEntryPath, +} from "../lib/project-templates.js"; +import { DEFAULT_BUNDLE, writeProjectConfig } from "../lib/project-config.js"; +import type { ProjectSourceFile } from "../lib/api.js"; + +export interface InitOptions { + /** Target directory (default: current working directory). */ + dir?: string; + name?: string; + slug?: string; + template?: string; + localOnly?: boolean; + noInstall?: boolean; + force?: boolean; +} + +export async function runNpmInstall(cwd: string): Promise { + await new Promise((resolvePromise, reject) => { + const child = spawn("npm", ["install"], { + cwd, + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolvePromise(); + return; + } + reject(new Error(`npm install exited with code ${code ?? "unknown"}`)); + }); + }); +} + +async function writeScaffoldFiles( + targetDir: string, + options: { + templateId: string; + packageName: string; + entryPath: string; + }, +): Promise { + const templateSources = loadTemplateWorkspaceSources(options.templateId); + const packageJson = buildCustomerPackageJson({ + templateId: options.templateId, + entryPath: options.entryPath, + packageName: options.packageName, + }); + + await mkdir(targetDir, { recursive: true }); + + for (const file of templateSources) { + const absolutePath = join(targetDir, file.path); + await mkdir(dirnameFor(file.path, targetDir), { recursive: true }); + await writeFile(absolutePath, file.content, "utf8"); + } + + await writeFile(join(targetDir, "package.json"), packageJson, "utf8"); + await writeFile(join(targetDir, ".gitignore"), CUSTOMER_GITIGNORE, "utf8"); + + const pushFiles: ProjectSourceFile[] = [ + ...templateSources, + { path: "package.json", content: packageJson }, + ]; + pushFiles.sort((a, b) => a.path.localeCompare(b.path)); + return pushFiles; +} + +function dirnameFor(relativePath: string, rootDir: string): string { + const segments = relativePath.split("/"); + if (segments.length <= 1) { + return rootDir; + } + return join(rootDir, ...segments.slice(0, -1)); +} + +export async function runInit(options: InitOptions = {}): Promise { + const templateId = (options.template?.trim() || "echo").toLowerCase(); + assertPlatformCreateTemplateId(templateId); + + const targetDir = resolve(options.dir?.trim() || "."); + const targetStat = await stat(targetDir).catch(() => null); + if (targetStat && !targetStat.isDirectory()) { + throw new Error(`Refusing to init over a non-directory path: ${targetDir}`); + } + + const packageJsonPath = join(targetDir, "package.json"); + const packageExists = await access(packageJsonPath) + .then(() => true) + .catch(() => false); + if (packageExists && !options.force) { + throw new Error( + `${packageJsonPath} already exists — use --force to overwrite or choose another directory`, + ); + } + + const displayName = (options.name?.trim() || basename(targetDir)).trim(); + if (!displayName) { + throw new Error( + "project name is required (--name or a non-empty directory name)", + ); + } + + const slug = (options.slug?.trim() || slugifyName(displayName)).replace( + /^-+|-+$/g, + "", + ); + if (!slug) { + throw new Error("Could not derive a valid slug; pass --slug explicitly"); + } + + const entryPath = resolveTemplateEntryPath(templateId); + const packageName = defaultCustomerPackageName(templateId); + + logStep(`Scaffolding ${templateId} workspace in ${targetDir}`); + const pushFiles = await writeScaffoldFiles(targetDir, { + templateId, + packageName, + entryPath, + }); + + if (!options.localOnly) { + const credentials = await requireCredentials(); + const api = createApiFromCredentials(credentials); + + logStep( + `Creating cloud project "${displayName}" (slug=${slug}, template=${templateId})`, + ); + const project = await api.createProject(displayName, slug, templateId); + logVerbose(`project id: ${project.id}`); + + logStep("Writing .voicethere/config.json"); + const configPath = await writeProjectConfig( + { + project_id: project.id, + project_slug: project.slug, + name: project.name, + bundle: DEFAULT_BUNDLE, + }, + { startDir: targetDir }, + ); + logCommandInfo(`project config: ${configPath}`); + + logStep("Seeding dashboard Code workspace"); + const remote = await api.getProjectSource(project.id); + const updated = await api.putProjectSource(project.id, { + entry_path: entryPath, + files: pushFiles, + revision: remote.revision, + }); + logVerbose(`source revision: ${remote.revision} → ${updated.revision}`); + } + + if (!options.noInstall) { + logStep("Running npm install"); + await runNpmInstall(targetDir); + } + + const nextSteps = options.localOnly + ? "Next: npm run verify" + : "Next: edit sources, npm run verify, voicethere source push"; + console.error(nextSteps); +} diff --git a/src/commands/source.test.ts b/src/commands/source.test.ts new file mode 100644 index 0000000..bd177f8 --- /dev/null +++ b/src/commands/source.test.ts @@ -0,0 +1,153 @@ +import { mkdir, readFile, rm, 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 { runSourcePull, runSourcePush } from "./source.js"; + +const getProjectSource = vi.fn(); +const putProjectSource = vi.fn(); +const requireCredentials = vi.fn(); + +vi.mock("../lib/control-plane-auth.js", () => ({ + createApiFromCredentials: vi.fn(() => ({ + getProjectSource, + putProjectSource, + })), +})); + +vi.mock("../lib/config.js", () => ({ + requireCredentials: (...args: unknown[]) => requireCredentials(...args), +})); + +vi.mock("../lib/project-config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + requireProjectId: vi.fn(async () => "proj-src"), + readProjectConfig: vi.fn(async () => ({ + config: { project_id: "proj-src" }, + path: "", + })), + }; +}); + +describe("source push/pull", () => { + let tempDir: string; + let configPath: string; + + beforeEach(async () => { + tempDir = join( + tmpdir(), + `voicethere-source-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + configPath = join(tempDir, ".voicethere", "config.json"); + await mkdir(join(tempDir, ".voicethere"), { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify({ version: 1, project_id: "proj-src" }, null, 2)}\n`, + "utf8", + ); + await writeFile( + join(tempDir, "package.json"), + `${JSON.stringify( + { + scripts: { + build: + "npx @voicethere/agent build --entry echo.ts --outfile dist/agent.js", + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeFile(join(tempDir, "echo.ts"), "export {};\n", "utf8"); + + const { readProjectConfig } = await import("../lib/project-config.js"); + vi.mocked(readProjectConfig).mockResolvedValue({ + config: { project_id: "proj-src" }, + path: configPath, + }); + + getProjectSource.mockReset(); + putProjectSource.mockReset(); + requireCredentials.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("source push sends GET then PUT with revision from GET", async () => { + getProjectSource.mockResolvedValue({ + project_id: "proj-src", + entry_path: "echo.ts", + files: [], + revision: 4, + }); + putProjectSource.mockResolvedValue({ + project_id: "proj-src", + entry_path: "echo.ts", + files: [], + revision: 5, + }); + + const previousCwd = process.cwd(); + process.chdir(tempDir); + try { + await runSourcePush(); + } finally { + process.chdir(previousCwd); + } + + expect(getProjectSource).toHaveBeenCalledWith("proj-src"); + expect(putProjectSource).toHaveBeenCalledWith( + "proj-src", + expect.objectContaining({ + entry_path: "echo.ts", + revision: 4, + files: expect.arrayContaining([ + expect.objectContaining({ path: "echo.ts" }), + expect.objectContaining({ path: "package.json" }), + ]), + }), + ); + expect(console.log).toHaveBeenCalledWith("5"); + }); + + it("source pull writes files from GET payload", async () => { + getProjectSource.mockResolvedValue({ + project_id: "proj-src", + entry_path: "agent.ts", + revision: 2, + files: [ + { path: "agent.ts", content: "defineAgent({});\n" }, + { path: "helpers/util.ts", content: "export const x = 1;\n" }, + ], + }); + + const previousCwd = process.cwd(); + process.chdir(tempDir); + try { + await runSourcePull(); + } finally { + process.chdir(previousCwd); + } + + expect(await readFile(join(tempDir, "agent.ts"), "utf8")).toBe( + "defineAgent({});\n", + ); + expect(await readFile(join(tempDir, "helpers", "util.ts"), "utf8")).toBe( + "export const x = 1;\n", + ); + }); +}); diff --git a/src/commands/source.ts b/src/commands/source.ts new file mode 100644 index 0000000..0ca7196 --- /dev/null +++ b/src/commands/source.ts @@ -0,0 +1,66 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { createApiFromCredentials } from "../lib/control-plane-auth.js"; +import { requireCredentials } from "../lib/config.js"; +import { logCommandInfo, logStep } from "../lib/command-log.js"; +import { + readProjectConfig, + repoRootFromConfigPath, + requireProjectId, +} from "../lib/project-config.js"; +import { + collectWorkspaceSourceFiles, + resolveWorkspaceEntryPath, +} from "../lib/workspace-files.js"; + +export async function runSourcePush(): Promise { + const credentials = await requireCredentials(); + const api = createApiFromCredentials(credentials); + const projectId = await requireProjectId(); + const linked = await readProjectConfig(); + if (!linked) { + throw new Error("No .voicethere/config.json found"); + } + + const repoRoot = repoRootFromConfigPath(linked.path); + logStep(`Collecting workspace files from ${repoRoot}`); + const files = await collectWorkspaceSourceFiles(repoRoot); + + const remote = await api.getProjectSource(projectId); + const entryPath = resolveWorkspaceEntryPath(files, remote.entry_path); + + logStep(`Uploading ${files.length} file(s) (revision ${remote.revision})`); + const updated = await api.putProjectSource(projectId, { + entry_path: entryPath, + files, + revision: remote.revision, + }); + + logCommandInfo(`source revision: ${updated.revision}`); + console.log(String(updated.revision)); +} + +export async function runSourcePull(): Promise { + const credentials = await requireCredentials(); + const api = createApiFromCredentials(credentials); + const projectId = await requireProjectId(); + const linked = await readProjectConfig(); + if (!linked) { + throw new Error("No .voicethere/config.json found"); + } + + const repoRoot = repoRootFromConfigPath(linked.path); + logStep("Downloading Code workspace from VoiceThere"); + const remote = await api.getProjectSource(projectId); + + for (const file of remote.files) { + const absolutePath = join(repoRoot, file.path); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, file.content, "utf8"); + } + + logCommandInfo( + `pulled ${remote.files.length} file(s) at revision ${remote.revision}`, + ); +} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 79e008e..a3e59e2 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -272,6 +272,87 @@ describe("VoicethereApi", () => { ); }); + it("creates a project with optional template", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + id: "proj-3", + org_id: "org-1", + name: "Echo Agent", + slug: "echo-agent", + active_build_id: null, + created_at: "2026-06-09T12:00:00.000Z", + }), + { status: 200 }, + ), + ); + + const api = new VoicethereApi(apiKey, apiBase); + await api.createProject("Echo Agent", "echo-agent", "echo"); + + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(init.body).toBe( + JSON.stringify({ + name: "Echo Agent", + slug: "echo-agent", + template: "echo", + }), + ); + }); + + it("gets and puts project source workspace", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + project_id: "proj-src", + entry_path: "echo.ts", + files: [{ path: "echo.ts", content: "old" }], + revision: 2, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + project_id: "proj-src", + entry_path: "echo.ts", + files: [{ path: "echo.ts", content: "new" }], + revision: 3, + }), + { status: 200 }, + ), + ); + + const api = new VoicethereApi(apiKey, apiBase); + const remote = await api.getProjectSource("proj-src"); + expect(remote.revision).toBe(2); + + const updated = await api.putProjectSource("proj-src", { + entry_path: "echo.ts", + files: [{ path: "echo.ts", content: "new" }], + revision: 2, + }); + expect(updated.revision).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const getCall = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(getCall[0].toString()).toBe(`${apiBase}/projects/proj-src/source`); + expect(getCall[1].method).toBe("GET"); + + const putCall = fetchMock.mock.calls[1] as [URL, RequestInit]; + expect(putCall[1].method).toBe("PUT"); + expect(putCall[1].body).toBe( + JSON.stringify({ + entry_path: "echo.ts", + files: [{ path: "echo.ts", content: "new" }], + revision: 2, + }), + ); + }); + it("throws ApiError with server message", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( diff --git a/src/lib/api.ts b/src/lib/api.ts index cad1dad..b59b6df 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -96,6 +96,25 @@ export interface Project { created_at: string; } +export interface ProjectSourceFile { + path: string; + content: string; +} + +export interface ProjectSourceResponse { + project_id: string; + entry_path: string; + files: ProjectSourceFile[]; + revision: number; + updated_at?: string | null; +} + +export interface PutProjectSourceInput { + entry_path: string; + files: ProjectSourceFile[]; + revision: number; +} + export interface Build { id: string; project_id: string; @@ -759,12 +778,41 @@ export class VoicethereApi { return Array.isArray(response) ? response : response.projects; } - async createProject(name: string, slug: string): Promise { + async createProject( + name: string, + slug: string, + template?: string, + ): Promise { + const body: { name: string; slug: string; template?: string } = { + name, + slug, + }; + if (template?.trim()) { + body.template = template.trim(); + } return this.request("POST", "/projects", { - json: { name, slug }, + json: body, }); } + async getProjectSource(projectId: string): Promise { + return this.request( + "GET", + `/projects/${projectId}/source`, + ); + } + + async putProjectSource( + projectId: string, + input: PutProjectSourceInput, + ): Promise { + return this.request( + "PUT", + `/projects/${projectId}/source`, + { json: input }, + ); + } + async getProject(projectId: string): Promise { return this.request("GET", `/projects/${projectId}`); } diff --git a/src/lib/customer-package-json.ts b/src/lib/customer-package-json.ts new file mode 100644 index 0000000..56b9b5f --- /dev/null +++ b/src/lib/customer-package-json.ts @@ -0,0 +1,56 @@ +/** Customer workspace pin — may differ from the CLI runtime `@voicethere/agent` dependency. */ +export const CUSTOMER_AGENT_VERSION = "0.7.7"; + +/** 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.ts`, `voice-showcase/agent.ts`). */ + entryPath: string; + /** npm package `name` field — defaults to `voicethere-`. */ + packageName?: string; +} + +export function defaultCustomerPackageName(templateId: string): string { + if (templateId === "blank") { + return "voicethere-agent"; + } + return `voicethere-${templateId}`; +} + +export function buildCustomerPackageJson( + options: BuildCustomerPackageJsonOptions, +): string { + const { templateId, entryPath } = options; + const name = options.packageName ?? defaultCustomerPackageName(templateId); + const dependencies: Record = { + "@voicethere/agent": `^${CUSTOMER_AGENT_VERSION}`, + ...(EXTRA_TEMPLATE_NPM_DEPS[templateId] ?? {}), + }; + + const pkg = { + name, + private: true, + type: "module", + dependencies, + scripts: { + build: `npx @voicethere/agent build --entry ${entryPath} --outfile dist/agent.js`, + verify: `npx @voicethere/agent verify --entry ${entryPath} --outfile dist/agent.js`, + "verify:start": `npx @voicethere/agent verify-start --entry ${entryPath} --outfile dist/agent.js`, + upload: "voicethere build upload", + deploy: "voicethere deploy --wait", + "source:push": "voicethere source push", + "source:pull": "voicethere source pull", + }, + }; + + return `${JSON.stringify(pkg, null, 2)}\n`; +} + +export const CUSTOMER_GITIGNORE = `node_modules/ +dist/ +.test-logs/ +`; diff --git a/src/lib/project-templates.ts b/src/lib/project-templates.ts new file mode 100644 index 0000000..55c9ae5 --- /dev/null +++ b/src/lib/project-templates.ts @@ -0,0 +1,136 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import { + getTemplate, + 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", + "game-sync", + "recording-consent", + "positional-tts", +] as const; + +export type PlatformCreateTemplateId = + (typeof PLATFORM_CREATE_TEMPLATE_IDS)[number]; + +const PLATFORM_CREATE_TEMPLATE_SET = new Set( + PLATFORM_CREATE_TEMPLATE_IDS, +); + +/** Registry entries for templates shipped in newer @voicethere/agent releases. */ +const EXTENDED_TEMPLATE_SOURCE_FILES: Record = { + "voice-showcase": [ + "voice-showcase/agent.ts", + "voice-showcase/conversation.ts", + "voice-showcase/delivery.ts", + "voice-showcase/weather.ts", + "voice-showcase/recipes.ts", + "voice-showcase/fun-facts.ts", + ], + "recording-consent": [ + "recording-consent/agent.ts", + "recording-consent/conversation.ts", + ], + "positional-tts": ["positional-tts/agent.ts", "positional-tts/orbit.ts"], +}; + +const EXTENDED_TEMPLATE_ENTRIES: Record = { + "voice-showcase": "voice-showcase/agent.ts", + "recording-consent": "recording-consent/agent.ts", + "positional-tts": "positional-tts/agent.ts", +}; + +const require = createRequire(import.meta.url); +const templatesModulePath = require.resolve("@voicethere/agent/templates"); +const AGENT_TEMPLATES_DIR = join( + dirname(templatesModulePath), + "..", + "..", + "templates", +); + +export const BLANK_AGENT_ENTRY = "agent.ts"; + +export const BLANK_AGENT_SOURCE = `import { defineAgent } from "@voicethere/agent"; + +defineAgent({ + onSessionStart() { + // Add your agent logic here. + }, +}); +`; + +export function isPlatformCreateTemplateId( + id: string, +): id is PlatformCreateTemplateId { + return PLATFORM_CREATE_TEMPLATE_SET.has(id); +} + +export function assertPlatformCreateTemplateId(id: string): void { + if (!isPlatformCreateTemplateId(id)) { + throw new Error( + `Unknown template "${id}". Choose one of: ${PLATFORM_CREATE_TEMPLATE_IDS.join(", ")}`, + ); + } +} + +export function resolveTemplateEntryPath(templateId: string): string { + if (templateId === "blank") { + return BLANK_AGENT_ENTRY; + } + + try { + return getTemplate(templateId).entry; + } catch { + const extended = EXTENDED_TEMPLATE_ENTRIES[templateId]; + if (extended) { + return extended; + } + throw new Error(`Unknown agent template id: ${templateId}`); + } +} + +function readTemplateFile(sourceFile: string): string { + const absolutePath = join(AGENT_TEMPLATES_DIR, sourceFile); + try { + return readFileSync(absolutePath, "utf8"); + } catch { + throw new Error( + `Template source not found: ${sourceFile}. Update @voicethere/agent or pick another --template.`, + ); + } +} + +/** Load template sources with registry-relative paths (dashboard Code UI parity). */ +export function loadTemplateWorkspaceSources( + templateId: string, +): TemplateSourceFile[] { + if (templateId === "blank") { + return [{ path: BLANK_AGENT_ENTRY, content: BLANK_AGENT_SOURCE }]; + } + + let sourceFiles: string[]; + try { + sourceFiles = getTemplate(templateId).sourceFiles; + } catch { + const extended = EXTENDED_TEMPLATE_SOURCE_FILES[templateId]; + if (!extended) { + throw new Error(`Unknown agent template id: ${templateId}`); + } + sourceFiles = extended; + } + + return sourceFiles.map((sourceFile) => ({ + path: sourceFile, + content: readTemplateFile(sourceFile), + })); +} diff --git a/src/lib/workspace-files.ts b/src/lib/workspace-files.ts new file mode 100644 index 0000000..587e46d --- /dev/null +++ b/src/lib/workspace-files.ts @@ -0,0 +1,130 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, relative } from "node:path"; + +const ALLOWED_SUFFIXES = [".d.ts", ".tsx", ".ts", ".js", ".json"] as const; + +const SKIP_DIR_NAMES = new Set([ + "node_modules", + "dist", + ".git", + ".test-logs", + ".voicethere", +]); + +/** Paths never uploaded via source push (local-only). */ +const SKIP_FILE_PATHS = new Set([".gitignore", ".voicethere/config.json"]); + +export interface WorkspaceSourceFile { + path: string; + content: string; +} + +function hasAllowedSuffix(relativePath: string): boolean { + return ALLOWED_SUFFIXES.some((suffix) => relativePath.endsWith(suffix)); +} + +function shouldSkipFile(relativePath: string): boolean { + if (SKIP_FILE_PATHS.has(relativePath)) { + return true; + } + const segments = relativePath.split("/"); + return segments.some((segment) => SKIP_DIR_NAMES.has(segment)); +} + +async function collectFilesRecursive( + rootDir: string, + currentDir: string, + files: WorkspaceSourceFile[], +): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const absolutePath = join(currentDir, entry.name); + const relativePath = relative(rootDir, absolutePath).replace(/\\/g, "/"); + + if (entry.isDirectory()) { + if (SKIP_DIR_NAMES.has(entry.name)) { + continue; + } + await collectFilesRecursive(rootDir, absolutePath, files); + continue; + } + + if (!entry.isFile()) { + continue; + } + if (shouldSkipFile(relativePath) || !hasAllowedSuffix(relativePath)) { + continue; + } + + const content = await readFile(absolutePath, "utf8"); + files.push({ path: relativePath, content }); + } +} + +/** Collect pushable workspace files under repoRoot (parent of `.voicethere/`). */ +export async function collectWorkspaceSourceFiles( + repoRoot: string, +): Promise { + const rootStat = await stat(repoRoot); + if (!rootStat.isDirectory()) { + throw new Error(`Workspace root is not a directory: ${repoRoot}`); + } + + const files: WorkspaceSourceFile[] = []; + await collectFilesRecursive(repoRoot, repoRoot, files); + files.sort((a, b) => a.path.localeCompare(b.path)); + return files; +} + +/** Parse `--entry` from the customer package.json build script, if present. */ +export function parseEntryPathFromPackageJson( + packageJsonContent: string, +): string | null { + try { + const parsed = JSON.parse(packageJsonContent) as { + scripts?: { build?: string }; + }; + const buildScript = parsed.scripts?.build; + if (!buildScript) { + return null; + } + const match = /--entry\s+(\S+)/.exec(buildScript); + return match?.[1] ?? null; + } catch { + return null; + } +} + +export function resolveWorkspaceEntryPath( + files: WorkspaceSourceFile[], + remoteEntryPath: string | undefined, +): string { + const trimmedRemote = remoteEntryPath?.trim(); + if (trimmedRemote) { + return trimmedRemote; + } + + const packageJson = files.find((file) => file.path === "package.json"); + if (packageJson) { + const fromScript = parseEntryPathFromPackageJson(packageJson.content); + if (fromScript) { + return fromScript; + } + } + + if (files.some((file) => file.path === "agent.ts")) { + return "agent.ts"; + } + + const tsEntry = files.find( + (file) => file.path.endsWith(".ts") && !file.path.includes("/"), + ); + if (tsEntry) { + return tsEntry.path; + } + + throw new Error( + "Could not determine entry_path — ensure package.json build script includes --entry", + ); +}