From bfcb8d378ae6cc9b124d9ff23302da18dd418a9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:06:46 +0000 Subject: [PATCH] fix(create-objectstack): startup banner reads its own version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm create objectstack@latest` greeted a newcomer with a hardcoded `◆ Create ObjectStack v6.x` — eleven majors stale, on the first line of output anyone ever sees. `readCliVersion()` already resolves the real, published version from package.json (`.version()` on the commander program already uses it); the banner just never called it. The naive fix — dropping the real version into the old literal string — would have reintroduced the exact defect class this card exists to close: the box's right border is a fixed run of `═` computed for the 4-character `v6.x`, and a longer real version (`v17.1.0` is 7 characters) pushes the border out of alignment without recomputing the trailing pad (the sibling bug fixed one function away in the same file). `renderVersionBanner()` (new banner.ts, split out so it is unit-testable without importing index.ts, which calls `program.parse()` at module scope) derives the box width from the version string's PLAIN length and widens the frame — never truncates — for a version long enough to need more room; ordinary versions still render at the historical 39-column box size. Pinned two properties separately so neither can go vacuous: the banner names the version package.json actually declares (read at test time, not a literal), and the three box lines still render to equal display width with aligned borders, computed from ANSI-stripped plain text. Fixes #10325 --- .changeset/banner-reads-own-version.md | 20 +++ .../src/banner-version.test.ts | 147 ++++++++++++++++++ packages/create-objectstack/src/banner.ts | 67 ++++++++ packages/create-objectstack/src/index.ts | 5 +- 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 .changeset/banner-reads-own-version.md create mode 100644 packages/create-objectstack/src/banner-version.test.ts create mode 100644 packages/create-objectstack/src/banner.ts diff --git a/.changeset/banner-reads-own-version.md b/.changeset/banner-reads-own-version.md new file mode 100644 index 0000000000..9f52c35b2c --- /dev/null +++ b/.changeset/banner-reads-own-version.md @@ -0,0 +1,20 @@ +--- +"create-objectstack": patch +--- + +Fix `create-objectstack`'s startup banner hardcoding `◆ Create ObjectStack v6.x` +regardless of the package's real, released version — eleven majors stale, on +the first line of output a newcomer ever sees (#10325). The banner now calls +`readCliVersion()`, the same reader `.version()` already used, instead of a +literal string. + +Dropping the real version in without recomputing the box's padding would have +reintroduced the same defect one line later — the border is a fixed run of +`═` computed for the 4-character `v6.x`, and a longer real version (`v17.1.0` +is 7 characters) would push the right border out of alignment (the sibling +bug fixed in #10322, one function away in the same file). The box now derives +its width from the version string's plain length and widens the frame — never +truncates — for a version long enough to need more room; ordinary versions +still render at the historical box size. + +No behaviour change beyond the printed banner. diff --git a/packages/create-objectstack/src/banner-version.test.ts b/packages/create-objectstack/src/banner-version.test.ts new file mode 100644 index 0000000000..5f248c039f --- /dev/null +++ b/packages/create-objectstack/src/banner-version.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Pins #10325: the startup banner (`◆ Create ObjectStack …`) names the +// version `create-objectstack`'s own package.json actually declares, not a +// hardcoded literal — the banner had said `v6.x` for eleven majors, the +// first line of output a newcomer ever sees. +// +// Two distinct properties, pinned separately so neither can go vacuous: +// +// 1. The banner's version text matches package.json's real `version` +// field, read at test time (never a copy-pasted literal here — a test +// that hardcoded "17.1.0" would itself go stale the next time this +// package bumps, the same failure mode the card exists to close). +// 2. The three box lines still render to EQUAL display width with the +// borders aligned, computed from PLAIN, ANSI-stripped text — a test +// that only greps for the version string would still pass with the +// right border pushed out of alignment (the #10322 defect class, one +// function away in the same file: a box hand-kerned for one string +// length, broken by a longer one). +// +// `renderVersionBanner` lives in banner.ts specifically so it can be unit +// tested directly with synthetic version strings (including a long +// prerelease, to exercise the box-widening path) without spawning a +// subprocess. `index.ts` itself calls `program.parse()` at module scope (see +// the comment above `rewriteProjectIdentity`), so the *wiring* — that the +// real CLI actually calls this function with the real declared version — is +// covered separately below via `tsx`, the same no-build subprocess pattern +// `scaffold-description.test.ts` and `scaffold-next-steps-pm.test.ts` use. + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { renderVersionBanner } from './banner.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(HERE, '..'); +const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..'); +const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx'); +const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts'); + +// Built via fromCharCode rather than a literal escape in source, so nothing +// here can be silently re-materialized into a raw control byte on disk. +const ESC = String.fromCharCode(27); +/** Strip SGR color codes so a chalk-styled line measures the same as plain text. */ +const stripAnsi = (s: string): string => s.replace(new RegExp(ESC + '\\[[0-9;]*m', 'g'), ''); + +/** Assert the three banner lines render to equal PLAIN width with aligned borders. */ +function expectAlignedBox(lines: string[]): void { + expect(lines).toHaveLength(3); + const plain = lines.map(stripAnsi); + const widths = plain.map((l) => [...l].length); + expect(widths[1]).toBe(widths[0]); + expect(widths[2]).toBe(widths[0]); + // Borders: '╔'/'║'/'╚' open the line, '╗'/'║'/'╝' close it — verifying + // this (rather than just equal length) catches a padding bug that drops + // characters from the middle while coincidentally preserving total width. + expect(plain[0].endsWith('╗')).toBe(true); + expect(plain[1].endsWith('║')).toBe(true); + expect(plain[2].endsWith('╝')).toBe(true); +} + +describe('renderVersionBanner (#10325)', () => { + it('renders an aligned box for an ordinary semver', () => { + const lines = renderVersionBanner('17.1.0'); + expectAlignedBox(lines); + expect(stripAnsi(lines[1])).toContain('v17.1.0'); + }); + + it('widens the frame — never truncates — for a version longer than the historical width', () => { + const long = '18.0.0-beta.1+build.20260822'; + const lines = renderVersionBanner(long); + expectAlignedBox(lines); + // The full version string survives intact (not clipped) inside the wider box. + expect(stripAnsi(lines[1])).toContain(`v${long}`); + }); + + it('renders the same historical box width for a version no longer than the old placeholder budgeted for', () => { + // "6.x" (3 chars) is one shorter than "17.1.0" (6 chars) but both sit + // under the original hand-kerned budget — the box size should be + // unchanged from before this fix for either. + const lines = renderVersionBanner('6.x'); + const plainTop = stripAnsi(lines[0]); + expect([...plainTop].length).toBe(39); // ' ╔' + 35 '═' + '╗', unchanged from the pre-fix literal + }); + + it('never renders the stale hardcoded placeholder', () => { + const lines = renderVersionBanner('17.1.0').map(stripAnsi).join('\n'); + expect(lines).not.toContain('v6.x'); + }); +}); + +describe('the real CLI banner (#10325, wiring)', () => { + it('names the version create-objectstack\'s own package.json actually declares', () => { + const declaredVersion = String( + JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version, + ); + // Sanity: prove this is a real assertion, not one that would pass no + // matter what package.json said. + expect(declaredVersion).toMatch(/^\d+\.\d+\.\d+/); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-banner-')); + let stdout: string; + try { + stdout = execFileSync( + TSX, + [INDEX_TS, 'my-app', '--template', 'blank', '--skip-install', '--skip-skills'], + { + cwd: tmp, + encoding: 'utf8', + // Chalk decides its color level once, at import, from the process's + // own env/TTY state — this run's vitest process itself sees no TTY, + // so without forcing it here the child would render plain text and + // the ANSI-stripping below would be exercised against a no-op, + // leaving the "measure plain, not styled" requirement unverified. + // FORCE_COLOR set on a *fresh child process* is read at that + // process's own chalk import, unlike mutating it after the fact in + // an already-running process (which chalk ignores). + env: { ...process.env, FORCE_COLOR: '1' }, + }, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + + // Sanity: the real ANSI codes are actually present here — otherwise the + // stripAnsi() calls below would be passing through already-plain text + // and this test would not be verifying the "measure plain, never + // styled" property it exists to pin. + expect(stdout).toContain(ESC + '['); + + const plain = stripAnsi(stdout); + expect(plain).toContain(`◆ Create ObjectStack v${declaredVersion}`); + expect(plain).not.toContain('v6.x'); + + // The three banner lines specifically (not the whole run's output) must + // still be an aligned box in the real, wired-up output — not just in the + // isolated unit tests above. + const bannerLines = plain + .split('\n') + .filter((l) => l.includes('╔═') || l.includes('◆ Create ObjectStack') || l.includes('╚═')); + expectAlignedBox(bannerLines); + }, 20_000); +}); diff --git a/packages/create-objectstack/src/banner.ts b/packages/create-objectstack/src/banner.ts new file mode 100644 index 0000000000..ea36b436ef --- /dev/null +++ b/packages/create-objectstack/src/banner.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. + +/** + * The CLI startup banner — the fixed-style box printed as the very first + * output a scaffold run produces. Split out of index.ts (which calls + * `program.parse()` at module scope and so cannot be imported directly by + * tests — see the comment above `rewriteProjectIdentity`) purely so the + * padding math has somewhere to be unit-tested without spawning a subprocess. + * + * #10325: the banner used to hardcode `v6.x` — eleven majors stale — rather + * than reading the version it already had a working reader for + * (`readCliVersion()` in index.ts, already used by `.version()`). The naive + * fix of dropping the real version string into the old literal would have + * reintroduced the same defect class one line later: the box's borders are a + * fixed run of `═` computed for a 4-character `v6.x`, and `v17.1.0` (7 chars) + * would push the right border out of alignment without recomputing the pad + * (the sibling bug in #10322, one function away in the same file — a box + * hand-kerned for `npm` broken by the one-character-longer `pnpm`). + */ + +import chalk from 'chalk'; + +const PREFIX = ' ◆ Create ObjectStack '; + +// Historical interior width: the original hardcoded line was 35 columns +// between the borders (` ◆ Create ObjectStack ` + `v6.x` + 7 trailing +// spaces). Kept as a floor so ordinary version strings (`17.1.0`, `17.10.0`, +// …) still render the familiar box size unchanged; only a version long +// enough to need more room (e.g. a prerelease like `18.0.0-beta.1`) widens +// the frame. +const MIN_INNER_WIDTH = 35; + +// Minimum breathing room between the version and the right border, so a +// version exactly at the width floor never has the border hugging the text. +const MIN_TRAILING_PAD = 3; + +/** + * Render the three lines of the startup banner for the given (unstyled) + * `version` string (no leading `v` — this function adds it, matching the + * banner's existing display convention; `readCliVersion()` in index.ts + * returns the bare `package.json` version). The box WIDENS to fit a version + * too long for the historical width rather than truncating it or letting the + * trailing pad go negative — a truncated version number would be actively + * misleading in the one place a newcomer looks to confirm what they got. + * + * Width math is always done on the PLAIN prefix/version strings — chalk's + * ANSI escape codes are layered on only in the returned lines, never counted + * (measuring a chalk-wrapped string would silently corrupt this arithmetic). + */ +export function renderVersionBanner(version: string): string[] { + const versionLabel = `v${version}`; + const innerWidth = Math.max( + MIN_INNER_WIDTH, + PREFIX.length + versionLabel.length + MIN_TRAILING_PAD, + ); + const trailingPad = innerWidth - PREFIX.length - versionLabel.length; + const border = '═'.repeat(innerWidth); + + return [ + chalk.bold.cyan(` ╔${border}╗`), + chalk.bold.cyan(' ║') + + chalk.bold(PREFIX) + + chalk.dim(versionLabel) + + chalk.bold.cyan(`${' '.repeat(trailingPad)}║`), + chalk.bold.cyan(` ╚${border}╝`), + ]; +} diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index 1255748dfc..5611c26894 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -71,6 +71,7 @@ import { import { lookupTemplate, templateNames } from './template-registry.js'; import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js'; import { summarizeTree, describeEntry } from './created-summary.js'; +import { renderVersionBanner } from './banner.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -398,9 +399,7 @@ const program = new Command() options: { template: string; skipInstall?: boolean; skipSkills?: boolean }, ) => { console.log(''); - console.log(chalk.bold.cyan(' ╔═══════════════════════════════════╗')); - console.log(chalk.bold.cyan(' ║') + chalk.bold(' ◆ Create ObjectStack ') + chalk.dim('v6.x') + chalk.bold.cyan(' ║')); - console.log(chalk.bold.cyan(' ╚═══════════════════════════════════╝')); + for (const line of renderVersionBanner(readCliVersion())) console.log(line); printHeader('New Environment');