diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fef7bc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +.env +.env.local diff --git a/RECOVERY.md b/RECOVERY.md new file mode 100644 index 0000000..2e6354f --- /dev/null +++ b/RECOVERY.md @@ -0,0 +1,98 @@ +# Source recovery — where this code came from, and how you can check + +This repository advertised itself as the source of `@wave-av/cli` while containing no source at +all. Eight versions were published to npm between 2026-04-03 and 2026-08-04 from a working copy +that was never committed. Anyone who ran `npm install @wave-av/cli` and followed the `repository` +link the package itself carries arrived at a README and a LICENSE, and **the code executing on +their machine existed in no public repository.** + +This directory closes that gap. It is worth being precise about what kind of claim that is. + +## The source was recovered, not reconstructed + +Every published version of `@wave-av/cli` ships `dist/index.js.map`, and that sourcemap carries +`sourcesContent` — not merely the *names* of the original files but their **complete contents**, +pre-compilation. All 70 TypeScript files under `src/` were extracted from it verbatim. Nothing here +was inferred from the compiled bundle, hand-written to match, or reasoned backwards from types. + +## The receipt: it rebuilds byte-for-byte + +The claim "this is the source" is checkable, so it was checked, on **every** published version +rather than a sample: + +| version | rebuilt `dist/index.js` vs published | +|---|---| +| 1.0.0 | **byte-identical** | +| 1.0.2 | **byte-identical** | +| 1.0.3 | **byte-identical** | +| 1.0.4 | **byte-identical** | +| 1.0.5 | **byte-identical** | +| 1.0.6 | **byte-identical** | +| 1.0.7 | **byte-identical** | +| 1.0.8 | **byte-identical** | + +Re-derive any row yourself: + +```sh +# 1. fetch what npm actually shipped +# NOTE: --registry does NOT override a scope mapping. If your npm config points @wave-av at a +# private registry, the plain form 404s against the wrong host and reads as "not published". +npm pack @wave-av/cli@1.0.8 --@wave-av:registry=https://registry.npmjs.org +tar -xzf wave-av-cli-1.0.8.tgz + +# 2. extract the original sources out of the published sourcemap +node -e ' + const m = require("./package/dist/index.js.map"), fs = require("fs"), p = require("path"); + m.sources.forEach((s, i) => { + const f = p.join("recovered", s.replace(/^\.\.\//, "")); + fs.mkdirSync(p.dirname(f), { recursive: true }); + fs.writeFileSync(f, m.sourcesContent[i]); + }); +' + +# 3. build and compare +npm ci --include=dev && npx tsup +cmp dist/index.js package/dist/index.js && echo IDENTICAL +``` + +`cmp` exits 0 silently on a match. + +## What was authored for this recovery, and is therefore NOT recovered + +Two files. The sourcemap contains source, not build configuration, so these were written to make +the tree buildable and are stated here rather than left to look like they came out of the artifact: + +- **`tsconfig.json`** — a conventional strict ES2022/ESNext configuration. +- **`tsup.config.ts`** — entry `src/index.ts`, ESM, node18, sourcemap on, shebang banner. + +They are not guesses in any loose sense: they are the settings under which the output matches the +published bytes exactly, on all eight versions. A different plausible configuration would have +produced a different bundle and the comparison above would have failed. But they were **written**, +not **recovered**, and conflating the two would be the same class of error this whole exercise +exists to correct. + +`package.json` was taken from the published manifest, which npm preserves in full — including +`scripts` and `devDependencies`. + +## Safety + +The recovered tree was scanned with `gitleaks` before being proposed here: **no leaks found**, +~200KB across 70 files. This mattered more than it looks. The sourcemap has been publicly +downloadable since 2026-04-03, so a hardcoded credential inside it would have been a live +four-month exposure — a fact about the *published package*, not a risk created by recovering it. + +## What this does and does not settle + +It settles the question *"what source produced the code now running on their machine?"* for every +version of `@wave-av/cli`, with a receipt anyone outside WAVE can reproduce. + +It does **not** settle it for `@wave-av/workflow-sdk`, whose seven published versions carry no +sourcemap at all. That package's source is recoverable only as a *reconstruction* — a tree that can +be proven to produce the published bytes, which is a genuinely weaker claim than a tree that did. +The two must not be recorded as the same thing. + +## Provenance of the versions themselves + +Recovering the source does not retroactively create the tags that never existed. Tagging each +published version against its own `cmp` receipt is tracked separately; until those tags exist, this +file is the record of where the code came from. diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ed97cf --- /dev/null +++ b/package.json @@ -0,0 +1,80 @@ +{ + "name": "@wave-av/cli", + "version": "1.0.8", + "description": "WAVE CLI \u2014 manage live streams, productions, and video infrastructure from your terminal. 34 command groups.", + "main": "./dist/index.js", + "type": "module", + "bin": { + "wave": "./dist/index.js" + }, + "files": [ + "dist", + "templates", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "wave", + "cli", + "streaming", + "video", + "broadcast", + "production", + "live", + "webrtc", + "srt", + "rtmp", + "terminal" + ], + "author": "WAVE Inc. ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/wave-av/cli.git", + "directory": "." + }, + "homepage": "https://docs.wave.online/cli", + "bugs": { + "url": "https://github.com/wave-av/cli/issues" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": false, + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@wave-av/sdk": "^2.0.11", + "chalk": "^5.4.1", + "cli-table3": "^0.6.5", + "commander": "^13.1.0", + "conf": "^13.1.0", + "inquirer": "^12.3.2", + "keytar": "^7.9.0", + "open": "^10.1.0", + "ora": "^8.2.0", + "ws": "^8.18.0", + "yaml": "^2.7.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@sentry/node": "^9.4.0", + "@types/inquirer": "^9.0.7", + "@types/node": "^22.13.0", + "@types/ws": "^8.5.14", + "tsup": "^8.0.0", + "typescript": "^5.9.3", + "vitest": "^4.0.16" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..1229ef2 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,174 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { registerAuthCommands } from "./commands/auth/index.js"; +import { registerOrgCommands } from "./commands/org/index.js"; +import { registerConfigCommands } from "./commands/config/index.js"; +import { registerStreamCommands } from "./commands/stream/index.js"; +import { registerStudioCommands } from "./commands/studio/index.js"; +import { registerClipCommands } from "./commands/clips/index.js"; +import { registerEditorCommands } from "./commands/editor/index.js"; +import { registerVoiceCommands } from "./commands/voice/index.js"; +import { registerPhoneCommands } from "./commands/phone/index.js"; +import { registerCollabCommands } from "./commands/collab/index.js"; +import { registerCaptionsCommands } from "./commands/captions/index.js"; +import { registerChaptersCommands } from "./commands/chapters/index.js"; +import { registerAICommands } from "./commands/ai/index.js"; +import { registerTranscribeCommands } from "./commands/transcribe/index.js"; +import { registerSentimentCommands } from "./commands/sentiment/index.js"; +import { registerSearchCommands } from "./commands/search/index.js"; +import { registerSceneCommands } from "./commands/scene/index.js"; +import { registerFleetCommands } from "./commands/fleet/index.js"; +import { registerGhostCommands } from "./commands/ghost/index.js"; +import { registerMeshCommands } from "./commands/mesh/index.js"; +import { registerEdgeCommands } from "./commands/edge/index.js"; +import { registerAnalyticsCommands } from "./commands/analytics/index.js"; +import { registerPrismCommands } from "./commands/prism/index.js"; +import { registerZoomCommands } from "./commands/zoom/index.js"; +import { registerVaultCommands } from "./commands/vault/index.js"; +import { registerMarketplaceCommands } from "./commands/marketplace/index.js"; +import { registerConnectCommands } from "./commands/connect/index.js"; +import { registerDistributionCommands } from "./commands/distribution/index.js"; +import { registerDesktopCommands } from "./commands/desktop/index.js"; +import { registerSignageCommands } from "./commands/signage/index.js"; +import { registerQrCommands } from "./commands/qr/index.js"; +import { registerAudienceCommands } from "./commands/audience/index.js"; +import { registerCreatorCommands } from "./commands/creator/index.js"; +import { registerPodcastCommands } from "./commands/podcast/index.js"; +import { registerSlidesCommands } from "./commands/slides/index.js"; +import { registerUsbCommands } from "./commands/usb/index.js"; +import { registerNotifyCommands } from "./commands/notify/index.js"; +import { registerDrmCommands } from "./commands/drm/index.js"; +import { registerBillingCommands } from "./commands/billing/index.js"; +import { registerListenCommands } from "./commands/listen/index.js"; +import { registerLogsCommands } from "./commands/logs/index.js"; +import { registerTriggerCommands } from "./commands/trigger/index.js"; +import { registerDevCommands } from "./commands/dev/index.js"; +import { registerOpenCommands } from "./commands/open/index.js"; +import { registerInitCommands } from "./commands/init/index.js"; +import { registerAdminCommands } from "./commands/admin/index.js"; +import { registerDoctorCommands } from "./commands/doctor/index.js"; +import { registerStatusCommands } from "./commands/status/index.js"; +import { registerCompletionCommands } from "./commands/completion/index.js"; +import { registerApiCommands } from "./commands/api/index.js"; +import { registerLinkCommands } from "./commands/link/index.js"; +import { detectEnvironment } from "./lib/environment.js"; + +function printBanner(): void { + // WAVE brand gradient: blue (#3366FF) -> purple (#7B41E8) -> cyan (#33BBCC) + const b = chalk.hex("#3366FF"); // primary blue + const p = chalk.hex("#7B41E8"); // secondary purple + const c = chalk.hex("#33BBCC"); // accent cyan + const d = chalk.dim; + + console.log(""); + console.log(` ${b("██╗ ██╗")} ${p("█████╗ ")} ${p("██╗ ██╗")} ${c("███████╗")}`); + console.log(` ${b("██║ ██║")} ${p("██╔══██╗")} ${p("██║ ██║")} ${c("██╔════╝")}`); + console.log(` ${b("██║ █╗ ██║")} ${p("███████║")} ${p("██║ ██║")} ${c("█████╗ ")}`); + console.log(` ${b("██║███╗██║")} ${p("██╔══██║")} ${p("╚██╗ ██╔╝")} ${c("██╔══╝ ")}`); + console.log(` ${b("╚███╔███╔╝")} ${p("██║ ██║")} ${p(" ╚████╔╝ ")} ${c("███████╗")}`); + console.log(` ${b(" ╚══╝╚══╝ ")} ${p("╚═╝ ╚═╝")} ${p(" ╚═══╝ ")} ${c("╚══════╝")}`); + console.log(""); + console.log(` ${d("Enterprise Streaming Platform")} ${chalk.hex("#555")("v1.0.0")}`); + console.log(` ${d("─".repeat(45))}`); + console.log(""); +} + +export function createProgram(): Command { + const program = new Command(); + + program + .name("wave") + .description("WAVE CLI - Command-line interface for the WAVE streaming platform") + .version("1.0.0", "-v, --version") + .option("-o, --output ", "Output format: table, json, yaml", "table") + .option("--project ", "Override project context") + .option("--org ", "Override organization") + .option("-c, --confirm", "Skip confirmation prompts") + .option("--no-color", "Disable colored output") + .option("--debug", "Verbose debug logging"); + + // Auth & Config + registerAuthCommands(program); + registerOrgCommands(program); + registerConfigCommands(program); + registerInitCommands(program); + registerLinkCommands(program); + + // Core APIs (P1) + registerStreamCommands(program); + registerStudioCommands(program); + + // Production (P1) + registerClipCommands(program); + registerEditorCommands(program); + registerVoiceCommands(program); + registerPhoneCommands(program); + registerCollabCommands(program); + registerCaptionsCommands(program); + registerChaptersCommands(program); + registerAICommands(program); + registerTranscribeCommands(program); + + // Intelligence (P2) + registerSentimentCommands(program); + registerSearchCommands(program); + registerSceneCommands(program); + + // Enterprise (P2) + registerFleetCommands(program); + registerGhostCommands(program); + registerMeshCommands(program); + registerEdgeCommands(program); + registerAnalyticsCommands(program); + registerPrismCommands(program); + registerZoomCommands(program); + + // Content & Commerce (P3) + registerVaultCommands(program); + registerMarketplaceCommands(program); + registerConnectCommands(program); + registerDistributionCommands(program); + registerDesktopCommands(program); + registerSignageCommands(program); + registerQrCommands(program); + registerAudienceCommands(program); + registerCreatorCommands(program); + + // Specialized (P4) + registerPodcastCommands(program); + registerSlidesCommands(program); + registerUsbCommands(program); + + // Cross-cutting + registerNotifyCommands(program); + registerDrmCommands(program); + registerBillingCommands(program); + + // Developer tools + registerListenCommands(program); + registerLogsCommands(program); + registerTriggerCommands(program); + registerDevCommands(program); + registerOpenCommands(program); + + // Admin + registerAdminCommands(program); + + // Diagnostics & utilities + registerDoctorCommands(program); + registerStatusCommands(program); + registerCompletionCommands(program); + registerApiCommands(program); + + // Skip banner for AI agents and CI (they prefer clean output) + const env = detectEnvironment(); + if (!env.isAgent && !env.isCI) { + const originalHelp = program.helpInformation.bind(program); + program.helpInformation = function () { + printBanner(); + return originalHelp(); + }; + } + + return program; +} diff --git a/src/commands/admin/index.ts b/src/commands/admin/index.ts new file mode 100644 index 0000000..61979f7 --- /dev/null +++ b/src/commands/admin/index.ts @@ -0,0 +1,75 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { formatOutput } from "../../lib/output/index.js"; +import { wrapCommand } from "../../lib/errors.js"; +import { loadConfig } from "../../lib/config/manager.js"; +import { getApiKey } from "../../lib/auth/keychain.js"; + +async function adminFetch( + path: string, + opts?: { method?: string; body?: unknown }, +): Promise { + const config = await loadConfig(); + const project = config.projects[config.currentProject]; + const baseUrl = project?.baseUrl ?? process.env["WAVE_BASE_URL"] ?? "https://wave.online"; + const apiKey = await getApiKey(config.currentProject); + + if (!apiKey) { + throw new Error(`No API key found. Run ${chalk.bold("wave login")} to authenticate.`); + } + + const res = await fetch(`${baseUrl}${path}`, { + method: opts?.method ?? "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "X-Wave-Source": "cli", + }, + body: opts?.body ? JSON.stringify(opts.body) : undefined, + }); + + if (!res.ok) { + const error = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(error.message ?? `Admin API error: ${res.status} ${res.statusText}`); + } + + return res.json(); +} + +export function registerAdminCommands(program: Command): void { + const admin = program + .command("admin") + .description("Administrative commands (requires admin role)"); + + const jobs = admin.command("jobs").description("Manage background jobs"); + + jobs + .command("list") + .description("List background job functions") + .option("--status ", "Filter by status (active, paused, failed)") + .action( + wrapCommand(async (opts) => { + const params = new URLSearchParams(); + if (opts.status) params.set("status", opts.status); + const query = params.toString(); + const result = await adminFetch(`/api/admin/jobs${query ? `?${query}` : ""}`); + formatOutput(result, program.opts()); + }), + ); + + jobs + .command("trigger ") + .description("Manually trigger a background job function") + .option("--data ", "JSON data payload for the job") + .action( + wrapCommand(async (functionId: string, opts) => { + const data = opts.data ? JSON.parse(opts.data as string) : undefined; + const result = await adminFetch(`/api/admin/jobs/${functionId}/trigger`, { + method: "POST", + body: data ? { data } : undefined, + }); + console.log(chalk.green(`Job "${functionId}" triggered.`)); + formatOutput(result, program.opts()); + }), + ); +} diff --git a/src/commands/ai/index.ts b/src/commands/ai/index.ts new file mode 100644 index 0000000..d704cb9 --- /dev/null +++ b/src/commands/ai/index.ts @@ -0,0 +1,54 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { getClient } from "../../lib/api-client.js"; +import { formatOutput } from "../../lib/output/index.js"; +import { wrapCommand } from "../../lib/errors.js"; + +export function registerAICommands(program: Command): void { + const ai = program.command("ai").description("AI-powered studio assistant"); + + const assistant = ai.command("assistant").description("Studio AI assistant"); + + assistant + .command("start") + .description("Start the AI assistant for a production") + .requiredOption("--production-id ", "Production ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.studioAI.start({ + productionId: opts.productionId, + }); + console.log(chalk.green("AI assistant started.")); + formatOutput(result, program.opts()); + }), + ); + + assistant + .command("stop") + .description("Stop the AI assistant for a production") + .requiredOption("--production-id ", "Production ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.studioAI.stop({ + productionId: opts.productionId, + }); + console.log(chalk.green("AI assistant stopped.")); + formatOutput(result, program.opts()); + }), + ); + + ai.command("suggestions") + .description("Get AI suggestions for the current production") + .requiredOption("--production-id ", "Production ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.studioAI.suggestions({ + productionId: opts.productionId, + }); + formatOutput(result, program.opts()); + }), + ); +} diff --git a/src/commands/analytics/index.ts b/src/commands/analytics/index.ts new file mode 100644 index 0000000..60bbbde --- /dev/null +++ b/src/commands/analytics/index.ts @@ -0,0 +1,54 @@ +import { Command } from "commander"; +import { getClient } from "../../lib/api-client.js"; +import { formatOutput } from "../../lib/output/index.js"; +import { wrapCommand } from "../../lib/errors.js"; + +export function registerAnalyticsCommands(program: Command): void { + const analytics = program.command("analytics").description("Streaming analytics and insights"); + + analytics + .command("viewers") + .description("View audience analytics") + .option("--stream-id ", "Filter by stream ID") + .option("--period ", "Time period (hour, day, week, month)", "day") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.pulse.viewers({ + streamId: opts.streamId, + period: opts.period, + }); + formatOutput(result, program.opts()); + }), + ); + + analytics + .command("revenue") + .description("View revenue analytics") + .option("--period ", "Time period (day, week, month)", "month") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.pulse.revenue({ period: opts.period }); + formatOutput(result, program.opts()); + }), + ); + + analytics + .command("export") + .description("Export analytics data") + .option("--format ", "Export format (csv, json)", "csv") + .option("--period ", "Time period (day, week, month)", "month") + .option("--stream-id ", "Filter by stream ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.pulse.export({ + format: opts.format, + period: opts.period, + streamId: opts.streamId, + }); + formatOutput(result, program.opts()); + }), + ); +} diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts new file mode 100644 index 0000000..6278306 --- /dev/null +++ b/src/commands/api/index.ts @@ -0,0 +1,82 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { wrapCommand } from "../../lib/errors.js"; +import { formatOutput } from "../../lib/output/index.js"; +import { getApiKey } from "../../lib/auth/keychain.js"; +import { loadConfig } from "../../lib/config/manager.js"; + +export function registerApiCommands(program: Command): void { + program + .command("api ") + .description("Make raw API requests (like gh api)") + .option("-d, --data ", "Request body (JSON)") + .option("-H, --header
", "Additional header (key:value)", collectHeaders, []) + .option("--paginate", "Auto-paginate and collect all results") + .action( + wrapCommand(async (method: string, path: string, opts) => { + const config = await loadConfig(); + const project = config.currentProject || "default"; + const apiKey = await getApiKey(project); + + if (!apiKey) { + console.error(chalk.red("Not authenticated. Run `wave auth login` first.")); + process.exit(1); + } + + const baseUrl = config.projects[project]?.baseUrl ?? "https://wave.online"; + const url = path.startsWith("http") + ? path + : `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "User-Agent": "wave-cli/1.0.0", + }; + + // Add custom headers + for (const h of opts.header as string[]) { + const [key, ...valueParts] = h.split(":"); + if (key && valueParts.length > 0) { + headers[key.trim()] = valueParts.join(":").trim(); + } + } + + const fetchOpts: RequestInit = { + method: method.toUpperCase(), + headers, + }; + + if (opts.data && ["POST", "PUT", "PATCH"].includes(method.toUpperCase())) { + fetchOpts.body = opts.data as string; + } + + const res = await fetch(url, fetchOpts); + const contentType = res.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + const data = await res.json(); + + if (!res.ok) { + console.error(chalk.red(`${res.status} ${res.statusText}`)); + console.error(JSON.stringify(data, null, 2)); + process.exit(1); + } + + formatOutput(data, program.opts()); + } else { + const text = await res.text(); + if (!res.ok) { + console.error(chalk.red(`${res.status} ${res.statusText}`)); + console.error(text); + process.exit(1); + } + console.log(text); + } + }), + ); +} + +function collectHeaders(value: string, previous: string[]): string[] { + return previous.concat([value]); +} diff --git a/src/commands/audience/index.ts b/src/commands/audience/index.ts new file mode 100644 index 0000000..305a1df --- /dev/null +++ b/src/commands/audience/index.ts @@ -0,0 +1,136 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { getClient } from "../../lib/api-client.js"; +import { formatOutput } from "../../lib/output/index.js"; +import { wrapCommand } from "../../lib/errors.js"; + +export function registerAudienceCommands(program: Command): void { + const audience = program.command("audience").description("Audience engagement tools"); + + // Polls + const polls = audience.command("polls").description("Manage audience polls"); + + polls + .command("create") + .description("Create an audience poll") + .requiredOption("--question ", "Poll question") + .requiredOption("--options ", "Comma-separated poll options") + .option("--stream-id ", "Attach to a stream") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const options = (opts.options as string).split(",").map((o: string) => o.trim()); + const result = await client.audience.polls.create({ + question: opts.question, + options, + streamId: opts.streamId, + }); + console.log(chalk.green(`Poll created: ${result.id}`)); + formatOutput(result, program.opts()); + }), + ); + + polls + .command("list") + .description("List polls") + .option("--stream-id ", "Filter by stream ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.audience.polls.list({ + streamId: opts.streamId, + }); + formatOutput(result.data, program.opts()); + }), + ); + + polls + .command("results ") + .description("Get poll results") + .action( + wrapCommand(async (id: string) => { + const client = await getClient(program.opts()); + const result = await client.audience.polls.results(id); + formatOutput(result, program.opts()); + }), + ); + + polls + .command("close ") + .description("Close an active poll") + .action( + wrapCommand(async (id: string) => { + const client = await getClient(program.opts()); + const result = await client.audience.polls.close(id); + console.log(chalk.green(`Poll ${id} closed.`)); + formatOutput(result, program.opts()); + }), + ); + + // Questions + const questions = audience.command("questions").description("Manage audience Q&A"); + + questions + .command("create") + .description("Open a Q&A session") + .option("--stream-id ", "Attach to a stream") + .option("--moderated", "Enable moderation", false) + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.audience.questions.create({ + streamId: opts.streamId, + moderated: opts.moderated, + }); + console.log(chalk.green(`Q&A session created: ${result.id}`)); + formatOutput(result, program.opts()); + }), + ); + + questions + .command("list") + .description("List questions in a Q&A session") + .requiredOption("--session-id ", "Q&A session ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.audience.questions.list({ + sessionId: opts.sessionId, + }); + formatOutput(result.data, program.opts()); + }), + ); + + // Reactions + const reactions = audience.command("reactions").description("Manage audience reactions"); + + reactions + .command("enable") + .description("Enable reactions for a stream") + .requiredOption("--stream-id ", "Stream ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.audience.reactions.enable({ + streamId: opts.streamId, + }); + console.log(chalk.green("Reactions enabled.")); + formatOutput(result, program.opts()); + }), + ); + + reactions + .command("disable") + .description("Disable reactions for a stream") + .requiredOption("--stream-id ", "Stream ID") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.audience.reactions.disable({ + streamId: opts.streamId, + }); + console.log(chalk.green("Reactions disabled.")); + formatOutput(result, program.opts()); + }), + ); +} diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts new file mode 100644 index 0000000..9ecfb90 --- /dev/null +++ b/src/commands/auth/index.ts @@ -0,0 +1,139 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { wrapCommand } from "../../lib/errors.js"; +import { formatOutput } from "../../lib/output/index.js"; +import { storeApiKey, deleteApiKey, deleteAllKeys, getApiKey } from "../../lib/auth/keychain.js"; +import { loadConfig, updateConfig } from "../../lib/config/manager.js"; +import { startDeviceAuth, pollForToken } from "../../lib/auth/device-flow.js"; + +export function registerAuthCommands(program: Command): void { + const auth = program.command("auth").description("Manage authentication"); + + auth + .command("login") + .description("Authenticate with the WAVE platform") + .option("--api-key ", "API key for non-interactive authentication") + .action( + wrapCommand(async (opts) => { + if (opts.apiKey) { + const config = await loadConfig(); + const project = config.currentProject || "default"; + await storeApiKey(project, opts.apiKey); + console.log(chalk.green(`API key stored for project "${project}".`)); + return; + } + + // RFC 8628 Device Authorization Flow via the tested device-flow library + const baseUrl = process.env["WAVE_BASE_URL"] ?? "https://wave.online"; + + const deviceAuth = await startDeviceAuth(baseUrl); + const token = await pollForToken( + baseUrl, + deviceAuth.device_code, + deviceAuth.interval, + deviceAuth.expires_in, + ); + + // Store the API key and update config + const project = "default"; + await storeApiKey(project, token.access_token); + await updateConfig((config) => ({ + ...config, + currentProject: project, + })); + + console.log(chalk.green("\nAuthentication complete. You can now use the WAVE CLI.")); + }), + ); + + auth + .command("logout") + .description("Remove stored credentials") + .option("--all", "Remove credentials for all projects") + .action( + wrapCommand(async (opts) => { + if (opts.all) { + await deleteAllKeys(); + console.log(chalk.green("All credentials removed.")); + } else { + const config = await loadConfig(); + await deleteApiKey(config.currentProject); + console.log(chalk.green(`Credentials removed for project "${config.currentProject}".`)); + } + }), + ); + + auth + .command("status") + .description("Show current authentication status") + .action( + wrapCommand(async () => { + const config = await loadConfig(); + const apiKey = await getApiKey(config.currentProject); + const status = { + project: config.currentProject, + authenticated: !!apiKey, + organization: config.projects[config.currentProject]?.organizationName ?? "N/A", + organizationId: config.projects[config.currentProject]?.organizationId ?? "N/A", + }; + formatOutput(status, program.opts()); + }), + ); + + // Top-level whoami alias + program + .command("whoami") + .description("Show the current authenticated user") + .action( + wrapCommand(async () => { + const config = await loadConfig(); + const project = config.currentProject || "default"; + const apiKey = await getApiKey(project); + + if (!apiKey) { + console.error(chalk.red("Not authenticated. Run `wave auth login` first.")); + process.exit(1); + } + + const baseUrl = config.projects[project]?.baseUrl ?? "https://wave.online"; + const res = await fetch(`${baseUrl}/api/v1/me`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!res.ok) { + console.error( + chalk.red( + "Authentication invalid or expired. Run `wave auth login` to re-authenticate.", + ), + ); + process.exit(1); + } + + const user = (await res.json()) as { + id?: string; + email?: string; + name?: string; + organization?: string; + }; + + console.log(chalk.bold("\n Authenticated as:")); + if (user.name) console.log(` Name: ${chalk.cyan(user.name)}`); + if (user.email) console.log(` Email: ${chalk.cyan(user.email)}`); + console.log( + ` Org: ${chalk.cyan(user.organization ?? config.projects[project]?.organizationName ?? "N/A")}`, + ); + console.log(` Project: ${chalk.cyan(project)}`); + console.log(""); + + formatOutput( + { + project, + name: user.name ?? "N/A", + email: user.email ?? "N/A", + organization: user.organization ?? config.projects[project]?.organizationName ?? "N/A", + }, + program.opts(), + ); + }), + ); +} diff --git a/src/commands/billing/index.ts b/src/commands/billing/index.ts new file mode 100644 index 0000000..b53b1e5 --- /dev/null +++ b/src/commands/billing/index.ts @@ -0,0 +1,110 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { formatOutput } from "../../lib/output/index.js"; +import { wrapCommand } from "../../lib/errors.js"; +import { loadConfig } from "../../lib/config/manager.js"; +import { getApiKey } from "../../lib/auth/keychain.js"; + +async function billingFetch( + path: string, + opts?: { method?: string; body?: unknown }, +): Promise { + const config = await loadConfig(); + const project = config.projects[config.currentProject]; + const baseUrl = project?.baseUrl ?? process.env["WAVE_BASE_URL"] ?? "https://wave.online"; + const apiKey = await getApiKey(config.currentProject); + + if (!apiKey) { + throw new Error(`No API key found. Run ${chalk.bold("wave login")} to authenticate.`); + } + + const res = await fetch(`${baseUrl}${path}`, { + method: opts?.method ?? "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "X-Wave-Source": "cli", + }, + body: opts?.body ? JSON.stringify(opts.body) : undefined, + }); + + if (!res.ok) { + const error = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(error.message ?? `Billing API error: ${res.status} ${res.statusText}`); + } + + return res.json(); +} + +export function registerBillingCommands(program: Command): void { + const billing = program + .command("billing") + .description("Billing, usage, and subscription management"); + + billing + .command("status") + .description("Show current billing status and plan") + .action( + wrapCommand(async () => { + const result = await billingFetch("/api/billing/status"); + formatOutput(result, program.opts()); + }), + ); + + billing + .command("usage") + .description("Show current usage metrics") + .option("--period ", "Billing period (current, previous)", "current") + .action( + wrapCommand(async (opts) => { + const result = await billingFetch(`/api/billing/usage?period=${opts.period}`); + formatOutput(result, program.opts()); + }), + ); + + billing + .command("invoices") + .description("List billing invoices") + .option("--limit ", "Maximum results", "10") + .action( + wrapCommand(async (opts) => { + const result = await billingFetch(`/api/billing/invoices?limit=${opts.limit}`); + formatOutput(result, program.opts()); + }), + ); + + billing + .command("limits") + .description("Show current usage limits") + .action( + wrapCommand(async () => { + const result = await billingFetch("/api/billing/limits"); + formatOutput(result, program.opts()); + }), + ); + + billing + .command("portal") + .description("Open the billing portal in your browser") + .action( + wrapCommand(async () => { + const result = (await billingFetch("/api/billing/portal", { + method: "POST", + })) as { url: string }; + const open = (await import("open")).default; + await open(result.url); + console.log(chalk.green("Billing portal opened in your browser.")); + }), + ); + + billing + .command("upgrade") + .description("View available upgrade options") + .action( + wrapCommand(async () => { + const result = await billingFetch("/api/billing/plans"); + formatOutput(result, program.opts()); + console.log(chalk.gray("\nTo upgrade, visit the billing portal: wave billing portal")); + }), + ); +} diff --git a/src/commands/captions/index.ts b/src/commands/captions/index.ts new file mode 100644 index 0000000..a2dc7f1 --- /dev/null +++ b/src/commands/captions/index.ts @@ -0,0 +1,60 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { getClient } from "../../lib/api-client.js"; +import { formatOutput } from "../../lib/output/index.js"; +import { wrapCommand } from "../../lib/errors.js"; + +export function registerCaptionsCommands(program: Command): void { + const captions = program.command("captions").description("Live captioning and subtitles"); + + captions + .command("generate") + .description("Generate captions for a stream") + .requiredOption("--stream-id ", "Stream ID") + .option("--language ", "Source language", "en") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.captions.generate({ + streamId: opts.streamId, + language: opts.language, + }); + console.log(chalk.green("Caption generation started.")); + formatOutput(result, program.opts()); + }), + ); + + captions + .command("translate") + .description("Translate captions to another language") + .requiredOption("--stream-id ", "Stream ID") + .requiredOption("--target-language ", "Target language code") + .action( + wrapCommand(async (opts) => { + const client = await getClient(program.opts()); + const result = await client.captions.translate({ + streamId: opts.streamId, + targetLanguage: opts.targetLanguage, + }); + console.log(chalk.green(`Translation to ${opts.targetLanguage} started.`)); + formatOutput(result, program.opts()); + }), + ); + + captions + .command("burn-in") + .description("Burn captions into a recording") + .requiredOption("--recording-id ", "Recording ID") + .option("--style