Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions .github/workflows/smoke-install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:-<no version in 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
Expand Down
171 changes: 159 additions & 12 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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<package.json version> 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([]);
});
});
21 changes: 1 addition & 20 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createRequire } from "node:module";
import { Command } from "commander";
import chalk from "chalk";
import { registerAuthCommands } from "./commands/auth/index.js";
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/commands/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -31,7 +32,7 @@ export function registerApiCommands(program: Command): void {
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"User-Agent": "wave-cli/1.0.0",
"User-Agent": cliUserAgent(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: No direct test asserts the outbound header values at the call sites

cliUserAgent() and CLI_VERSION are unit-tested in isolation, but no test asserts that the actual User-Agent / X-Wave-CLI-Version headers built in commands/api/index.ts and lib/api-client.ts use them (e.g. via a mocked client/fetch). This is low-risk given the trivial substitution, but a quick assertion on the constructed headers object in each call site would close the gap the incident was specifically about (headers silently drifting from the printed version).

Was this helpful? React with 👍 / 👎

};

// Add custom headers
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Wave> {
// Environment variable override (for CI/CD)
Expand Down Expand Up @@ -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,
},
});

Expand Down
62 changes: 62 additions & 0 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +27 to +41

@gitar-bot gitar-bot Bot Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: A malformed ancestor package.json aborts the walk-up entirely

The try/catch wraps the whole walk-up loop, so if any ancestor directory (not the CLI's own) contains a package.json with invalid JSON — plausible when installed nested inside another tool's node_modules or a monorepo workspace — JSON.parse throws and the function immediately returns UNKNOWN_VERSION instead of continuing to walk further up to the real package.json. Move the JSON.parse/read into a per-iteration try/catch (continue the loop on parse failure) so only a fatal error before the loop starts (e.g. fileURLToPath failing) falls through to the outer catch.

Catch parse errors per-directory so one bad ancestor package.json doesn't abort the whole resolution.:

for (;;) {
  const candidate = join(dir, "package.json");
  if (existsSync(candidate)) {
    try {
      const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
      if (typeof pkg.version === "string" && pkg.version.length > 0) {
        return pkg.version;
      }
    } catch {
      // malformed package.json at this level; keep walking up
    }
  }
  if (dir === root) break;
  const parent = dirname(dir);
  if (parent === dir) break;
  dir = parent;
}

Was this helpful? React with 👍 / 👎

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}`;
}
Loading