Skip to content

Commit cec9d23

Browse files
Elon Muskclaude
andauthored
fix(create-objectstack): startup banner reads its own version (#11030)
`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 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 15b63e8 commit cec9d23

4 files changed

Lines changed: 236 additions & 3 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"create-objectstack": patch
3+
---
4+
5+
Fix `create-objectstack`'s startup banner hardcoding `◆ Create ObjectStack v6.x`
6+
regardless of the package's real, released version — eleven majors stale, on
7+
the first line of output a newcomer ever sees (#10325). The banner now calls
8+
`readCliVersion()`, the same reader `.version()` already used, instead of a
9+
literal string.
10+
11+
Dropping the real version in without recomputing the box's padding would have
12+
reintroduced the same defect one line later — the border is a fixed run of
13+
`` computed for the 4-character `v6.x`, and a longer real version (`v17.1.0`
14+
is 7 characters) would push the right border out of alignment (the sibling
15+
bug fixed in #10322, one function away in the same file). The box now derives
16+
its width from the version string's plain length and widens the frame — never
17+
truncates — for a version long enough to need more room; ordinary versions
18+
still render at the historical box size.
19+
20+
No behaviour change beyond the printed banner.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// Pins #10325: the startup banner (`◆ Create ObjectStack …`) names the
4+
// version `create-objectstack`'s own package.json actually declares, not a
5+
// hardcoded literal — the banner had said `v6.x` for eleven majors, the
6+
// first line of output a newcomer ever sees.
7+
//
8+
// Two distinct properties, pinned separately so neither can go vacuous:
9+
//
10+
// 1. The banner's version text matches package.json's real `version`
11+
// field, read at test time (never a copy-pasted literal here — a test
12+
// that hardcoded "17.1.0" would itself go stale the next time this
13+
// package bumps, the same failure mode the card exists to close).
14+
// 2. The three box lines still render to EQUAL display width with the
15+
// borders aligned, computed from PLAIN, ANSI-stripped text — a test
16+
// that only greps for the version string would still pass with the
17+
// right border pushed out of alignment (the #10322 defect class, one
18+
// function away in the same file: a box hand-kerned for one string
19+
// length, broken by a longer one).
20+
//
21+
// `renderVersionBanner` lives in banner.ts specifically so it can be unit
22+
// tested directly with synthetic version strings (including a long
23+
// prerelease, to exercise the box-widening path) without spawning a
24+
// subprocess. `index.ts` itself calls `program.parse()` at module scope (see
25+
// the comment above `rewriteProjectIdentity`), so the *wiring* — that the
26+
// real CLI actually calls this function with the real declared version — is
27+
// covered separately below via `tsx`, the same no-build subprocess pattern
28+
// `scaffold-description.test.ts` and `scaffold-next-steps-pm.test.ts` use.
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { execFileSync } from 'node:child_process';
32+
import fs from 'node:fs';
33+
import os from 'node:os';
34+
import path from 'node:path';
35+
import { fileURLToPath } from 'node:url';
36+
37+
import { renderVersionBanner } from './banner.js';
38+
39+
const HERE = path.dirname(fileURLToPath(import.meta.url));
40+
const PKG_ROOT = path.resolve(HERE, '..');
41+
const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..');
42+
const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
43+
const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts');
44+
45+
// Built via fromCharCode rather than a literal escape in source, so nothing
46+
// here can be silently re-materialized into a raw control byte on disk.
47+
const ESC = String.fromCharCode(27);
48+
/** Strip SGR color codes so a chalk-styled line measures the same as plain text. */
49+
const stripAnsi = (s: string): string => s.replace(new RegExp(ESC + '\\[[0-9;]*m', 'g'), '');
50+
51+
/** Assert the three banner lines render to equal PLAIN width with aligned borders. */
52+
function expectAlignedBox(lines: string[]): void {
53+
expect(lines).toHaveLength(3);
54+
const plain = lines.map(stripAnsi);
55+
const widths = plain.map((l) => [...l].length);
56+
expect(widths[1]).toBe(widths[0]);
57+
expect(widths[2]).toBe(widths[0]);
58+
// Borders: '╔'/'║'/'╚' open the line, '╗'/'║'/'╝' close it — verifying
59+
// this (rather than just equal length) catches a padding bug that drops
60+
// characters from the middle while coincidentally preserving total width.
61+
expect(plain[0].endsWith('╗')).toBe(true);
62+
expect(plain[1].endsWith('║')).toBe(true);
63+
expect(plain[2].endsWith('╝')).toBe(true);
64+
}
65+
66+
describe('renderVersionBanner (#10325)', () => {
67+
it('renders an aligned box for an ordinary semver', () => {
68+
const lines = renderVersionBanner('17.1.0');
69+
expectAlignedBox(lines);
70+
expect(stripAnsi(lines[1])).toContain('v17.1.0');
71+
});
72+
73+
it('widens the frame — never truncates — for a version longer than the historical width', () => {
74+
const long = '18.0.0-beta.1+build.20260822';
75+
const lines = renderVersionBanner(long);
76+
expectAlignedBox(lines);
77+
// The full version string survives intact (not clipped) inside the wider box.
78+
expect(stripAnsi(lines[1])).toContain(`v${long}`);
79+
});
80+
81+
it('renders the same historical box width for a version no longer than the old placeholder budgeted for', () => {
82+
// "6.x" (3 chars) is one shorter than "17.1.0" (6 chars) but both sit
83+
// under the original hand-kerned budget — the box size should be
84+
// unchanged from before this fix for either.
85+
const lines = renderVersionBanner('6.x');
86+
const plainTop = stripAnsi(lines[0]);
87+
expect([...plainTop].length).toBe(39); // ' ╔' + 35 '═' + '╗', unchanged from the pre-fix literal
88+
});
89+
90+
it('never renders the stale hardcoded placeholder', () => {
91+
const lines = renderVersionBanner('17.1.0').map(stripAnsi).join('\n');
92+
expect(lines).not.toContain('v6.x');
93+
});
94+
});
95+
96+
describe('the real CLI banner (#10325, wiring)', () => {
97+
it('names the version create-objectstack\'s own package.json actually declares', () => {
98+
const declaredVersion = String(
99+
JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version,
100+
);
101+
// Sanity: prove this is a real assertion, not one that would pass no
102+
// matter what package.json said.
103+
expect(declaredVersion).toMatch(/^\d+\.\d+\.\d+/);
104+
105+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-banner-'));
106+
let stdout: string;
107+
try {
108+
stdout = execFileSync(
109+
TSX,
110+
[INDEX_TS, 'my-app', '--template', 'blank', '--skip-install', '--skip-skills'],
111+
{
112+
cwd: tmp,
113+
encoding: 'utf8',
114+
// Chalk decides its color level once, at import, from the process's
115+
// own env/TTY state — this run's vitest process itself sees no TTY,
116+
// so without forcing it here the child would render plain text and
117+
// the ANSI-stripping below would be exercised against a no-op,
118+
// leaving the "measure plain, not styled" requirement unverified.
119+
// FORCE_COLOR set on a *fresh child process* is read at that
120+
// process's own chalk import, unlike mutating it after the fact in
121+
// an already-running process (which chalk ignores).
122+
env: { ...process.env, FORCE_COLOR: '1' },
123+
},
124+
);
125+
} finally {
126+
fs.rmSync(tmp, { recursive: true, force: true });
127+
}
128+
129+
// Sanity: the real ANSI codes are actually present here — otherwise the
130+
// stripAnsi() calls below would be passing through already-plain text
131+
// and this test would not be verifying the "measure plain, never
132+
// styled" property it exists to pin.
133+
expect(stdout).toContain(ESC + '[');
134+
135+
const plain = stripAnsi(stdout);
136+
expect(plain).toContain(`◆ Create ObjectStack v${declaredVersion}`);
137+
expect(plain).not.toContain('v6.x');
138+
139+
// The three banner lines specifically (not the whole run's output) must
140+
// still be an aligned box in the real, wired-up output — not just in the
141+
// isolated unit tests above.
142+
const bannerLines = plain
143+
.split('\n')
144+
.filter((l) => l.includes('╔═') || l.includes('◆ Create ObjectStack') || l.includes('╚═'));
145+
expectAlignedBox(bannerLines);
146+
}, 20_000);
147+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
3+
/**
4+
* The CLI startup banner — the fixed-style box printed as the very first
5+
* output a scaffold run produces. Split out of index.ts (which calls
6+
* `program.parse()` at module scope and so cannot be imported directly by
7+
* tests — see the comment above `rewriteProjectIdentity`) purely so the
8+
* padding math has somewhere to be unit-tested without spawning a subprocess.
9+
*
10+
* #10325: the banner used to hardcode `v6.x` — eleven majors stale — rather
11+
* than reading the version it already had a working reader for
12+
* (`readCliVersion()` in index.ts, already used by `.version()`). The naive
13+
* fix of dropping the real version string into the old literal would have
14+
* reintroduced the same defect class one line later: the box's borders are a
15+
* fixed run of `═` computed for a 4-character `v6.x`, and `v17.1.0` (7 chars)
16+
* would push the right border out of alignment without recomputing the pad
17+
* (the sibling bug in #10322, one function away in the same file — a box
18+
* hand-kerned for `npm` broken by the one-character-longer `pnpm`).
19+
*/
20+
21+
import chalk from 'chalk';
22+
23+
const PREFIX = ' ◆ Create ObjectStack ';
24+
25+
// Historical interior width: the original hardcoded line was 35 columns
26+
// between the borders (` ◆ Create ObjectStack ` + `v6.x` + 7 trailing
27+
// spaces). Kept as a floor so ordinary version strings (`17.1.0`, `17.10.0`,
28+
// …) still render the familiar box size unchanged; only a version long
29+
// enough to need more room (e.g. a prerelease like `18.0.0-beta.1`) widens
30+
// the frame.
31+
const MIN_INNER_WIDTH = 35;
32+
33+
// Minimum breathing room between the version and the right border, so a
34+
// version exactly at the width floor never has the border hugging the text.
35+
const MIN_TRAILING_PAD = 3;
36+
37+
/**
38+
* Render the three lines of the startup banner for the given (unstyled)
39+
* `version` string (no leading `v` — this function adds it, matching the
40+
* banner's existing display convention; `readCliVersion()` in index.ts
41+
* returns the bare `package.json` version). The box WIDENS to fit a version
42+
* too long for the historical width rather than truncating it or letting the
43+
* trailing pad go negative — a truncated version number would be actively
44+
* misleading in the one place a newcomer looks to confirm what they got.
45+
*
46+
* Width math is always done on the PLAIN prefix/version strings — chalk's
47+
* ANSI escape codes are layered on only in the returned lines, never counted
48+
* (measuring a chalk-wrapped string would silently corrupt this arithmetic).
49+
*/
50+
export function renderVersionBanner(version: string): string[] {
51+
const versionLabel = `v${version}`;
52+
const innerWidth = Math.max(
53+
MIN_INNER_WIDTH,
54+
PREFIX.length + versionLabel.length + MIN_TRAILING_PAD,
55+
);
56+
const trailingPad = innerWidth - PREFIX.length - versionLabel.length;
57+
const border = '═'.repeat(innerWidth);
58+
59+
return [
60+
chalk.bold.cyan(` ╔${border}╗`),
61+
chalk.bold.cyan(' ║') +
62+
chalk.bold(PREFIX) +
63+
chalk.dim(versionLabel) +
64+
chalk.bold.cyan(`${' '.repeat(trailingPad)}║`),
65+
chalk.bold.cyan(` ╚${border}╝`),
66+
];
67+
}

packages/create-objectstack/src/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
import { lookupTemplate, templateNames } from './template-registry.js';
7272
import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js';
7373
import { summarizeTree, describeEntry } from './created-summary.js';
74+
import { renderVersionBanner } from './banner.js';
7475

7576
const __filename = fileURLToPath(import.meta.url);
7677
const __dirname = path.dirname(__filename);
@@ -398,9 +399,7 @@ const program = new Command()
398399
options: { template: string; skipInstall?: boolean; skipSkills?: boolean },
399400
) => {
400401
console.log('');
401-
console.log(chalk.bold.cyan(' ╔═══════════════════════════════════╗'));
402-
console.log(chalk.bold.cyan(' ║') + chalk.bold(' ◆ Create ObjectStack ') + chalk.dim('v6.x') + chalk.bold.cyan(' ║'));
403-
console.log(chalk.bold.cyan(' ╚═══════════════════════════════════╝'));
402+
for (const line of renderVersionBanner(readCliVersion())) console.log(line);
404403

405404
printHeader('New Environment');
406405

0 commit comments

Comments
 (0)