diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts new file mode 100644 index 0000000..37717cf --- /dev/null +++ b/__tests__/commands/content.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +import { contentCommand } from "../../src/commands/content.js"; +import { get, post } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("content command", () => { + it("lists templates", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + templates: [ + { name: "artist-caption-bedroom", description: "Moody purple bedroom setting" }, + ], + }); + + await contentCommand.parseAsync(["templates"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/content/templates"); + expect(logSpy).toHaveBeenCalledWith( + "- artist-caption-bedroom: Moody purple bedroom setting", + ); + }); + + it("validates an artist", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + ready: true, + missing: [], + }); + + await contentCommand.parseAsync(["validate", "--artist", "550e8400-e29b-41d4-a716-446655440000"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/content/validate", { + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(logSpy).toHaveBeenCalledWith("Ready: yes"); + }); + + it("estimates content cost", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + per_video_estimate_usd: 0.82, + total_estimate_usd: 1.64, + }); + + await contentCommand.parseAsync(["estimate", "--batch", "2"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/content/estimate", { + lipsync: "false", + batch: "2", + compare: "false", + }); + expect(logSpy).toHaveBeenCalledWith("Per video: $0.82"); + }); + + it("creates content run", async () => { + vi.mocked(post).mockResolvedValue({ + runIds: ["run_abc123"], + status: "triggered", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "550e8400-e29b-41d4-a716-446655440000", "--template", "artist-caption-bedroom"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/content/create", { + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + template: "artist-caption-bedroom", + lipsync: false, + caption_length: "short", + upscale: false, + batch: 1, + }); + expect(logSpy).toHaveBeenCalledWith(`Run started: run_abc123`); + }); + + it("shows tasks status hint after create", async () => { + vi.mocked(post).mockResolvedValue({ + runIds: ["run_abc123"], + status: "triggered", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "550e8400-e29b-41d4-a716-446655440000"], + { from: "user" }, + ); + + expect(logSpy).toHaveBeenCalledWith( + "Use `recoup tasks status --run ` to check progress.", + ); + }); + + it("handles non-Error thrown values gracefully", async () => { + vi.mocked(get).mockRejectedValue("plain string error"); + + await contentCommand.parseAsync(["templates"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: plain string error"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints error when API call fails", async () => { + vi.mocked(get).mockRejectedValue(new Error("Request failed")); + + await contentCommand.parseAsync(["templates"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors when --batch is not a positive integer", async () => { + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--batch", "abc"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith("Error: --batch must be a positive integer"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors when --caption-length is invalid", async () => { + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--caption-length", "huge"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: --caption-length must be one of: short, medium, long", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors when API returns no runIds", async () => { + vi.mocked(post).mockResolvedValue({ + status: "error", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "test-artist"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: Response did not include any run IDs", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("creates content run with batch flag and shows batch output", async () => { + vi.mocked(post).mockResolvedValue({ + runIds: ["run_1", "run_2", "run_3"], + status: "triggered", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--caption-length", "long", "--upscale", "--batch", "3"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/content/create", { + artist_account_id: "test-artist", + template: "artist-caption-bedroom", + lipsync: false, + caption_length: "long", + upscale: true, + batch: 3, + }); + expect(logSpy).toHaveBeenCalledWith("Batch started: 3 videos"); + }); +}); + diff --git a/__tests__/commands/tasks.test.ts b/__tests__/commands/tasks.test.ts new file mode 100644 index 0000000..733a26b --- /dev/null +++ b/__tests__/commands/tasks.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +import { tasksCommand } from "../../src/commands/tasks.js"; +import { get } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("tasks command", () => { + it("shows run status", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [ + { + id: "run_abc123", + status: "COMPLETED", + output: {}, + }, + ], + }); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/tasks/runs", { runId: "run_abc123" }); + expect(logSpy).toHaveBeenCalledWith("Run: run_abc123"); + expect(logSpy).toHaveBeenCalledWith("Status: COMPLETED"); + }); + + it("shows video URL when available", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [ + { + id: "run_abc123", + status: "COMPLETED", + output: { + video: { + signedUrl: "https://example.com/video.mp4", + }, + }, + }, + ], + }); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith("Video URL: https://example.com/video.mp4"); + }); + + it("shows run not found", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [], + }); + + await tasksCommand.parseAsync(["status", "--run", "run_missing"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith("Run not found."); + }); + + it("prints error when API call fails", async () => { + vi.mocked(get).mockRejectedValue(new Error("Request failed")); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("handles non-Error thrown values gracefully", async () => { + vi.mocked(get).mockRejectedValue("plain string error"); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: plain string error"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/bin.ts b/src/bin.ts index f687860..5608963 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -8,6 +8,8 @@ import { sandboxesCommand } from "./commands/sandboxes.js"; import { songsCommand } from "./commands/songs.js"; import { notificationsCommand } from "./commands/notifications.js"; import { orgsCommand } from "./commands/orgs.js"; +import { contentCommand } from "./commands/content.js"; +import { tasksCommand } from "./commands/tasks.js"; const pkgPath = join(__dirname, "..", "package.json"); const { version } = JSON.parse(readFileSync(pkgPath, "utf-8")); @@ -26,5 +28,7 @@ program.addCommand(songsCommand); program.addCommand(notificationsCommand); program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); +program.addCommand(tasksCommand); +program.addCommand(contentCommand); program.parse(); diff --git a/src/commands/content.ts b/src/commands/content.ts new file mode 100644 index 0000000..6180412 --- /dev/null +++ b/src/commands/content.ts @@ -0,0 +1,13 @@ +import { Command } from "commander"; +import { templatesCommand } from "./content/templatesCommand.js"; +import { validateCommand } from "./content/validateCommand.js"; +import { estimateCommand } from "./content/estimateCommand.js"; +import { createCommand } from "./content/createCommand.js"; + +export const contentCommand = new Command("content") + .description("Content-creation pipeline commands"); + +contentCommand.addCommand(templatesCommand); +contentCommand.addCommand(validateCommand); +contentCommand.addCommand(estimateCommand); +contentCommand.addCommand(createCommand); diff --git a/src/commands/content/createCommand.ts b/src/commands/content/createCommand.ts new file mode 100644 index 0000000..9738a24 --- /dev/null +++ b/src/commands/content/createCommand.ts @@ -0,0 +1,58 @@ +import { Command } from "commander"; +import { post } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; +import { parsePositiveInt } from "./parsePositiveInt.js"; + +const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]); + +export const createCommand = new Command("create") + .description("Trigger content creation pipeline") + .requiredOption("--artist ", "Artist account ID") + .option("--template ", "Template name", "artist-caption-bedroom") + .option("--lipsync", "Enable lipsync mode") + .option("--caption-length ", "Caption length: short, medium, long", "short") + .option("--upscale", "Upscale image and video for higher quality") + .option("--batch ", "Generate multiple videos in parallel", "1") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); + if (!ALLOWED_CAPTION_LENGTHS.has(opts.captionLength)) { + throw new Error("--caption-length must be one of: short, medium, long"); + } + + const data = await post("/api/content/create", { + artist_account_id: opts.artist, + template: opts.template, + lipsync: !!opts.lipsync, + caption_length: opts.captionLength, + upscale: !!opts.upscale, + batch, + }); + + if (opts.json) { + printJson(data); + return; + } + + const runIds = Array.isArray(data.runIds) + ? data.runIds.filter((id): id is string => typeof id === "string") + : []; + if (runIds.length === 0) { + throw new Error("Response did not include any run IDs"); + } + + if (runIds.length === 1) { + console.log(`Run started: ${runIds[0]}`); + } else { + console.log(`Batch started: ${runIds.length} videos`); + for (const id of runIds) { + console.log(` - ${id}`); + } + } + console.log("Use `recoup tasks status --run ` to check progress."); + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/estimateCommand.ts b/src/commands/content/estimateCommand.ts new file mode 100644 index 0000000..7126e12 --- /dev/null +++ b/src/commands/content/estimateCommand.ts @@ -0,0 +1,32 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; +import { parsePositiveInt } from "./parsePositiveInt.js"; + +export const estimateCommand = new Command("estimate") + .description("Estimate content creation cost") + .option("--lipsync", "Estimate for lipsync mode") + .option("--batch ", "Number of videos", "1") + .option("--compare", "Include comparison profiles") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); + const data = await get("/api/content/estimate", { + lipsync: opts.lipsync ? "true" : "false", + batch: String(batch), + compare: opts.compare ? "true" : "false", + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Per video: $${data.per_video_estimate_usd}`); + console.log(`Total: $${data.total_estimate_usd}`); + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/parsePositiveInt.ts b/src/commands/content/parsePositiveInt.ts new file mode 100644 index 0000000..43dca7e --- /dev/null +++ b/src/commands/content/parsePositiveInt.ts @@ -0,0 +1,7 @@ +export function parsePositiveInt(value: string, flag: string): number { + const parsed = parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${flag} must be a positive integer`); + } + return parsed; +} diff --git a/src/commands/content/templatesCommand.ts b/src/commands/content/templatesCommand.ts new file mode 100644 index 0000000..94e2814 --- /dev/null +++ b/src/commands/content/templatesCommand.ts @@ -0,0 +1,29 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; + +export const templatesCommand = new Command("templates") + .description("List available content templates") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/templates"); + if (opts.json) { + printJson(data); + return; + } + + const templates = Array.isArray(data.templates) ? data.templates : []; + if (templates.length === 0) { + console.log("No templates available."); + return; + } + + for (const template of templates as Array>) { + console.log(`- ${template.name}: ${template.description}`); + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/validateCommand.ts b/src/commands/content/validateCommand.ts new file mode 100644 index 0000000..886b876 --- /dev/null +++ b/src/commands/content/validateCommand.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; + +export const validateCommand = new Command("validate") + .description("Validate whether an artist is ready for content creation") + .requiredOption("--artist ", "Artist account ID") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/validate", { + artist_account_id: opts.artist, + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Ready: ${data.ready ? "yes" : "no"}`); + if (Array.isArray(data.missing) && data.missing.length > 0) { + console.log("Missing:"); + for (const item of data.missing as Array>) { + console.log(`- ${item.file}`); + } + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts new file mode 100644 index 0000000..7379930 --- /dev/null +++ b/src/commands/tasks.ts @@ -0,0 +1,7 @@ +import { Command } from "commander"; +import { statusCommand } from "./tasks/statusCommand.js"; + +export const tasksCommand = new Command("tasks") + .description("Check the status of background task runs"); + +tasksCommand.addCommand(statusCommand); diff --git a/src/commands/tasks/statusCommand.ts b/src/commands/tasks/statusCommand.ts new file mode 100644 index 0000000..58aac17 --- /dev/null +++ b/src/commands/tasks/statusCommand.ts @@ -0,0 +1,36 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; + +export const statusCommand = new Command("status") + .description("Check the status of a task run") + .requiredOption("--run ", "Trigger.dev run ID") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/tasks/runs", { runId: opts.run }); + if (opts.json) { + printJson(data); + return; + } + + const runs = Array.isArray(data.runs) ? data.runs : []; + const run = runs[0] as Record | undefined; + if (!run) { + console.log("Run not found."); + return; + } + + console.log(`Run: ${run.id}`); + console.log(`Status: ${run.status}`); + + const output = run.output as Record | undefined; + const video = (output?.video || null) as Record | null; + if (video?.signedUrl) { + console.log(`Video URL: ${video.signedUrl}`); + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/getErrorMessage.ts b/src/getErrorMessage.ts new file mode 100644 index 0000000..6c83786 --- /dev/null +++ b/src/getErrorMessage.ts @@ -0,0 +1,3 @@ +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +}