diff --git a/.changeset/console-sha-drift-guard.md b/.changeset/console-sha-drift-guard.md new file mode 100644 index 0000000000..60603644c7 --- /dev/null +++ b/.changeset/console-sha-drift-guard.md @@ -0,0 +1,7 @@ +--- +"@objectstack/cli": patch +--- + +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). + +`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. diff --git a/.github/workflows/showcase-smoke.yml b/.github/workflows/showcase-smoke.yml index 169287cd2f..194a6acfbb 100644 --- a/.github/workflows/showcase-smoke.yml +++ b/.github/workflows/showcase-smoke.yml @@ -36,6 +36,11 @@ jobs: # not found" and the app shell never renders. - name: Vendor the pinned Console SPA run: bash scripts/build-console.sh + # Self-test the drift guard: a fresh build-console.sh must stamp the dist + # with the pinned SHA so `pnpm check:console-sha` reports in-sync. Catches + # a regression where the stamp step is dropped or the pin/stamp diverge. + - name: Verify Console SHA stamp matches pin + run: pnpm check:console-sha - name: Install Playwright Chromium working-directory: examples/app-showcase run: pnpm exec playwright install --with-deps chromium diff --git a/package.json b/package.json index 1bb11a2ebb..4d9c4f4ba9 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations", "scripts": { "build": "turbo run build --filter=!@objectstack/docs", - "dev": "pnpm --filter @objectstack/example-showcase dev", - "dev:showcase": "pnpm --filter @objectstack/example-showcase dev", - "dev:crm": "pnpm --filter @objectstack/example-crm dev", - "dev:todo": "pnpm --filter @objectstack/example-todo dev", + "dev": "pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", + "dev:showcase": "pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", + "dev:crm": "pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev", + "dev:todo": "pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev", "spec:rebuild": "turbo run build --filter=...@objectstack/spec", "test": "turbo run test", "test:e2e": "turbo run test:e2e", @@ -27,7 +27,8 @@ "objectui:clean": "rm -rf packages/console/dist .cache/objectui-*", "lint": "eslint . --no-inline-config", "check:doc-authoring": "node scripts/check-doc-authoring.mjs", - "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs" + "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs", + "check:console-sha": "node scripts/check-console-sha.mjs" }, "keywords": [ "objectstack", diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index f20c881f64..342df805e2 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -240,7 +240,13 @@ export function resolveConsolePath(options?: ResolveConsoleOptions): string | nu // Pass 1: prefer a candidate that actually has a built dist. for (const dir of candidates) { - if (hasConsoleDist(dir)) return dir; + if (hasConsoleDist(dir)) { + // Loudly flag (but still serve) a dist built from a different objectui + // SHA than the framework pins — the silent-drift case the npm-major + // guard above can't see. No-op for published installs / unstamped dists. + warnOnConsoleShaDrift(dir, warn); + return dir; + } } // Pass 2: nothing built yet — return the highest-priority candidate so @@ -256,6 +262,70 @@ export function hasConsoleDist(consolePath: string): boolean { return fs.existsSync(path.join(consolePath, 'dist', 'index.html')); } +// ─── objectui-SHA Drift Guard (dev monorepo only) ─────────────────── + +/** + * The vendored console `dist/` is a locally-built, gitignored artifact + * rebuilt only by `scripts/build-console.sh` (pnpm objectui:build / + * objectui:refresh / release) — never by `turbo run build`. When a + * developer pulls a branch that bumps the framework's `.objectui-sha` pin + * without re-running `pnpm objectui:build`, the dist stays frozen at the + * previous objectui commit and would be served silently. The npm-major + * version guard above can't catch this: the objectui SHA moves underneath a + * single `@objectstack/console` version. + * + * build-console.sh stamps the built SHA into `dist/.objectui-sha`. Here we + * compare it against the framework's committed `.objectui-sha` pin, located + * by walking up from the resolved console package. This is inherently a + * monorepo/dev concern: a published install ships no `.objectui-sha` pin + * (so the stamped dist is authoritative), and the sibling-repo dev fallback + * writes no stamp — both cases are skipped silently. + */ +function findObjectuiPin(startDir: string): { pin: string; file: string } | null { + let dir = startDir; + for (let depth = 0; depth < 8; depth++) { + const file = path.join(dir, '.objectui-sha'); + if (fs.existsSync(file)) { + try { + const pin = fs.readFileSync(file, 'utf-8').trim(); + return pin ? { pin, file } : null; + } catch { + return null; // unreadable pin — fail open + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +export function warnOnConsoleShaDrift( + consoleDir: string, + warn: (message: string) => void, +): void { + const found = findObjectuiPin(consoleDir); + if (!found) return; // published install / no monorepo pin — nothing to compare + + const stampFile = path.join(consoleDir, 'dist', '.objectui-sha'); + let stamp: string | null = null; + try { + if (fs.existsSync(stampFile)) stamp = fs.readFileSync(stampFile, 'utf-8').trim(); + } catch { + return; // unreadable stamp — fail open + } + // Unstamped dist (pre-guard build or sibling-repo fallback): can't prove + // drift; `pnpm check:console-sha` surfaces it — don't nag on every boot. + if (!stamp || stamp === found.pin) return; + + warn( + ` ⚠ Console version drift: serving @objectstack/console built from objectui@${stamp.slice(0, 12)}, ` + + `but ${found.file} pins objectui@${found.pin.slice(0, 12)}. ` + + `packages/console/dist is a gitignored local build that 'turbo run build' does not refresh — ` + + `rebuild it with 'pnpm objectui:build'.`, + ); +} + // ─── Plugin Factory ───────────────────────────────────────────────── /** diff --git a/packages/cli/test/console-resolve.test.ts b/packages/cli/test/console-resolve.test.ts index bbcfa8640e..de1eefbe4c 100644 --- a/packages/cli/test/console-resolve.test.ts +++ b/packages/cli/test/console-resolve.test.ts @@ -18,6 +18,7 @@ import { createRequire } from 'module'; import { resolveConsolePath, isConsoleVersionCompatible, + warnOnConsoleShaDrift, } from '../src/utils/console.js'; // resolveConsolePath() also discovers the real, version-locked workspace @@ -156,3 +157,66 @@ describe('resolveConsolePath version guard', () => { expect(warnings.some((m) => m.includes('unknown'))).toBe(true); }); }); + +describe('warnOnConsoleShaDrift', () => { + const SHA_A = '2b86379384f0f6e99d9a5bb81d73017fd6f99cef'; + const SHA_B = '69d6b94419bcaa11223344556677889900aabbcc'; + + /** + * Lay out a monorepo-shaped tree: /.objectui-sha is the pin, and + * /packages/console/dist is the vendored, optionally-stamped build. + * Returns the console package dir (what resolveConsolePath would hand back). + */ + function makePinnedTree( + pin: string, + stamp: string | null, + ): { root: string; consoleDir: string } { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-sha-'))); + fs.writeFileSync(path.join(root, '.objectui-sha'), `${pin}\n`); + const consoleDir = path.join(root, 'packages', 'console'); + fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(consoleDir, 'dist', 'index.html'), ''); + if (stamp !== null) { + fs.writeFileSync(path.join(consoleDir, 'dist', '.objectui-sha'), `${stamp}\n`); + } + return { root, consoleDir }; + } + + it('warns when the dist stamp differs from the pin (the pull-without-rebuild case)', () => { + const { consoleDir } = makePinnedTree(SHA_A, SHA_B); + const warnings: string[] = []; + warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m)); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(SHA_A.slice(0, 12)); + expect(warnings[0]).toContain(SHA_B.slice(0, 12)); + expect(warnings[0]).toContain('objectui:build'); + }); + + it('is silent when the dist stamp matches the pin', () => { + const { consoleDir } = makePinnedTree(SHA_A, SHA_A); + const warnings: string[] = []; + warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m)); + expect(warnings).toEqual([]); + }); + + it('is silent for an unstamped dist (pre-guard build / sibling-repo fallback)', () => { + const { consoleDir } = makePinnedTree(SHA_A, null); + const warnings: string[] = []; + warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m)); + expect(warnings).toEqual([]); + }); + + it('is silent when no pin exists up-tree (published install)', () => { + // A console package with a stamped dist but NO .objectui-sha anywhere + // above it — the shape of a published npm install. + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-sha-pub-'))); + const consoleDir = path.join(root, 'node_modules', '@objectstack', 'console'); + fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(consoleDir, 'dist', 'index.html'), ''); + fs.writeFileSync(path.join(consoleDir, 'dist', '.objectui-sha'), `${SHA_B}\n`); + + const warnings: string[] = []; + warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m)); + expect(warnings).toEqual([]); + }); +}); diff --git a/scripts/build-console.sh b/scripts/build-console.sh index c05da32b7d..35ae388b1e 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -126,6 +126,13 @@ rm -rf "$TARGET" mkdir -p "$(dirname "$TARGET")" cp -R "$CONSOLE_DIST" "$TARGET" +# Provenance stamp: record which objectui SHA this dist was built from, so +# `pnpm check:console-sha` and the CLI serve-time guard can detect drift when +# .objectui-sha later moves ahead of this gitignored, locally-built dist +# (which `turbo run build` does NOT rebuild). Travels inside dist/ so a +# cloud/objectos Docker overlay that replaces dist/ restamps it too. +echo "$PINNED_SHA" > "${TARGET}/.objectui-sha" + BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')" echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}" diff --git a/scripts/check-console-sha.mjs b/scripts/check-console-sha.mjs new file mode 100644 index 0000000000..f5ae11c289 --- /dev/null +++ b/scripts/check-console-sha.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check:console-sha — guard against @objectstack/console version drift. + * + * The Console SPA in packages/console/dist is a gitignored, locally-built + * artifact. It is (re)built ONLY by scripts/build-console.sh — via + * `pnpm objectui:build` / `objectui:refresh` / `release` / CI — and NOT by + * `turbo run build`. So pulling a branch that bumps the committed + * `.objectui-sha` pin updates the pin while leaving a stale dist in place, + * silently serving a console built from a different objectui commit. + * + * build-console.sh stamps the SHA it built from into + * packages/console/dist/.objectui-sha. This script compares that stamp + * against the pin and fails loudly on drift so the fix is obvious. + * + * Remediation is `pnpm objectui:build` (rebuild at the *pinned* SHA), NOT + * `objectui:refresh` — after a pull the pin is already correct; refresh + * would re-bump it to your local ../objectui HEAD. + * + * Exit codes: + * 0 in sync; or no dist built yet (nothing to serve — CLI degrades on its + * own); or dist present but unstamped (unverifiable → warns, non-fatal) + * 1 drift: dist was built from a different objectui SHA than the pin + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PIN_FILE = path.join(ROOT, '.objectui-sha'); +const DIST = path.join(ROOT, 'packages', 'console', 'dist'); +const STAMP_FILE = path.join(DIST, '.objectui-sha'); +const INDEX = path.join(DIST, 'index.html'); + +const read = (p) => fs.readFileSync(p, 'utf-8').trim(); +const short = (s) => (s ? s.slice(0, 12) : ''); +const rel = (p) => path.relative(ROOT, p); + +if (!fs.existsSync(PIN_FILE)) { + console.error(`✗ ${rel(PIN_FILE)} is missing — cannot determine the pinned objectui commit.`); + process.exit(1); +} +const pin = read(PIN_FILE); + +// No console built yet: not a drift condition. The CLI already degrades +// gracefully (serves the API, warns the dist is absent), and package-only / +// CI builds legitimately have no dist. Nothing to verify. +if (!fs.existsSync(INDEX)) { + console.log(`ℹ No console dist at ${rel(DIST)} — skipping SHA check. Build it with: pnpm objectui:build`); + process.exit(0); +} + +// Dist present but unstamped: built by an older build-console.sh (pre-guard) +// or assembled by hand. Can't prove drift, but can't prove freshness either. +if (!fs.existsSync(STAMP_FILE)) { + console.warn( + `⚠ Console dist has no objectui-SHA stamp — cannot verify it matches the pin (objectui@${short(pin)}).\n` + + ` Rebuild once to enable the drift guard: pnpm objectui:build`, + ); + process.exit(0); +} + +const stamp = read(STAMP_FILE); +if (stamp === pin) { + console.log(`✓ Console dist matches the objectui pin (objectui@${short(pin)}).`); + process.exit(0); +} + +console.error( + `\n✗ Console version drift detected.\n\n` + + ` pinned (.objectui-sha): objectui@${short(pin)}\n` + + ` built (console/dist/.objectui-sha): objectui@${short(stamp)}\n\n` + + ` packages/console/dist is a gitignored local artifact that 'turbo run build' does NOT rebuild.\n` + + ` The objectui pin moved ahead of your locally-built console, so a stale Console SPA would be served.\n\n` + + ` Rebuild the console at the pinned SHA:\n\n` + + ` pnpm objectui:build\n\n` + + ` (Use 'pnpm objectui:refresh' only when you intend to move the pin to your local ../objectui HEAD.)\n`, +); +process.exit(1);