Skip to content

Commit c77a8f5

Browse files
os-zhuangclaude
andauthored
fix(cli): guard against @objectstack/console objectui-SHA drift (#2507)
packages/console/dist is a gitignored, locally-built artifact that only scripts/build-console.sh produces — `turbo run build` never rebuilds it. So pulling a branch that bumps the committed .objectui-sha pin left a stale dist in place, and the CLI silently served a Console built from a different objectui commit (the npm-major version guard can't see a SHA move under a single @objectstack/console version). - build-console.sh stamps the built SHA into dist/.objectui-sha - new scripts/check-console-sha.mjs compares stamp vs pin; hard-gates pnpm dev / dev:crm / dev:todo, and is self-tested in showcase-smoke CI - resolveConsolePath warns at serve time on a drifted dist (all boot paths) - remediation is `pnpm objectui:build`; published installs ship no pin so the guard is a no-op there Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b827e74 commit c77a8f5

7 files changed

Lines changed: 241 additions & 6 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
Guard against `@objectstack/console` version drift. The vendored Console SPA in `packages/console/dist` is a gitignored, locally-built artifact that only `scripts/build-console.sh` (`pnpm objectui:build` / `objectui:refresh` / `release` / CI) produces — `turbo run build` never rebuilds it. Pulling a branch that bumps the committed `.objectui-sha` pin therefore left a stale dist in place, and the CLI would silently serve a Console built from a different objectui commit (the npm-major version guard can't see a SHA move under one package version).
6+
7+
`build-console.sh` now stamps the built objectui SHA into `dist/.objectui-sha`. A new `pnpm check:console-sha` compares that stamp against the pin and fails loudly on drift (hard-gating `pnpm dev` / `dev:crm` / `dev:todo`), and `resolveConsolePath` warns at serve time when it selects a drifted dist. Remediation is `pnpm objectui:build` (rebuild at the pinned SHA). Published installs ship no pin, so their stamped dist stays authoritative and the guard is a no-op.

.github/workflows/showcase-smoke.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ jobs:
3636
# not found" and the app shell never renders.
3737
- name: Vendor the pinned Console SPA
3838
run: bash scripts/build-console.sh
39+
# Self-test the drift guard: a fresh build-console.sh must stamp the dist
40+
# with the pinned SHA so `pnpm check:console-sha` reports in-sync. Catches
41+
# a regression where the stamp step is dropped or the pin/stamp diverge.
42+
- name: Verify Console SHA stamp matches pin
43+
run: pnpm check:console-sha
3944
- name: Install Playwright Chromium
4045
working-directory: examples/app-showcase
4146
run: pnpm exec playwright install --with-deps chromium

package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
"description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations",
66
"scripts": {
77
"build": "turbo run build --filter=!@objectstack/docs",
8-
"dev": "pnpm --filter @objectstack/example-showcase dev",
9-
"dev:showcase": "pnpm --filter @objectstack/example-showcase dev",
10-
"dev:crm": "pnpm --filter @objectstack/example-crm dev",
11-
"dev:todo": "pnpm --filter @objectstack/example-todo dev",
8+
"dev": "pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev",
9+
"dev:showcase": "pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev",
10+
"dev:crm": "pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev",
11+
"dev:todo": "pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev",
1212
"spec:rebuild": "turbo run build --filter=...@objectstack/spec",
1313
"test": "turbo run test",
1414
"test:e2e": "turbo run test:e2e",
@@ -27,7 +27,8 @@
2727
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
2828
"lint": "eslint . --no-inline-config",
2929
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
30-
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs"
30+
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs",
31+
"check:console-sha": "node scripts/check-console-sha.mjs"
3132
},
3233
"keywords": [
3334
"objectstack",

packages/cli/src/utils/console.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,13 @@ export function resolveConsolePath(options?: ResolveConsoleOptions): string | nu
240240

241241
// Pass 1: prefer a candidate that actually has a built dist.
242242
for (const dir of candidates) {
243-
if (hasConsoleDist(dir)) return dir;
243+
if (hasConsoleDist(dir)) {
244+
// Loudly flag (but still serve) a dist built from a different objectui
245+
// SHA than the framework pins — the silent-drift case the npm-major
246+
// guard above can't see. No-op for published installs / unstamped dists.
247+
warnOnConsoleShaDrift(dir, warn);
248+
return dir;
249+
}
244250
}
245251

246252
// Pass 2: nothing built yet — return the highest-priority candidate so
@@ -256,6 +262,70 @@ export function hasConsoleDist(consolePath: string): boolean {
256262
return fs.existsSync(path.join(consolePath, 'dist', 'index.html'));
257263
}
258264

265+
// ─── objectui-SHA Drift Guard (dev monorepo only) ───────────────────
266+
267+
/**
268+
* The vendored console `dist/` is a locally-built, gitignored artifact
269+
* rebuilt only by `scripts/build-console.sh` (pnpm objectui:build /
270+
* objectui:refresh / release) — never by `turbo run build`. When a
271+
* developer pulls a branch that bumps the framework's `.objectui-sha` pin
272+
* without re-running `pnpm objectui:build`, the dist stays frozen at the
273+
* previous objectui commit and would be served silently. The npm-major
274+
* version guard above can't catch this: the objectui SHA moves underneath a
275+
* single `@objectstack/console` version.
276+
*
277+
* build-console.sh stamps the built SHA into `dist/.objectui-sha`. Here we
278+
* compare it against the framework's committed `.objectui-sha` pin, located
279+
* by walking up from the resolved console package. This is inherently a
280+
* monorepo/dev concern: a published install ships no `.objectui-sha` pin
281+
* (so the stamped dist is authoritative), and the sibling-repo dev fallback
282+
* writes no stamp — both cases are skipped silently.
283+
*/
284+
function findObjectuiPin(startDir: string): { pin: string; file: string } | null {
285+
let dir = startDir;
286+
for (let depth = 0; depth < 8; depth++) {
287+
const file = path.join(dir, '.objectui-sha');
288+
if (fs.existsSync(file)) {
289+
try {
290+
const pin = fs.readFileSync(file, 'utf-8').trim();
291+
return pin ? { pin, file } : null;
292+
} catch {
293+
return null; // unreadable pin — fail open
294+
}
295+
}
296+
const parent = path.dirname(dir);
297+
if (parent === dir) break;
298+
dir = parent;
299+
}
300+
return null;
301+
}
302+
303+
export function warnOnConsoleShaDrift(
304+
consoleDir: string,
305+
warn: (message: string) => void,
306+
): void {
307+
const found = findObjectuiPin(consoleDir);
308+
if (!found) return; // published install / no monorepo pin — nothing to compare
309+
310+
const stampFile = path.join(consoleDir, 'dist', '.objectui-sha');
311+
let stamp: string | null = null;
312+
try {
313+
if (fs.existsSync(stampFile)) stamp = fs.readFileSync(stampFile, 'utf-8').trim();
314+
} catch {
315+
return; // unreadable stamp — fail open
316+
}
317+
// Unstamped dist (pre-guard build or sibling-repo fallback): can't prove
318+
// drift; `pnpm check:console-sha` surfaces it — don't nag on every boot.
319+
if (!stamp || stamp === found.pin) return;
320+
321+
warn(
322+
` ⚠ Console version drift: serving @objectstack/console built from objectui@${stamp.slice(0, 12)}, ` +
323+
`but ${found.file} pins objectui@${found.pin.slice(0, 12)}. ` +
324+
`packages/console/dist is a gitignored local build that 'turbo run build' does not refresh — ` +
325+
`rebuild it with 'pnpm objectui:build'.`,
326+
);
327+
}
328+
259329
// ─── Plugin Factory ─────────────────────────────────────────────────
260330

261331
/**

packages/cli/test/console-resolve.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createRequire } from 'module';
1818
import {
1919
resolveConsolePath,
2020
isConsoleVersionCompatible,
21+
warnOnConsoleShaDrift,
2122
} from '../src/utils/console.js';
2223

2324
// resolveConsolePath() also discovers the real, version-locked workspace
@@ -156,3 +157,66 @@ describe('resolveConsolePath version guard', () => {
156157
expect(warnings.some((m) => m.includes('unknown'))).toBe(true);
157158
});
158159
});
160+
161+
describe('warnOnConsoleShaDrift', () => {
162+
const SHA_A = '2b86379384f0f6e99d9a5bb81d73017fd6f99cef';
163+
const SHA_B = '69d6b94419bcaa11223344556677889900aabbcc';
164+
165+
/**
166+
* Lay out a monorepo-shaped tree: <root>/.objectui-sha is the pin, and
167+
* <root>/packages/console/dist is the vendored, optionally-stamped build.
168+
* Returns the console package dir (what resolveConsolePath would hand back).
169+
*/
170+
function makePinnedTree(
171+
pin: string,
172+
stamp: string | null,
173+
): { root: string; consoleDir: string } {
174+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-sha-')));
175+
fs.writeFileSync(path.join(root, '.objectui-sha'), `${pin}\n`);
176+
const consoleDir = path.join(root, 'packages', 'console');
177+
fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true });
178+
fs.writeFileSync(path.join(consoleDir, 'dist', 'index.html'), '<html></html>');
179+
if (stamp !== null) {
180+
fs.writeFileSync(path.join(consoleDir, 'dist', '.objectui-sha'), `${stamp}\n`);
181+
}
182+
return { root, consoleDir };
183+
}
184+
185+
it('warns when the dist stamp differs from the pin (the pull-without-rebuild case)', () => {
186+
const { consoleDir } = makePinnedTree(SHA_A, SHA_B);
187+
const warnings: string[] = [];
188+
warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m));
189+
expect(warnings).toHaveLength(1);
190+
expect(warnings[0]).toContain(SHA_A.slice(0, 12));
191+
expect(warnings[0]).toContain(SHA_B.slice(0, 12));
192+
expect(warnings[0]).toContain('objectui:build');
193+
});
194+
195+
it('is silent when the dist stamp matches the pin', () => {
196+
const { consoleDir } = makePinnedTree(SHA_A, SHA_A);
197+
const warnings: string[] = [];
198+
warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m));
199+
expect(warnings).toEqual([]);
200+
});
201+
202+
it('is silent for an unstamped dist (pre-guard build / sibling-repo fallback)', () => {
203+
const { consoleDir } = makePinnedTree(SHA_A, null);
204+
const warnings: string[] = [];
205+
warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m));
206+
expect(warnings).toEqual([]);
207+
});
208+
209+
it('is silent when no pin exists up-tree (published install)', () => {
210+
// A console package with a stamped dist but NO .objectui-sha anywhere
211+
// above it — the shape of a published npm install.
212+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-sha-pub-')));
213+
const consoleDir = path.join(root, 'node_modules', '@objectstack', 'console');
214+
fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true });
215+
fs.writeFileSync(path.join(consoleDir, 'dist', 'index.html'), '<html></html>');
216+
fs.writeFileSync(path.join(consoleDir, 'dist', '.objectui-sha'), `${SHA_B}\n`);
217+
218+
const warnings: string[] = [];
219+
warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m));
220+
expect(warnings).toEqual([]);
221+
});
222+
});

scripts/build-console.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ rm -rf "$TARGET"
126126
mkdir -p "$(dirname "$TARGET")"
127127
cp -R "$CONSOLE_DIST" "$TARGET"
128128

129+
# Provenance stamp: record which objectui SHA this dist was built from, so
130+
# `pnpm check:console-sha` and the CLI serve-time guard can detect drift when
131+
# .objectui-sha later moves ahead of this gitignored, locally-built dist
132+
# (which `turbo run build` does NOT rebuild). Travels inside dist/ so a
133+
# cloud/objectos Docker overlay that replaces dist/ restamps it too.
134+
echo "$PINNED_SHA" > "${TARGET}/.objectui-sha"
135+
129136
BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')"
130137
echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}"
131138

scripts/check-console-sha.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3+
4+
/**
5+
* check:console-sha — guard against @objectstack/console version drift.
6+
*
7+
* The Console SPA in packages/console/dist is a gitignored, locally-built
8+
* artifact. It is (re)built ONLY by scripts/build-console.sh — via
9+
* `pnpm objectui:build` / `objectui:refresh` / `release` / CI — and NOT by
10+
* `turbo run build`. So pulling a branch that bumps the committed
11+
* `.objectui-sha` pin updates the pin while leaving a stale dist in place,
12+
* silently serving a console built from a different objectui commit.
13+
*
14+
* build-console.sh stamps the SHA it built from into
15+
* packages/console/dist/.objectui-sha. This script compares that stamp
16+
* against the pin and fails loudly on drift so the fix is obvious.
17+
*
18+
* Remediation is `pnpm objectui:build` (rebuild at the *pinned* SHA), NOT
19+
* `objectui:refresh` — after a pull the pin is already correct; refresh
20+
* would re-bump it to your local ../objectui HEAD.
21+
*
22+
* Exit codes:
23+
* 0 in sync; or no dist built yet (nothing to serve — CLI degrades on its
24+
* own); or dist present but unstamped (unverifiable → warns, non-fatal)
25+
* 1 drift: dist was built from a different objectui SHA than the pin
26+
*/
27+
import fs from 'fs';
28+
import path from 'path';
29+
import { fileURLToPath } from 'url';
30+
31+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32+
const PIN_FILE = path.join(ROOT, '.objectui-sha');
33+
const DIST = path.join(ROOT, 'packages', 'console', 'dist');
34+
const STAMP_FILE = path.join(DIST, '.objectui-sha');
35+
const INDEX = path.join(DIST, 'index.html');
36+
37+
const read = (p) => fs.readFileSync(p, 'utf-8').trim();
38+
const short = (s) => (s ? s.slice(0, 12) : '<none>');
39+
const rel = (p) => path.relative(ROOT, p);
40+
41+
if (!fs.existsSync(PIN_FILE)) {
42+
console.error(`✗ ${rel(PIN_FILE)} is missing — cannot determine the pinned objectui commit.`);
43+
process.exit(1);
44+
}
45+
const pin = read(PIN_FILE);
46+
47+
// No console built yet: not a drift condition. The CLI already degrades
48+
// gracefully (serves the API, warns the dist is absent), and package-only /
49+
// CI builds legitimately have no dist. Nothing to verify.
50+
if (!fs.existsSync(INDEX)) {
51+
console.log(`ℹ No console dist at ${rel(DIST)} — skipping SHA check. Build it with: pnpm objectui:build`);
52+
process.exit(0);
53+
}
54+
55+
// Dist present but unstamped: built by an older build-console.sh (pre-guard)
56+
// or assembled by hand. Can't prove drift, but can't prove freshness either.
57+
if (!fs.existsSync(STAMP_FILE)) {
58+
console.warn(
59+
`⚠ Console dist has no objectui-SHA stamp — cannot verify it matches the pin (objectui@${short(pin)}).\n` +
60+
` Rebuild once to enable the drift guard: pnpm objectui:build`,
61+
);
62+
process.exit(0);
63+
}
64+
65+
const stamp = read(STAMP_FILE);
66+
if (stamp === pin) {
67+
console.log(`✓ Console dist matches the objectui pin (objectui@${short(pin)}).`);
68+
process.exit(0);
69+
}
70+
71+
console.error(
72+
`\n✗ Console version drift detected.\n\n` +
73+
` pinned (.objectui-sha): objectui@${short(pin)}\n` +
74+
` built (console/dist/.objectui-sha): objectui@${short(stamp)}\n\n` +
75+
` packages/console/dist is a gitignored local artifact that 'turbo run build' does NOT rebuild.\n` +
76+
` The objectui pin moved ahead of your locally-built console, so a stale Console SPA would be served.\n\n` +
77+
` Rebuild the console at the pinned SHA:\n\n` +
78+
` pnpm objectui:build\n\n` +
79+
` (Use 'pnpm objectui:refresh' only when you intend to move the pin to your local ../objectui HEAD.)\n`,
80+
);
81+
process.exit(1);

0 commit comments

Comments
 (0)