diff --git a/src/cli.ts b/src/cli.ts index 8d30a24..379218f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ 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 { registerCiCommands } from "./commands/ci/index.js"; import { registerChaptersCommands } from "./commands/chapters/index.js"; import { registerAICommands } from "./commands/ai/index.js"; import { registerTranscribeCommands } from "./commands/transcribe/index.js"; @@ -108,6 +109,7 @@ export function createProgram(): Command { registerPhoneCommands(program); registerCollabCommands(program); registerCaptionsCommands(program); + registerCiCommands(program); registerChaptersCommands(program); registerAICommands(program); registerTranscribeCommands(program); diff --git a/src/commands/ci/index.test.ts b/src/commands/ci/index.test.ts new file mode 100644 index 0000000..5018f5c --- /dev/null +++ b/src/commands/ci/index.test.ts @@ -0,0 +1,79 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerCiCommands } from "./index.js"; + +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.option("-o, --output ", "", "json"); + registerCiCommands(program); + return program; +} + +describe("wave ci", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + process.env.WAVE_CI_GATEWAY_SECRET = "test-secret"; + delete process.env.WAVE_CI_BASE; + fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete process.env.WAVE_CI_GATEWAY_SECRET; + }); + + it("status hits the gateway with the trust secret", async () => { + await buildProgram().parseAsync(["ci", "status"], { from: "user" }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://ci.wave.online/v1/ci/status"); + expect((init.headers as Record)["x-wave-gateway-secret"]).toBe("test-secret"); + }); + + it("dispatch posts repo/workflow/ref", async () => { + await buildProgram().parseAsync( + ["ci", "dispatch", "--repo", "wave-av/cli", "--workflow", "smoke-install.yml", "--ref", "main"], + { from: "user" }, + ); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://ci.wave.online/v1/ci/dispatch"); + expect(JSON.parse(init.body as string)).toMatchObject({ repo: "wave-av/cli", workflow: "smoke-install.yml", ref: "main" }); + }); + + it("rwx-dispatch posts key/ref", async () => { + await buildProgram().parseAsync(["ci", "rwx-dispatch", "--key", "deploy", "--ref", "main"], { from: "user" }); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://ci.wave.online/v1/ci/rwx/dispatch"); + }); + + it("captain-suites hits the suites route", async () => { + await buildProgram().parseAsync(["ci", "captain-suites"], { from: "user" }); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://ci.wave.online/v1/ci/captain/suites"); + }); + + it("refuses without the gateway secret", async () => { + delete process.env.WAVE_CI_GATEWAY_SECRET; + const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + await buildProgram().parseAsync(["ci", "status"], { from: "user" }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("surfaces gateway errors", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { code: "CI_AUTH_REQUIRED", message: "nope" } }), { status: 401 }), + ); + const err = vi.mocked(console.error).mock.calls; + const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + await buildProgram().parseAsync(["ci", "status"], { from: "user" }); + expect(exit).toHaveBeenCalledWith(1); + expect(err.map((c) => String(c[0])).join(" ")).toMatch(/CI_AUTH_REQUIRED/); + }); +}); diff --git a/src/commands/ci/index.ts b/src/commands/ci/index.ts new file mode 100644 index 0000000..5da2435 --- /dev/null +++ b/src/commands/ci/index.ts @@ -0,0 +1,116 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { wrapCommand } from "../../lib/errors.js"; +import { formatOutput } from "../../lib/output/index.js"; + +const CI_BASE = process.env.WAVE_CI_BASE?.replace(/\/+$/, "") ?? "https://ci.wave.online"; + +function ciHeaders(): Record { + const secret = process.env.WAVE_CI_GATEWAY_SECRET; + if (!secret) { + throw new Error("WAVE_CI_GATEWAY_SECRET is not set (server-side gateway secret; see wave-ci docs)."); + } + return { "x-wave-gateway-secret": secret, "x-wave-org": process.env.WAVE_CI_ORG ?? "wave-av" }; +} + +async function ciCall(method: string, path: string, body?: unknown): Promise { + const res = await fetch(CI_BASE + path, { + method, + headers: body !== undefined ? { ...ciHeaders(), "content-type": "application/json" } : ciHeaders(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const data = (await res.json().catch(() => ({}))) as { error?: { code?: string; message?: string } }; + if (!res.ok) { + throw new Error(`${res.status} ${data.error?.code ?? "UNKNOWN"}: ${data.error?.message ?? "request failed"}`); + } + return data as T; +} + +export function registerCiCommands(program: Command): void { + const ci = program.command("ci").description("Inspect the wave-ci plane (Depot/RWX engines, billing, census)"); + + ci.command("status") + .description("wave-ci health probe (engine configuration)") + .action( + wrapCommand(async () => { + formatOutput(await ciCall("GET", "/v1/ci/status"), program.opts()); + }), + ); + + ci.command("dispatch") + .description("Dispatch a Depot workflow run") + .requiredOption("--repo ", "Repository slug (org/name)") + .requiredOption("--workflow ", "Workflow file") + .option("--ref ", "Git ref", "main") + .action( + wrapCommand(async (opts: { repo: string; workflow: string; ref: string }) => { + formatOutput(await ciCall("POST", "/v1/ci/dispatch", opts), program.opts()); + }), + ); + + ci.command("get-run") + .description("Poll one CI run's status by runId") + .argument("", "Run ID") + .action( + wrapCommand(async (runId: string) => { + formatOutput(await ciCall("GET", `/v1/ci/runs/${encodeURIComponent(runId)}`), program.opts()); + }), + ); + + ci.command("metrics") + .description("CPU/mem/duration metrics for one CI run") + .argument("", "Run ID") + .action( + wrapCommand(async (runId: string) => { + formatOutput(await ciCall("GET", `/v1/ci/runs/${encodeURIComponent(runId)}/metrics`), program.opts()); + }), + ); + + ci.command("rerun") + .description("Re-run a finished CI workflow") + .argument("", "Run ID") + .action( + wrapCommand(async (runId: string) => { + formatOutput(await ciCall("POST", `/v1/ci/runs/${encodeURIComponent(runId)}/rerun`), program.opts()); + }), + ); + + ci.command("rwx-dispatch") + .description("Trigger an RWX dispatch trigger run") + .requiredOption("--key ", "Dispatch trigger key") + .requiredOption("--ref ", "Git ref") + .action( + wrapCommand(async (opts: { key: string; ref: string }) => { + formatOutput(await ciCall("POST", "/v1/ci/rwx/dispatch", opts), program.opts()); + }), + ); + + ci.command("captain-suites") + .description("Captain Cloud test suites with flake counters") + .action( + wrapCommand(async () => { + formatOutput(await ciCall("GET", "/v1/ci/captain/suites"), program.opts()); + }), + ); + + ci.command("billing") + .description("GitHub org billing snapshot for a period") + .option("--year ", "Year") + .option("--month ", "Month 1-12") + .action( + wrapCommand(async (opts: { year?: string; month?: string }) => { + const qs = + opts.year || opts.month + ? `?${[opts.year && `year=${opts.year}`, opts.month && `month=${opts.month}`].filter(Boolean).join("&")}` + : ""; + const data = await ciCall>("GET", `/v1/ci/billing${qs}`); + if (program.opts().output && program.opts().output !== "table") { + formatOutput(data, program.opts()); + return; + } + const mtd = data.mtd as { actionsNetUsd?: number } | undefined; + console.log(chalk.bold("Actions MTD: ") + chalk.green(`$${mtd?.actionsNetUsd ?? "?"}`)); + formatOutput(data, { ...program.opts(), output: "json" }); + }), + ); +}