From 91093d5144aab64d8a3db83ad1a8fbe9f3b478f0 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 20:21:46 -0400 Subject: [PATCH] fix(version): derive every CLI version surface from package.json (VER-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Published @wave-av/cli@1.0.8 printed `1.0.0` from `wave --version`. The `--version` half was fixed in 1.0.9, but two hardcoded literals survived on the wire, so even a correct 1.0.9 release would have told the gateway it was 1.0.0: src/lib/api-client.ts:49 "X-Wave-CLI-Version": "1.0.0" src/commands/api/index.ts:34 "User-Agent": "wave-cli/1.0.0" The literal is the defect class, not the value. Bumping it to 1.0.9 would reproduce the bug at the next release, so this removes it instead. - NEW src/lib/version.ts: the single source of truth. CLI_VERSION and cliUserAgent() derive from package.json. Resolution walks UP to the nearest package.json rather than reading a fixed `../package.json`, because the dev layout (src/lib/version.ts) and the shipped bundle (dist/index.js) sit at different depths below the package root — a fixed relative path is correct in exactly one of them and silently wrong in the other. - src/cli.ts, api-client.ts, commands/api/index.ts now consume it. Verified in the built bundle: zero `wave-cli/1.0.0` and zero hardcoded X-Wave-CLI-Version remain. - src/cli.test.ts: three gates keyed on package.json — --version agreement, banner agreement, and a scan of src/ that fails on any new version literal outside a documented allowlist (config-file schema version, and the 0.0.0-unknown sentinel). - smoke-install.yml: adds a `unit` job so `npm test` runs on PRs at all (it previously ran ONLY in release.yml on a `v*` tag, so the version tests never gated a PR), and hardens the existing smoke step, which ran `wave --version` and discarded the output, to compare it and the banner against package.json. release.yml is deliberately untouched — PRs #17 and #45 own that file. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-install.yml | 62 +++++++++- src/cli.test.ts | 171 ++++++++++++++++++++++++++-- src/cli.ts | 21 +--- src/commands/api/index.ts | 3 +- src/lib/api-client.ts | 3 +- src/lib/version.ts | 62 ++++++++++ 6 files changed, 285 insertions(+), 37 deletions(-) create mode 100644 src/lib/version.ts diff --git a/.github/workflows/smoke-install.yml b/.github/workflows/smoke-install.yml index 4ef21d2..2c18ec9 100644 --- a/.github/workflows/smoke-install.yml +++ b/.github/workflows/smoke-install.yml @@ -23,6 +23,30 @@ concurrency: cancel-in-progress: true jobs: + # VER-001 gate. Until now `npm test` ran ONLY in release.yml, which is `on: push: tags: v*` — + # so the version regression tests in src/cli.test.ts never gated a pull request, and a version + # drift could only be discovered after a tag was already cut. This job runs them on every PR. + unit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Unit tests (includes the VER-001 version-truth gate) + run: npm test + smoke: runs-on: ubuntu-latest timeout-minutes: 10 @@ -57,11 +81,43 @@ jobs: tarball=$(ls "$RUNNER_TEMP"/wave-av-cli-*.tgz | head -n1) npm i "$tarball" - - name: wave --version / --help (module-resolution smoke) + - name: wave --version / --help (module-resolution + VER-001 version-truth smoke) run: | cd "$RUNNER_TEMP/smoke" - npx --yes wave --version - npx --yes wave --help >/dev/null + + # VER-001: this step used to run `wave --version` and DISCARD the output, so the + # installed tarball could print any version at all and still pass. Published 1.0.8 + # printed "1.0.0" exactly like this and shipped. Compare against package.json — this + # is the end-to-end half of the gate (the unit job covers the source half), and it + # runs against the real packed tarball as a real user's install would see it. + EXPECTED=$(node -p "require('$GITHUB_WORKSPACE/package.json').version") + ACTUAL=$(npx --yes wave --version | tr -d '[:space:]') + + echo "package.json=$EXPECTED wave --version=$ACTUAL" + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "::error::VER-001: installed CLI reports '$ACTUAL' but package.json says '$EXPECTED'" + exit 1 + fi + + # The banner is a second, independent rendering of the version — it disagreed with + # --version in the shipped 1.0.8 bundle. Assert it carries the same version. The CLI + # deliberately suppresses the banner under CI/agent env vars, so clear every variable + # detectEnvironment() keys on (src/lib/environment.ts) — otherwise this check would + # silently assert against a banner that was never printed, and pass for the wrong + # reason. Comparing the extracted BANNER for EQUALITY (rather than grepping for a + # substring) is what makes a missing banner a failure instead of a quiet pass. + HELP=$( + unset CI GITHUB_ACTIONS VERCEL BUILDKITE GITLAB_CI CIRCLECI \ + WAVE_AGENT CLAUDE_CODE CURSOR_SESSION AIDER_SESSION CONTINUE_SESSION + npx --yes wave --help 2>&1 + ) + BANNER=$(printf '%s' "$HELP" | sed 's/\x1b\[[0-9;]*m//g' | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' | head -n1) + if [ "$BANNER" != "v$EXPECTED" ]; then + echo "::error::VER-001: help banner reported '${BANNER:-}', expected 'v$EXPECTED'" + printf '%s\n' "$HELP" | head -n 20 + exit 1 + fi + echo "VER-001: package.json == --version == banner == $EXPECTED" - name: wave status / wave doctor (live gateway reachability) working-directory: ${{ runner.temp }}/smoke diff --git a/src/cli.test.ts b/src/cli.test.ts index eddc92a..03746f4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,26 +1,173 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createProgram } from "./cli.js"; +import { CLI_VERSION, UNKNOWN_VERSION, cliUserAgent } from "./lib/version.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = __dirname; +const PKG_VERSION = ( + JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")) as { + version: string; + } +).version; + +/** Strip SGR colour codes so assertions run against the text a user actually reads. */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const stripAnsi = (s: string): string => s.replace(ANSI, ""); /** - * Regression test for `wave --version` printing a hardcoded "1.0.0" instead of the actual - * published package version (1.0.8+). See CHANGELOG for the incident. + * VER-001 — every version-bearing surface must agree with package.json. + * + * Background: published @wave-av/cli@1.0.8 printed `1.0.0` from `wave --version` because the + * version was a hardcoded literal that stopped tracking package.json. `--version` was fixed to + * derive from package.json, but two literals survived on the wire (`X-Wave-CLI-Version` and the + * `User-Agent`), so the gateway still saw 1.0.0. These tests fail if ANY of the four surfaces — + * package.json, `--version`, the help banner, the outbound headers — drift apart again, and the + * scan below fails if a new hardcoded literal is introduced anywhere under src/. */ -describe("wave --version", () => { - it("reports the version from package.json, not a hardcoded string", () => { - const pkg = JSON.parse( - readFileSync(join(__dirname, "..", "package.json"), "utf-8"), - ) as { version: string }; +describe("VER-001: CLI version is a single source of truth", () => { + it("derives CLI_VERSION from package.json", () => { + expect(CLI_VERSION).toBe(PKG_VERSION); + expect(CLI_VERSION).not.toBe(UNKNOWN_VERSION); + }); + it("reports the version from package.json via --version, not a hardcoded string", () => { const program = createProgram(); - expect(program.version()).toBe(pkg.version); + expect(program.version()).toBe(PKG_VERSION); // The bug shipped as literally "1.0.0" regardless of the real published version. - if (pkg.version !== "1.0.0") { + if (PKG_VERSION !== "1.0.0") { expect(program.version()).not.toBe("1.0.0"); } }); + + it("sends the same version on the wire as it prints", () => { + expect(cliUserAgent()).toBe(`wave-cli/${PKG_VERSION}`); + }); +}); + +/** + * The banner is a SECOND, independent rendering of the version — published 1.0.8 disagreed with + * itself here. `printBanner` is module-private, and the banner hook is only installed for humans, + * so the reachable path is: clear the CI/agent env vars, then call `program.helpInformation()`. + */ +describe("VER-001: help banner agrees with package.json", () => { + const SUPPRESSING_ENV = [ + "CI", + "GITHUB_ACTIONS", + "VERCEL", + "BUILDKITE", + "GITLAB_CI", + "CIRCLECI", + "WAVE_AGENT", + "CLAUDE_CODE", + "CURSOR_SESSION", + "AIDER_SESSION", + "CONTINUE_SESSION", + ] as const; + + let saved: Record = {}; + + beforeEach(() => { + saved = {}; + for (const key of SUPPRESSING_ENV) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); + }); + + it("prints v in the banner", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + + const program = createProgram(); + program.helpInformation(); + + const banner = stripAnsi(lines.join("\n")); + const match = /\bv(\d+\.\d+\.\d+\S*)/.exec(banner); + + expect(match, `no version found in banner:\n${banner}`).not.toBeNull(); + expect(match?.[1]).toBe(PKG_VERSION); + }); +}); + +/** + * The defect CLASS gate. Updating a literal to the current version reproduces the bug at the next + * release; the only durable fix is that no version literal exists in src/ at all. Anything matched + * here must either derive from `lib/version.ts` or earn an explicit, reasoned allowlist entry that + * a reviewer has to see in the diff. + */ +const LITERAL_ALLOWLIST: ReadonlyArray<{ file: string; literal: string; reason: string }> = [ + { + file: "lib/config/schema.ts", + literal: "1.0.0", + reason: + "on-disk CONFIG FILE schema version. Deliberately independent of the CLI version — it " + + "changes only when the config file format changes, and must NOT track releases.", + }, + { + file: "lib/version.ts", + literal: "0.0.0", + reason: + "the UNKNOWN_VERSION sentinel returned when package.json cannot be read. Intentionally " + + "not a plausible version so a broken install is obvious rather than silently wrong.", + }, +]; + +function listTsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listTsFiles(full)); + } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { + out.push(full); + } + } + return out; +} + +/** Whole-line comments only: a doc comment may legitimately narrate the 1.0.0 incident. */ +function isCommentLine(line: string): boolean { + const t = line.trimStart(); + return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*"); +} + +describe("VER-001: no hardcoded version literals under src/", () => { + it("finds every x.y.z literal derived from lib/version.ts or explicitly allowlisted", () => { + const offenders: string[] = []; + + for (const file of listTsFiles(SRC_DIR)) { + const rel = relative(SRC_DIR, file).split(sep).join("/"); + const lines = readFileSync(file, "utf-8").split("\n"); + + lines.forEach((line, i) => { + if (isCommentLine(line)) return; + for (const m of line.matchAll(/\b\d+\.\d+\.\d+/g)) { + const allowed = LITERAL_ALLOWLIST.some( + (a) => a.file === rel && a.literal === m[0], + ); + if (!allowed) offenders.push(`${rel}:${i + 1} ${line.trim()}`); + } + }); + } + + expect( + offenders, + "Hardcoded version literal(s) found. Import CLI_VERSION / cliUserAgent() from " + + "src/lib/version.ts instead of writing a version string, or add a reasoned entry to " + + "LITERAL_ALLOWLIST in this file.", + ).toEqual([]); + }); }); diff --git a/src/cli.ts b/src/cli.ts index cced1ce..31dd661 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import { Command } from "commander"; import chalk from "chalk"; import { registerAuthCommands } from "./commands/auth/index.js"; @@ -54,25 +53,7 @@ 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"; - -/** - * Read the CLI's own version straight from package.json, next to whatever entry point is - * actually running (src/cli.ts in dev, dist/index.js once bundled — both sit one directory - * below the package root). Previously this was hardcoded ("1.0.0") in two places and never - * matched the published version (1.0.8+), which broke `wave --version` and any tooling that - * shells out to it to detect the installed CLI version. - */ -function readOwnVersion(): string { - try { - const require = createRequire(import.meta.url); - const pkg = require("../package.json") as { version?: string }; - return pkg.version ?? "0.0.0-unknown"; - } catch { - return "0.0.0-unknown"; - } -} - -const CLI_VERSION = readOwnVersion(); +import { CLI_VERSION } from "./lib/version.js"; function printBanner(): void { // WAVE brand gradient: blue (#3366FF) -> purple (#7B41E8) -> cyan (#33BBCC) diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index 6278306..8ba483a 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -4,6 +4,7 @@ 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"; +import { cliUserAgent } from "../../lib/version.js"; export function registerApiCommands(program: Command): void { program @@ -31,7 +32,7 @@ export function registerApiCommands(program: Command): void { const headers: Record = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", - "User-Agent": "wave-cli/1.0.0", + "User-Agent": cliUserAgent(), }; // Add custom headers diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 5490972..2b6a228 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -2,6 +2,7 @@ import { Wave } from "@wave-av/sdk"; import chalk from "chalk"; import { loadConfig } from "./config/manager.js"; import { getApiKey } from "./auth/keychain.js"; +import { CLI_VERSION } from "./version.js"; export async function getClient(opts?: { org?: string; project?: string }): Promise { // Environment variable override (for CI/CD) @@ -46,7 +47,7 @@ export async function getClient(opts?: { org?: string; project?: string }): Prom baseUrl: project.baseUrl, customHeaders: { "X-Wave-Source": "cli", - "X-Wave-CLI-Version": "1.0.0", + "X-Wave-CLI-Version": CLI_VERSION, }, }); diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..ad287c8 --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,62 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, parse } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * The single source of truth for "what version of the WAVE CLI is this?". + * + * Every version-bearing surface — `wave --version`, the help banner, the outbound + * `X-Wave-CLI-Version` header and the `User-Agent` — MUST derive from this module. + * A hardcoded version literal anywhere else is the defect class, not a typo: the + * published CLI shipped a literal that stopped tracking package.json and reported a + * stale version to users and to the gateway long after the real version had moved on. + * `src/cli.test.ts` scans `src/` and fails the build if a new literal appears. + * + * Resolution walks UP from this module's own location to the nearest directory holding + * a package.json with a string `version`. That is deliberately depth-independent: in + * development this file is `src/lib/version.ts` (two levels below the package root), + * while the shipped bundle is a single `dist/index.js` (one level below it). A fixed + * `../package.json` would be correct in exactly one of those two layouts and silently + * wrong in the other, which is how depth-coupled version reads break at publish time. + */ + +/** Returned when package.json cannot be located or parsed — never a plausible-looking version. */ +export const UNKNOWN_VERSION = "0.0.0-unknown"; + +function readOwnVersion(): string { + try { + let dir = dirname(fileURLToPath(import.meta.url)); + const { root } = parse(dir); + + for (;;) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown }; + if (typeof pkg.version === "string" && pkg.version.length > 0) { + return pkg.version; + } + } + + if (dir === root) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return UNKNOWN_VERSION; + } catch { + return UNKNOWN_VERSION; + } +} + +/** The running CLI's version, read once from package.json at process start. */ +export const CLI_VERSION = readOwnVersion(); + +/** + * The canonical outbound User-Agent. Centralised so every HTTP caller reports the same + * version as `wave --version` — see the Corridor guardrail on constructing outbound + * request headers through a single utility rather than inline per call site. + */ +export function cliUserAgent(): string { + return `wave-cli/${CLI_VERSION}`; +}