Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/console-sha-drift-guard.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .github/workflows/showcase-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
72 changes: 71 additions & 1 deletion packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ─────────────────────────────────────────────────

/**
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/test/console-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <root>/.objectui-sha is the pin, and
* <root>/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'), '<html></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'), '<html></html>');
fs.writeFileSync(path.join(consoleDir, 'dist', '.objectui-sha'), `${SHA_B}\n`);

const warnings: string[] = [];
warnOnConsoleShaDrift(consoleDir, (m) => warnings.push(m));
expect(warnings).toEqual([]);
});
});
7 changes: 7 additions & 0 deletions scripts/build-console.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
81 changes: 81 additions & 0 deletions scripts/check-console-sha.mjs
Original file line number Diff line number Diff line change
@@ -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) : '<none>');
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);