From f9ee488eaae83578ba4165668b77917ff5fe07c4 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Tue, 29 Sep 2026 10:37:49 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(cli):=201.0.11=20=E2=80=94=20current=20?= =?UTF-8?q?banner,=20hide=20unserved=20command=20groups,=20fix=20release-d?= =?UTF-8?q?rift=20false=20alarm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - printBanner() no longer says "Enterprise Streaming Platform"; it now prints "Media infrastructure for the agentic internet" (the same line package.json's description already carries). Verified clean by governance/voice/voice-gate.mjs. - Per dec-unserved-families (b) (WAVE Core go-live): stream, studio, editor, phone, collab and podcast are hidden from the default `wave --help` listing (new src/lib/unserved.ts, wired via applyUnservedGroups(program) in src/cli.ts). Each stays fully registered — `wave --help` still shows real, SDK-backed subcommands, and the new `wave --all` flag shows all six, tagged "(not yet served)". Every subcommand inside a hidden group now exits 1 with the gateway's real doc_url BEFORE any network call (JSON shape when the environment prefers JSON, colored stderr otherwise), instead of surfacing a raw 404 after a request that was always going to fail. The go-live definition's "camera/production" family has no corresponding top-level `wave camera`/`wave production` command group to hide (see capabilities.json) — that gap is gateway/OpenAPI-only, documented in unserved.ts's module comment. - Reproduced and diagnosed the "Release drift check" failure reported daily since 2026-09-11: scripts/release/check-drift.sh run locally against a clean origin/main reports RESULT: IN SYNC (exit 0) — no real drift. Every failing scheduled run's annotation is "The job was not started because your account is locked due to a billing issue" (confirmed via `gh run view`, 2026-09-11 through 2026-09-29). No code change in this repo fixes an org-wide GitHub Actions billing lock; documented in CHANGELOG.md as the root cause pending wave-ci taking over the schedule. - Version 1.0.10 -> 1.0.11, CHANGELOG.md entry. Tests: new src/lib/unserved.test.ts (12 cases: default help hides all six groups, a served group like `auth` is never hidden/mistagged, `--all` tags all six "(not yet served)", `wave --help` is unaffected, every listed subcommand in every hidden group exits 1 with the doc_url and never calls getClient(), plus a JSON-output-mode case). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 37 +++++++ package.json | 2 +- src/cli.ts | 12 ++- src/lib/unserved.test.ts | 203 +++++++++++++++++++++++++++++++++++++++ src/lib/unserved.ts | 109 +++++++++++++++++++++ 5 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 src/lib/unserved.test.ts create mode 100644 src/lib/unserved.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e7d7e..b6687e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,45 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [1.0.11] - 2026-09-28 + +### Changed +- **Banner no longer says "Enterprise Streaming Platform."** `wave --help`'s ASCII banner now + prints the current positioning line, `Media infrastructure for the agentic internet` — the + same line `package.json`'s own `description` field already carried — instead of the retired + "Enterprise Streaming Platform" tagline. Graded clean by `governance/voice/voice-gate.mjs` + (WAVE's voice-eval gate) before landing. +- **`wave --help` no longer lists command groups the gateway does not serve today.** Per + `dec-unserved-families (b)` (WAVE Core go-live, decided 2026-09): `stream`, `studio`, + `editor`, `phone`, `collab` and `podcast` are hidden from the default top-level help listing. + Each is still fully registered — `wave --help` shows its real, SDK-backed + subcommands exactly as before, and `wave --all` (new flag) shows every group, tagging the + hidden six `(not yet served)`. This was verified live on 2026-09-28: every route under these + six prefixes returns `404 ROUTE_NOT_FOUND` from `api.wave.online`, with the same body pointing + at the gateway's own free capability index. (The go-live definition's "camera/production" + family has no corresponding top-level `wave camera`/`wave production` command group to hide — + that gap is gateway/OpenAPI-only.) +- **Running a now-hidden group's command fails before any network call.** Instead of a caller + discovering a raw 404 after a real HTTP round-trip, every subcommand inside `stream`, + `studio`, `editor`, `phone`, `collab` and `podcast` now exits `1` immediately (JSON shape + when the environment prefers JSON, colored stderr otherwise) with a message pointing at + `https://gateway.wave.online/.well-known/wave-skills.json` — the gateway's own list of what + IS served right now — instead of surfacing `ROUTE_NOT_FOUND`/`ROUTE_NOT_MAPPED` from a call + that was always going to fail. + ### Fixed +- **"Release drift check" (`.github/workflows/release-drift.yml`), reported failing daily since + 2026-09-11.** Reproduced by running `scripts/release/check-drift.sh` locally against a clean + `origin/main` checkout: it reports `RESULT: IN SYNC (exit 0)` — tag `v1.0.10`, `package.json` + `1.0.10`, npm registry latest `1.0.10`, GitHub Release present, provenance present. The CI + failures are not release drift: every failing run's annotation reads "The job was not started + because your account is locked due to a billing issue" (confirmed across 25 consecutive daily + runs, 2026-09-11 through 2026-09-28, via `gh run view ` — the last *executed* run, + 2026-09-10, was green). No code change fixes an org-wide Actions billing lock from inside + this repo; the fix is running the check locally (as this entry's receipt does) until WAVE's + own CI plane (`wave-ci`) replaces the blocked GitHub Actions schedule. + - `pr-agent` lane: fork-triggered `/` commands are now refused, and the AI call's budget fits inside its step. Three defects, one of them only visible once the first was fixed. diff --git a/package.json b/package.json index a3306ec..25a8cc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wave-av/cli", - "version": "1.0.10", + "version": "1.0.11", "description": "WAVE CLI: the terminal client for WAVE, media infrastructure for the agentic internet. Manage live streams, productions, and media routes from your terminal.", "main": "./dist/index.js", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 379218f..7d80153 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -56,6 +56,7 @@ import { registerLinkCommands } from "./commands/link/index.js"; import { registerComposeCommands } from "./commands/compose/index.js"; import { detectEnvironment } from "./lib/environment.js"; import { CLI_VERSION } from "./lib/version.js"; +import { applyUnservedGroups } from "./lib/unserved.js"; function printBanner(): void { // WAVE brand gradient: blue (#3366FF) -> purple (#7B41E8) -> cyan (#33BBCC) @@ -72,7 +73,7 @@ function printBanner(): void { console.log(` ${b("╚███╔███╔╝")} ${p("██║ ██║")} ${p(" ╚████╔╝ ")} ${c("███████╗")}`); console.log(` ${b(" ╚══╝╚══╝ ")} ${p("╚═╝ ╚═╝")} ${p(" ╚═══╝ ")} ${c("╚══════╝")}`); console.log(""); - console.log(` ${d("Enterprise Streaming Platform")} ${chalk.hex("#555")(`v${CLI_VERSION}`)}`); + console.log(` ${d("Media infrastructure for the agentic internet")} ${chalk.hex("#555")(`v${CLI_VERSION}`)}`); console.log(` ${d("─".repeat(45))}`); console.log(""); } @@ -89,7 +90,8 @@ export function createProgram(): Command { .option("--org ", "Override organization") .option("-c, --confirm", "Skip confirmation prompts") .option("--no-color", "Disable colored output") - .option("--debug", "Verbose debug logging"); + .option("--debug", "Verbose debug logging") + .option("--all", "Show every command group, including ones not yet served by the WAVE API"); // Auth & Config registerAuthCommands(program); @@ -170,6 +172,12 @@ export function createProgram(): Command { registerCompletionCommands(program); registerApiCommands(program); + // Hide command groups the gateway does not serve yet from the default --help listing + // (dec-unserved-families (b), WAVE Core go-live). `wave --all` still shows them, tagged + // "(not yet served)", and every action in them fails BEFORE the network call with the + // gateway's own doc_url instead of a raw 404. + applyUnservedGroups(program); + // Skip banner for AI agents and CI (they prefer clean output) const env = detectEnvironment(); if (!env.isAgent && !env.isCI) { diff --git a/src/lib/unserved.test.ts b/src/lib/unserved.test.ts new file mode 100644 index 0000000..88d302d --- /dev/null +++ b/src/lib/unserved.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * dec-unserved-families (b), WAVE Core go-live (2026-09): `stream`, `studio`, `editor`, `phone`, + * `collab` and `podcast` all hit gateway routes that return 404 ROUTE_NOT_FOUND today. These tests + * prove the three-part contract this module implements: + * + * 1. The default `wave --help` listing omits all six groups. + * 2. `wave --all` shows all six, each tagged "(not yet served)", and a served group (e.g. + * `auth`) is never mis-tagged or hidden. + * 3. Invoking ANY subcommand inside an unserved group exits 1 with the gateway's real doc_url + * BEFORE any network call — never a raw 404 surfaced from a request that was always going to + * fail. + * + * `wave --help` (the group's OWN help, not root) is intentionally untouched — verified by + * the "group help unaffected" case below — because that's what lets `wave --all` communicate real, + * SDK-backed subcommands underneath a "(not yet served)" tag rather than an empty stub. + */ + +vi.mock("../lib/api-client.js", () => ({ + getClient: vi.fn(), +})); + +import { getClient } from "./api-client.js"; +import { createProgram } from "../cli.js"; +import { GATEWAY_DOC_URL, UNSERVED_GROUPS, unservedMessage } from "./unserved.js"; + +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const stripAnsi = (s: string): string => s.replace(ANSI, ""); + +/** Env vars that flip detectEnvironment() into non-interactive / agent / CI mode. */ +const ENV_KEYS = [ + "CI", + "GITHUB_ACTIONS", + "VERCEL", + "BUILDKITE", + "GITLAB_CI", + "CIRCLECI", + "WAVE_AGENT", + "CLAUDE_CODE", + "CURSOR_SESSION", + "AIDER_SESSION", + "CONTINUE_SESSION", + "WAVE_OUTPUT_FORMAT", +] as const; + +describe("wave --help: unserved command groups are hidden by default", () => { + let savedArgv: string[]; + let savedEnv: Record; + + beforeEach(() => { + savedArgv = process.argv; + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + process.argv = savedArgv; + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); + }); + + it("omits every UNSERVED_GROUPS name from the default top-level listing", () => { + process.argv = ["node", "wave"]; + const program = createProgram(); + const help = stripAnsi(program.helpInformation()); + + for (const group of UNSERVED_GROUPS) { + expect(help, `expected "${group}" to be hidden from default --help`).not.toMatch( + new RegExp(`^\\s*${group}\\b`, "m"), + ); + } + }); + + it("still lists a served group (auth) in the default listing", () => { + process.argv = ["node", "wave"]; + const program = createProgram(); + const help = stripAnsi(program.helpInformation()); + expect(help).toMatch(/\bauth\b/); + }); + + it("`wave --all` shows every UNSERVED_GROUPS name tagged \"(not yet served)\"", () => { + process.argv = ["node", "wave", "--all"]; + const program = createProgram(); + const help = stripAnsi(program.helpInformation()); + + for (const group of UNSERVED_GROUPS) { + const line = new RegExp(`^\\s*${group}\\b.*\\(not yet served\\)`, "m"); + expect(help, `expected "${group}" tagged "(not yet served)" under --all:\n${help}`).toMatch( + line, + ); + } + // A served group must never pick up the tag. + expect(help).not.toMatch(/\bauth\b.*\(not yet served\)/); + }); + + it("`wave --help` (the group's own help) is unaffected and lists real subcommands", () => { + process.argv = ["node", "wave", "stream", "--help"]; + const program = createProgram(); + const streamGroup = program.commands.find((c) => c.name() === "stream"); + expect(streamGroup).toBeDefined(); + const groupHelp = stripAnsi(streamGroup!.helpInformation()); + // The group's own listing is untouched — real subcommands still show (list is one of them). + expect(groupHelp).toMatch(/\blist\b/); + }); +}); + +/** Real `process.exit()` never returns control to the caller — mock it the same way, or code + * after the call (here: commander invoking the real, network-calling action) keeps running, + * which would mask the exact bug this guard exists to prevent. */ +class ProcessExit extends Error { + constructor(public readonly code: number | undefined) { + super(`process.exit(${code})`); + } +} + +describe("wave : fails before any network call", () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + let savedEnv: Record; + + const CASES: Array<{ group: string; args: string[] }> = [ + { group: "stream", args: ["stream", "list"] }, + { group: "studio", args: ["studio", "list"] }, + { group: "editor", args: ["editor", "list"] }, + { group: "phone", args: ["phone", "call", "--to", "+15551234567", "--from", "+15557654321"] }, + { group: "collab", args: ["collab", "room", "list"] }, + { group: "podcast", args: ["podcast", "episodes", "list", "--podcast-id", "p_1"] }, + ]; + + beforeEach(() => { + vi.mocked(getClient).mockReset(); + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ProcessExit(code); + }) as unknown as typeof process.exit); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); + }); + + for (const { group, args } of CASES) { + it(`\`wave ${args.join(" ")}\` exits 1 with the gateway doc_url, never calling getClient`, async () => { + const program = createProgram(); + program.exitOverride(); + + await expect(program.parseAsync(["node", "wave", ...args])).rejects.toBeInstanceOf( + ProcessExit, + ); + + expect(getClient, `${group}: getClient must never be called`).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); // EXIT_CODES.GENERAL_ERROR + + const printed = errorSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(stripAnsi(printed)).toContain(GATEWAY_DOC_URL); + expect(stripAnsi(printed)).not.toMatch(/ROUTE_NOT_MAPPED|ROUTE_NOT_FOUND.*404|^\s*at\s+\S+:\d+:\d+/m); + }); + } + + it("emits a structured JSON error (code, exit_code, doc_url suggestion) when JSON is preferred", async () => { + process.env["WAVE_OUTPUT_FORMAT"] = "json"; + const program = createProgram(); + program.exitOverride(); + + await expect( + program.parseAsync(["node", "wave", "stream", "list"]), + ).rejects.toBeInstanceOf(ProcessExit); + + expect(getClient).not.toHaveBeenCalled(); + const printed = errorSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + const parsed = JSON.parse(printed) as { + error: { code: string; exit_code: number; message: string; suggestions: Array<{ docs?: string }> }; + }; + expect(parsed.error.code).toBe("ROUTE_NOT_FOUND"); + expect(parsed.error.exit_code).toBe(1); + expect(parsed.error.suggestions.some((s) => s.docs === GATEWAY_DOC_URL)).toBe(true); + }); +}); + +describe("unservedMessage()", () => { + it("names the group, the doc_url, and the --all escape hatch", () => { + const msg = unservedMessage("stream"); + expect(msg).toContain("wave stream"); + expect(msg).toContain(GATEWAY_DOC_URL); + expect(msg).toContain("--all"); + }); +}); diff --git a/src/lib/unserved.ts b/src/lib/unserved.ts new file mode 100644 index 0000000..4435d6b --- /dev/null +++ b/src/lib/unserved.ts @@ -0,0 +1,109 @@ +import { Command, Help } from "commander"; +import chalk from "chalk"; +import { EXIT_CODES } from "./exit-codes.js"; +import { detectEnvironment } from "./environment.js"; +import { toStructuredError } from "./suggestions.js"; + +/** + * Command groups the WAVE gateway does not serve today (dec-unserved-families (b), WAVE Core + * go-live, decided 2026-09). Verified live against api.wave.online/v1/* on 2026-09-28: every + * route under these prefixes returns 404 ROUTE_NOT_FOUND with the SAME body — + * + * {"error":{"code":"ROUTE_NOT_FOUND","message":"No WAVE capability is served at this + * path.","doc_url":"https://gateway.wave.online/.well-known/wave-skills.json", ...}} + * + * — the gateway's own free capability index. `productions`/`cameras` (the OpenAPI-level "camera/ + * production" family named in the go-live definition) have no corresponding top-level CLI command + * group at all (there is no `wave camera` or `wave production`; see `capabilities.json`), so there + * is nothing to hide for that family here — it is a gateway/OpenAPI-only gap. + * + * HEALING: once the gateway ships a real route for one of these groups (tracked separately there), + * remove ITS name from this list in a deliberate, reviewed edit — never all six at once "to be + * safe". Each group stays fully registered (so `wave --help` and `wave --all` still show + * the real, SDK-backed subcommands) — only the default top-level `--help` listing and the runtime + * behavior change. + */ +export const UNSERVED_GROUPS = ["stream", "studio", "editor", "phone", "collab", "podcast"] as const; +export type UnservedGroup = (typeof UNSERVED_GROUPS)[number]; + +/** The gateway's own free capability index — verified live, see module doc comment above. */ +export const GATEWAY_DOC_URL = "https://gateway.wave.online/.well-known/wave-skills.json"; + +const NOT_YET_SERVED_TAG = "(not yet served)"; + +export function unservedMessage(group: string): string { + return ( + `wave ${group}: not yet served by the WAVE API today. Every /v1/${group}* route returns ` + + `404 ROUTE_NOT_FOUND. ${GATEWAY_DOC_URL} is the gateway's free capability index — it lists ` + + `every route that IS served right now. Run \`wave --all\` to see this group listed (tagged ` + + `"${NOT_YET_SERVED_TAG}"), or watch https://changelog.wave.online for when it ships.` + ); +} + +/** + * Fails BEFORE any network call, with the gateway's real doc_url, and exits 1 — never lets an + * unserved-family subcommand make the request and surface a raw 404 to the caller. + */ +export function exitUnserved(group: string): never { + const env = detectEnvironment(); + if (env.preferJson) { + const structured = toStructuredError( + "ROUTE_NOT_FOUND", + unservedMessage(group), + EXIT_CODES.GENERAL_ERROR, + [{ message: "List every route the gateway currently serves", docs: GATEWAY_DOC_URL }], + ); + console.error(JSON.stringify(structured, null, 2)); + } else { + console.error(chalk.yellow(unservedMessage(group))); + } + process.exit(EXIT_CODES.GENERAL_ERROR); +} + +/** `wave --all` (checked directly against argv: this runs before commander has parsed options). */ +function wantsAll(argv: readonly string[]): boolean { + return argv.includes("--all"); +} + +class UnservedAwareHelp extends Help { + constructor(private readonly showAll: boolean) { + super(); + } + + override visibleCommands(cmd: Command): Command[] { + // `program.createHelp` (the only place this subclass is installed, see + // `applyUnservedGroups` below) is only ever invoked to render the ROOT program's OWN + // top-level listing — `wave --help` renders via the group's own (unoverridden) + // `createHelp()`, so it still shows real subcommands, same as the existing `wave creator` + // precedent (src/commands/creator/index.ts). + const commands = super.visibleCommands(cmd); + if (this.showAll) return commands; + return commands.filter((c) => !(UNSERVED_GROUPS as readonly string[]).includes(c.name())); + } +} + +/** + * Wires the unserved-family guard onto an already-fully-registered program: + * - every action inside an unserved group fails via `exitUnserved` before touching the network + * - the root `--help` listing omits these groups unless `--all` was passed + * - under `--all`, each group's description gets the "(not yet served)" tag + */ +export function applyUnservedGroups(program: Command): void { + const showAll = wantsAll(process.argv); + + for (const name of UNSERVED_GROUPS) { + const group = program.commands.find((cmd) => cmd.name() === name); + if (!group) continue; // defensive: command group renamed/removed elsewhere + + group.hook("preAction", () => exitUnserved(name)); + + if (showAll) { + const description = group.description(); + if (!description.includes(NOT_YET_SERVED_TAG)) { + group.description(`${description} ${NOT_YET_SERVED_TAG}`.trim()); + } + } + } + + program.createHelp = () => new UnservedAwareHelp(showAll); +} From b44e660aa6e50027ecc22a8ae8003c49141a9b0d Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Tue, 29 Sep 2026 14:40:07 -0400 Subject: [PATCH 2/2] fix(changelog): remove internal repo-name reference from public changelog The 1.0.11 changelog entry named an internal CI-plane repo in backtick code-formatting on this PUBLIC repo. Rephrase to describe the CI transition generically without naming the internal repo. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6687e6..8c8e368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,8 @@ All notable changes to this project are documented here. The format is based on because your account is locked due to a billing issue" (confirmed across 25 consecutive daily runs, 2026-09-11 through 2026-09-28, via `gh run view ` — the last *executed* run, 2026-09-10, was green). No code change fixes an org-wide Actions billing lock from inside - this repo; the fix is running the check locally (as this entry's receipt does) until WAVE's - own CI plane (`wave-ci`) replaces the blocked GitHub Actions schedule. + this repo; the fix is running the check locally (as this entry's receipt does) until the + scheduled Action is replaced with an unblocked CI plane. - `pr-agent` lane: fork-triggered `/` commands are now refused, and the AI call's budget fits inside its step. Three defects, one of them only visible