diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 80748f8da..7a8cb49a9 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -552,7 +552,8 @@ Other wave items (read bounds, shell truncation, process-group kill, grep caps, ## Build and Validation ```bash -bun run build # bun build ./src/index.ts --outdir ./dist --target bun +bun run build # bun build ./src/index.ts --outdir ./dist --target bun, stamping + # the version badge via --define "$(bun scripts/build-info.ts)" bun run typecheck # tsc --noEmit bun test ./src ./tests ``` diff --git a/package.json b/package.json index 518c0f530..5ecf7b714 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "corbits": "./dist/corbits" }, "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*' && bun scripts/copy-repo-plugins.ts", - "build:bin": "bun build ./src/index.ts --compile --minify --sourcemap --define process.env.NODE_ENV='\"production\"' --outfile ./dist/corbits && ([ \"$(uname -s)\" != Darwin ] || codesign -s - --force ./dist/corbits) && bun scripts/copy-repo-plugins.ts", + "build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*' --define \"$(bun scripts/build-info.ts)\" && bun scripts/copy-repo-plugins.ts", + "build:bin": "bun build ./src/index.ts --compile --minify --sourcemap --define process.env.NODE_ENV='\"production\"' --define \"$(bun scripts/build-info.ts)\" --outfile ./dist/corbits && ([ \"$(uname -s)\" != Darwin ] || codesign -s - --force ./dist/corbits) && bun scripts/copy-repo-plugins.ts", "typecheck": "tsc --noEmit", "test": "bun test ./src ./e2e ./evals ./scripts ./testkit --randomize --seed 424242", "test:paths": "bun scripts/test-paths.ts", diff --git a/scripts/build-info.test.ts b/scripts/build-info.test.ts new file mode 100644 index 000000000..a5be4a751 --- /dev/null +++ b/scripts/build-info.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +import pkg from "../package.json" with { type: "json" }; +import { withTempDir } from "../testkit/temporary-dirs.js"; +import { + collectGitInfo, + composeDisplayVersion, + type GitInfo, + type RunResult, +} from "./build-info.js"; + +const repoRoot = join(import.meta.dir, ".."); +const scriptPath = join(repoRoot, "scripts", "build-info.ts"); + +const DISPLAY_VERSION_RE = + /^v\d+\.\d+\.\d+(-\d+-g[0-9a-f]+)?(\+g[0-9a-f]+)?(-dirty)?$/; + +describe("composeDisplayVersion", () => { + test("on tag, clean tree -> plain version", () => { + const info: GitInfo = { + tag: "v0.3.36", + count: 0, + hash: "9af7e1e", + dirty: false, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36"); + }); + + test("on tag, dirty tree -> -dirty suffix", () => { + const info: GitInfo = { + tag: "v0.3.36", + count: 0, + hash: "9af7e1e", + dirty: true, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36-dirty"); + }); + + test("N commits past tag -> -N-g", () => { + const info: GitInfo = { + tag: "v0.3.36", + count: 7, + hash: "9af7e1e", + dirty: false, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36-7-g9af7e1e"); + }); + + test("N commits past tag, dirty -> -N-g-dirty", () => { + const info: GitInfo = { + tag: "v0.3.36", + count: 7, + hash: "9af7e1e", + dirty: true, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe( + "v0.3.36-7-g9af7e1e-dirty", + ); + }); + + test("no tags -> +g", () => { + const info: GitInfo = { + tag: null, + count: null, + hash: "9af7e1e", + dirty: false, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36+g9af7e1e"); + }); + + test("no tags, dirty -> +g-dirty", () => { + const info: GitInfo = { + tag: null, + count: null, + hash: "9af7e1e", + dirty: true, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe( + "v0.3.36+g9af7e1e-dirty", + ); + }); + + test("not a git repo / metadata unavailable -> plain version", () => { + const info: GitInfo = { + tag: null, + count: null, + hash: null, + dirty: false, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36"); + }); + + test("tag present but commit count unavailable -> plain version", () => { + const info: GitInfo = { + tag: "v0.3.36", + count: null, + hash: "9af7e1e", + dirty: false, + }; + expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36"); + }); +}); + +interface FakeSpec { + stdout?: string; + exitCode?: number; + throws?: boolean; +} + +function fakeRun(specs: Record) { + return (args: string[]): RunResult => { + const spec = specs[args.join(" ")] ?? {}; + if (spec.throws) throw new Error(`git ${args.join(" ")} exploded`); + return { stdout: spec.stdout ?? "", exitCode: spec.exitCode ?? 0 }; + }; +} + +describe("collectGitInfo", () => { + test("git repo, clean tree", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { stdout: "true" }, + "describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" }, + "rev-list --count v0.3.36..HEAD": { stdout: "7" }, + "rev-parse --short=7 HEAD": { stdout: "9af7e1e" }, + "diff-index --quiet HEAD --": { exitCode: 0 }, + }); + expect(collectGitInfo("/repo", run)).toEqual({ + tag: "v0.3.36", + count: 7, + hash: "9af7e1e", + dirty: false, + }); + }); + + test("git repo, dirty tree (diff-index exits 1)", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { stdout: "true" }, + "describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" }, + "rev-list --count v0.3.36..HEAD": { stdout: "7" }, + "rev-parse --short=7 HEAD": { stdout: "9af7e1e" }, + "diff-index --quiet HEAD --": { exitCode: 1 }, + }); + expect(collectGitInfo("/repo", run)).toEqual({ + tag: "v0.3.36", + count: 7, + hash: "9af7e1e", + dirty: true, + }); + }); + + test("git repo, exactly on tag", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { stdout: "true" }, + "describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" }, + "rev-list --count v0.3.36..HEAD": { stdout: "0" }, + "rev-parse --short=7 HEAD": { stdout: "9af7e1e" }, + "diff-index --quiet HEAD --": { exitCode: 0 }, + }); + expect(collectGitInfo("/repo", run)).toEqual({ + tag: "v0.3.36", + count: 0, + hash: "9af7e1e", + dirty: false, + }); + }); + + test("no tags (describe throws) degrades tag/count, keeps hash", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { stdout: "true" }, + "describe --tags --match v* --abbrev=0": { throws: true }, + "rev-parse --short=7 HEAD": { stdout: "9af7e1e" }, + "diff-index --quiet HEAD --": { exitCode: 0 }, + }); + expect(collectGitInfo("/repo", run)).toEqual({ + tag: null, + count: null, + hash: "9af7e1e", + dirty: false, + }); + }); + + test("no tags (describe exits 128 with empty stdout) degrades tag/count, keeps hash", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { stdout: "true" }, + "describe --tags --match v* --abbrev=0": { exitCode: 128 }, + "rev-parse --short=7 HEAD": { stdout: "9af7e1e" }, + "diff-index --quiet HEAD --": { exitCode: 0 }, + }); + expect(collectGitInfo("/repo", run)).toEqual({ + tag: null, + count: null, + hash: "9af7e1e", + dirty: false, + }); + }); + + test("not a git repo (--is-inside-work-tree -> false)", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { stdout: "false" }, + }); + expect(collectGitInfo("/outside", run)).toEqual({ + tag: null, + count: null, + hash: null, + dirty: false, + }); + }); + + test("git missing (rev-parse exits non-zero) -> all null", () => { + const run = fakeRun({ + "rev-parse --is-inside-work-tree": { exitCode: 128 }, + }); + expect(collectGitInfo("/outside", run)).toEqual({ + tag: null, + count: null, + hash: null, + dirty: false, + }); + }); +}); + +describe("build-info CLI", () => { + test("outside a git repo prints a plain-version define", async () => { + await withTempDir("build-info-not-git-", async (dir) => { + const res = spawnSync(process.execPath, [scriptPath], { + cwd: dir, + encoding: "utf8", + }); + expect(res.status).toBe(0); + expect(res.stdout.trim()).toBe( + `process.env.CORBITS_BUILD_INFO="v${pkg.version}"`, + ); + }); + }); + + test("from the repo root prints a define matching the suffix regex", () => { + const res = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf8", + }); + expect(res.status).toBe(0); + const line = res.stdout.trim(); + expect(line.startsWith("process.env.CORBITS_BUILD_INFO=")).toBe(true); + const value: string = JSON.parse( + line.slice("process.env.CORBITS_BUILD_INFO=".length), + ); + expect(value).toMatch(DISPLAY_VERSION_RE); + }); +}); diff --git a/scripts/build-info.ts b/scripts/build-info.ts new file mode 100644 index 000000000..664e1b98f --- /dev/null +++ b/scripts/build-info.ts @@ -0,0 +1,123 @@ +import { spawnSync } from "node:child_process"; + +import pkg from "../package.json" with { type: "json" }; + +/** + * Build-time git metadata for the version badge. Run once by `bun + * scripts/build-info.ts` (wired into `build` and `build:bin` through + * `--define "$(bun scripts/build-info.ts)"`); the single stdout line is the + * define token, e.g. `process.env.CORBITS_BUILD_INFO="v0.3.36-7-g9af7e1e"`. + * + * A build must never fail for metadata: every field degrades to null/false + * and the script prints a stderr warning instead of throwing. + */ + +export interface GitInfo { + tag: string | null; + count: number | null; + hash: string | null; + dirty: boolean; +} + +export interface RunResult { + stdout: string; + exitCode: number; +} + +export function gitRunner(cwd: string, args: string[]): RunResult { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + return { + stdout: typeof res.stdout === "string" ? res.stdout.trim() : "", + exitCode: res.status ?? 1, + }; +} + +function safeRun( + run: (args: string[]) => RunResult, + args: string[], +): RunResult | null { + try { + return run(args); + } catch (err) { + console.error(`[build-info] git ${args.join(" ")} failed: ${String(err)}`); + return null; + } +} + +/** + * Gathers git metadata against `cwd`. Not a git repo (or git missing) yields + * `{ tag: null, count: null, hash: null, dirty: false }`; individual + * failures degrade just their own field. + */ +export function collectGitInfo( + cwd: string, + run: (args: string[]) => RunResult = (args) => gitRunner(cwd, args), +): GitInfo { + const inside = safeRun(run, ["rev-parse", "--is-inside-work-tree"]); + if (inside === null || inside.stdout !== "true") { + return { tag: null, count: null, hash: null, dirty: false }; + } + + const tagResult = safeRun(run, [ + "describe", + "--tags", + "--match", + "v*", + "--abbrev=0", + ]); + const tag = + tagResult !== null && tagResult.stdout !== "" ? tagResult.stdout : null; + + let count: number | null = null; + if (tag !== null) { + const countResult = safeRun(run, ["rev-list", "--count", `${tag}..HEAD`]); + if (countResult !== null) { + const parsed = Number.parseInt(countResult.stdout, 10); + count = Number.isNaN(parsed) ? null : parsed; + } + } + + const hashResult = safeRun(run, ["rev-parse", "--short=7", "HEAD"]); + const hash = + hashResult !== null && hashResult.stdout !== "" ? hashResult.stdout : null; + + const diffResult = safeRun(run, ["diff-index", "--quiet", "HEAD", "--"]); + const dirty = diffResult !== null && diffResult.exitCode === 1; + + return { tag, count, hash, dirty }; +} + +/** + * Pure composition of the display version from the six SOLUTION_SCOPE §3 + * cases: + * on tag, clean -> v0.3.36 + * on tag, dirty -> v0.3.36-dirty + * past tag -> v0.3.36-7-g9af7e1e (+ -dirty) + * no tags -> v0.3.36+g9af7e1e (+ -dirty) + * metadata unavailable -> v0.3.36 + */ +export function composeDisplayVersion(version: string, info: GitInfo): string { + const base = `v${version}`; + const dirty = info.dirty ? "-dirty" : ""; + if (info.tag !== null && info.count === 0) { + return `${base}${dirty}`; + } + if ( + info.tag !== null && + info.count !== null && + info.count > 0 && + info.hash !== null + ) { + return `${base}-${info.count}-g${info.hash}${dirty}`; + } + if (info.tag === null && info.hash !== null) { + return `${base}+g${info.hash}${dirty}`; + } + return base; +} + +if (import.meta.main) { + const version = typeof pkg.version === "string" ? pkg.version : "0.0.0"; + const display = composeDisplayVersion(version, collectGitInfo(process.cwd())); + console.log(`process.env.CORBITS_BUILD_INFO=${JSON.stringify(display)}`); +} diff --git a/scripts/release.sh b/scripts/release.sh index 8498d187e..ff517c528 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -138,7 +138,11 @@ host_label() { # Do NOT --external react-devtools-core: standalone binaries have no # node_modules; leaving it external makes first TUI load fail with # "Cannot find package 'react-devtools-core'". -# Keep this block in sync with package.json "build:bin" when those flags change. +# Keep this block in sync with package.json "build:bin" when those flags change, +# with one deliberate exception: release builds omit build:bin's +# `--define "$(bun scripts/build-info.ts)"`. They compile before the release +# tag exists, so the stamp would read as an off-tag dev build; without it the +# binary shows the plain package version. # # macOS targets are ad-hoc re-signed after compile. `bun build --compile` # appends the JS payload to Bun's linker-signed executable without re-signing diff --git a/src/config/index.ts b/src/config/index.ts index 2f0f15717..bbe63150c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,7 +9,7 @@ import { } from "../session/index.js"; import { loadState } from "../session/state.js"; import { COMMAND_NAME } from "../branding.js"; -import pkg from "../../package.json" with { type: "json" }; +import { DISPLAY_VERSION } from "../version.js"; import { isDirectorId } from "../agent/directors/registry.js"; import { DIRECTOR_IDS, type DirectorId } from "../agent/directors/types.js"; @@ -756,8 +756,8 @@ export class CliHelpError extends Error { } } -/** Printed for `corbits --version` / `-V`. Matches `package.json` version. */ -export const CLI_VERSION_TEXT = `${COMMAND_NAME} v${pkg.version}`; +/** Printed for `corbits --version` / `-V`. Matches the display version (`src/version.ts`). */ +export const CLI_VERSION_TEXT = `${COMMAND_NAME} ${DISPLAY_VERSION}`; /** * Thrown when the operator asked for the CLI version. Entry points must print diff --git a/src/tui/landing.ts b/src/tui/landing.ts index cdcdebd13..6b8f10a8a 100644 --- a/src/tui/landing.ts +++ b/src/tui/landing.ts @@ -12,7 +12,6 @@ import { type TextChunk, } from "@opentui/core"; import { BoxRenderable, TextRenderable } from "@opentui/core"; -import pkg from "../../package.json" with { type: "json" }; import { MARK_LARGE, @@ -34,9 +33,18 @@ const MARK_GAP_ROWS = 1; /** Columns of air between the mark's right edge and the hint block. */ export const LANDING_HERO_GAP = 3; -/** The running build, read from `package.json` so it cannot drift; - * rendered in the persistent chrome, not here. */ -export const LANDING_VERSION = `v${pkg.version}`; +/** + * The running build, read from the build-time injected version (`src/version.ts`) + * so it cannot drift from what shipped. Rendered in the shell's persistent + * chrome (bottom-right of the terminal), not as part of this module's landing + * composition — see `versionBadgeVisible` and `shell.ts`'s `versionBadge`. + * + * A live re-export of `DISPLAY_VERSION`, not a module-eval snapshot: the + * shell reads the current value when it builds the badge, so tests can + * re-mock `version.ts` in-process and the badge picks up the injected value + * without spawning a child process to re-evaluate the module graph. + */ +export { DISPLAY_VERSION as LANDING_VERSION } from "../version.js"; /** Minimum size for the version badge: below it, the badge hides before * the transcript is squeezed. */ diff --git a/src/tui/version-badge.test.ts b/src/tui/version-badge.test.ts new file mode 100644 index 000000000..7cd22f54e --- /dev/null +++ b/src/tui/version-badge.test.ts @@ -0,0 +1,89 @@ +/** + * Version badge with an off-tag build identity: the long display version must + * render right-aligned and unclipped at the 60-column floor, and the + * visibility gate must still hide the whole badge below it. + * + * `LANDING_VERSION` is a live re-export of `DISPLAY_VERSION` (see landing.ts), + * so mocking `version.js` at file scope reaches both `LANDING_VERSION` and the + * shell's version row even though those modules may already be evaluated with + * the plain dev fallback by the time this file runs — the value is read at + * access time, not captured at module-evaluation time. + */ +import { describe, expect, test } from "bun:test"; + +import { defined } from "../../testkit/defined.js"; +import { withMockedModule } from "../../testkit/mock-module.js"; + +const LONG_DISPLAY_VERSION = "v0.3.36-7-g9af7e1e3"; + +await withMockedModule( + import.meta.resolve("../version.js"), + (real: typeof import("../version.js")) => ({ + ...real, + DISPLAY_VERSION: LONG_DISPLAY_VERSION, + }), +); + +const { withAppShell } = await import("./test-helpers.js"); +const { + LANDING_VERSION, + VERSION_BADGE_MIN_COLUMNS, + VERSION_BADGE_MIN_ROWS, + versionBadgeVisible, +} = await import("./landing.js"); + +describe("version badge with an off-tag build identity", () => { + test("LANDING_VERSION carries the long build identity", () => { + expect(LANDING_VERSION).toBe(LONG_DISPLAY_VERSION); + }); + + test("the badge renders right-aligned and unclipped at 60 columns", async () => { + const size = { + width: VERSION_BADGE_MIN_COLUMNS, + height: VERSION_BADGE_MIN_ROWS, + }; + await withAppShell( + async (_shell, h) => { + await h.renderOnce(); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain(LONG_DISPLAY_VERSION); + + const painted = frame.split("\n"); + const versionRow = painted.findIndex((row) => + row.includes(LONG_DISPLAY_VERSION), + ); + expect(versionRow).toBeGreaterThanOrEqual(0); + // Unclipped: the badge sits on a real terminal row, not past it. + expect(versionRow).toBeLessThan(size.height); + // Right-aligned: the badge's right edge hugs the terminal's right + // edge (the existing landing assertion uses the same slack). + const versionCol = defined(painted[versionRow]).lastIndexOf( + LONG_DISPLAY_VERSION, + ); + expect(versionCol + LONG_DISPLAY_VERSION.length).toBeGreaterThan( + size.width - 4, + ); + }, + { ...size, shell: { run: "idle" } }, + ); + }); + + test("versionBadgeVisible still hides the badge below 60 columns", () => { + expect( + versionBadgeVisible( + VERSION_BADGE_MIN_COLUMNS - 1, + VERSION_BADGE_MIN_ROWS, + ), + ).toBe(false); + expect( + versionBadgeVisible(VERSION_BADGE_MIN_COLUMNS, VERSION_BADGE_MIN_ROWS), + ).toBe(true); + expect( + versionBadgeVisible( + VERSION_BADGE_MIN_COLUMNS, + VERSION_BADGE_MIN_ROWS - 1, + ), + ).toBe(false); + }); +}); diff --git a/src/version.test.ts b/src/version.test.ts new file mode 100644 index 000000000..bba76e3a7 --- /dev/null +++ b/src/version.test.ts @@ -0,0 +1,55 @@ +/** + * DISPLAY_VERSION resolution: the build-time injected `CORBITS_BUILD_INFO` + * (bun build --define) wins when present, and plain `v` is the + * dev/test fallback when it is absent. + * + * `version.ts` reads the env var at module-evaluation time, so each case + * imports it with a distinct cache-busting query: Bun keys the module + * registry by resolved URL, so `?injected` re-evaluates the module with the + * env var set even if `version.ts` was already evaluated in this process. + */ +import { describe, expect, test } from "bun:test"; + +import pkg from "../package.json" with { type: "json" }; + +const INJECTED_BUILD_INFO = "corbits 0.3.36-7-g9af7e1e3"; + +/** + * Import `version.ts` under a cache-busting query so the module re-evaluates + * with whatever `process.env.CORBITS_BUILD_INFO` is set to right now, even if + * it was already evaluated earlier in this process. Cast through the plain + * module type because the query-suffixed specifier is not a resolvable path. + */ +async function importVersion( + scope: string, +): Promise { + return (await import( + `./version.ts?${scope}` + )) as typeof import("./version.ts"); +} + +describe("DISPLAY_VERSION", () => { + test("resolves to the injected CORBITS_BUILD_INFO when set", async () => { + const prior = process.env.CORBITS_BUILD_INFO; + process.env.CORBITS_BUILD_INFO = INJECTED_BUILD_INFO; + try { + const { DISPLAY_VERSION } = await importVersion("injected=1"); + expect(DISPLAY_VERSION).toBe(INJECTED_BUILD_INFO); + } finally { + if (prior === undefined) delete process.env.CORBITS_BUILD_INFO; + else process.env.CORBITS_BUILD_INFO = prior; + } + }); + + test("falls back to `v${pkg.version}` when CORBITS_BUILD_INFO is unset", async () => { + const prior = process.env.CORBITS_BUILD_INFO; + delete process.env.CORBITS_BUILD_INFO; + try { + const { DISPLAY_VERSION } = await importVersion("fallback=1"); + expect(DISPLAY_VERSION).toBe(`v${pkg.version}`); + } finally { + if (prior === undefined) delete process.env.CORBITS_BUILD_INFO; + else process.env.CORBITS_BUILD_INFO = prior; + } + }); +}); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 000000000..90ebbf06c --- /dev/null +++ b/src/version.ts @@ -0,0 +1,8 @@ +import pkg from "../package.json" with { type: "json" }; + +// Injected by `bun build --define process.env.CORBITS_BUILD_INFO='...'` in both +// build and build:bin. Absent in dev/test runs -> plain version fallback. +const BUILD_INFO: string = process.env.CORBITS_BUILD_INFO ?? ""; + +export const VERSION_PREFIX = `v${typeof pkg.version === "string" ? pkg.version : "0.0.0"}`; +export const DISPLAY_VERSION = BUILD_INFO !== "" ? BUILD_INFO : VERSION_PREFIX;