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
33 changes: 33 additions & 0 deletions .changeset/console-sha-drift-dev-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/cli": patch
---

fix(cli): `os dev` refuses to serve a console built from a different objectui SHA than the pin (#7752)

`packages/console/dist` is a gitignored local build that only
`scripts/build-console.sh` (`pnpm objectui:build`) refreshes — `turbo run
build` never touches it. Pull a branch that moves `.objectui-sha` and the pin
advances while the dist stays frozen, so the server keeps serving a Console
SPA the repo no longer pins.

`pnpm check:console-sha` already fails on exactly this, but it is wired into
the root `pnpm dev` / `dev:showcase` / `dev:crm` / `dev:todo` scripts only.
Every other way to boot reaches the server without passing it — `objectstack
dev` run inside an example dir, an example's own `dev` script (`objectstack
dev --seed-admin`), a `.claude/launch.json` config driving `pnpm exec
objectstack dev`. A QA sweep booted that way and spent its run measuring a
console two days behind the pin; two of its clauses had to be recorded
`blocked` once the gap was found.

So the guard gets a second seat on the boot path itself. When the dist carries
an objectui stamp that provably differs from the repo's pin, `os dev` now
declines to mount `/_console` and prints the rebuild remediation, instead of
serving the stale bundle under a warning that scrolls past. The API still
boots, so api/cli work is unaffected, and the banner stops advertising a
console URL that would have been a lie.

Deliberately narrow: dev only, and only on drift it can prove. A published
install ships no `.objectui-sha` pin and the sibling-repo dev fallback writes
no stamp — both keep resolving exactly as before, and no production or cloud
deployment can reach the refusal. `OS_ALLOW_CONSOLE_DRIFT=1` boots the stale
bundle anyway when that is what you want.
2 changes: 1 addition & 1 deletion docs/qa/platform-checklist/RUNNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ contradicts it, and correct it here when it does.
| trap | what it fakes | counter |
|---|---|---|
| `hydration-race` | empty nav/list right after navigation | screenshot first; settle; then read DOM |
| `stale-console-bundle` | UI bug already fixed upstream in objectui | check against objectui HMR console / fresh build (skill §2) |
| `stale-console-bundle` | UI bug already fixed upstream in objectui | check against objectui HMR console / fresh build (skill §2). `os dev` refuses to mount a console whose stamp ≠ the `.objectui-sha` pin, so a 404 `/_console/` reads "rebuild with `pnpm objectui:build`", never "console broken" |
| `stale-dist` | src edits with no runtime effect | rebuild package + restart before judging |
| `automation-input` | dead buttons / empty submits caused by the driver | ref-targeted clicks; native setter + input/change events |
| `shared-browser-tab` | drifting origin, foreign drafts | pin absolute origin; own port/DB (skill §0) |
Expand Down
28 changes: 25 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ import {
CONSOLE_PATH,
resolveConsolePath,
hasConsoleDist,
decideConsoleMount,
formatConsoleShaDriftWarning,
formatConsoleShaDriftRefusal,
createConsoleStaticPlugin,
createRuntimeAssetsPlugin,
type ConsoleShaDrift,
} from '../utils/console.js';
import dotenvFlow from 'dotenv-flow';

Expand Down Expand Up @@ -2630,20 +2634,38 @@ export default class Serve extends Command {
// opt out of the Console entirely — useful for control-plane
// deployments where the runtime Console is meaningless.
const consoleEnabled = flags.console && process.env.OS_DISABLE_CONSOLE !== '1';
const consolePath = consoleEnabled ? resolveConsolePath() : null;
const consoleWillMount = !!(consolePath && hasConsoleDist(consolePath));
// Resolution reports objectui-SHA drift instead of warning about it
// itself, so the decision below owns the single message about it.
// (Boxed so the assignment made inside `onDrift` is visible to
// control-flow analysis after the call returns.)
const driftBox: { value: ConsoleShaDrift | null } = { value: null };
const consolePath = consoleEnabled
? resolveConsolePath({ onDrift: (d) => { driftBox.value = d; } })
: null;
const consoleDrift = driftBox.value;
const { mount: consoleWillMount, refusedForDrift } = decideConsoleMount({
hasDist: !!(consolePath && hasConsoleDist(consolePath)),
drift: consoleDrift,
isDev,
});

// ── Console portal ──────────────────────────────────────────
// The opinionated, fork-ready runtime console (`@object-ui/console`,
// published from the objectstack-ai/objectui monorepo) mounts under
// `/_console/`. When present, it owns the root `/` redirect
// (preferred default UI). It is optional — we only mount it when
// the package resolves and a pre-built `dist/` is present.
// the package resolves and a pre-built `dist/` is present, and — in
// dev — only when that build matches the repo's objectui pin (#7752).
if (consolePath) {
if (consoleWillMount) {
if (consoleDrift) {
console.warn(chalk.yellow(formatConsoleShaDriftWarning(consoleDrift)));
}
const consoleDistPath = path.join(consolePath, 'dist');
await kernel.use(createConsoleStaticPlugin(consoleDistPath, { isDev }));
trackPlugin('ConsoleUI');
} else if (refusedForDrift && consoleDrift) {
console.error(chalk.red(formatConsoleShaDriftRefusal(consoleDrift)));
} else {
console.warn(chalk.yellow(` ⚠ Console dist not found — install \`@object-ui/console\` (already built) or run \`pnpm --filter @object-ui/console build\` in the objectui workspace`));
}
Expand Down
113 changes: 113 additions & 0 deletions packages/cli/src/utils/console.sha-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui-SHA drift guard — the seat on the boot path itself (#7752).
*
* `pnpm check:console-sha` guards the root `pnpm dev` / `dev:showcase` /
* `dev:crm` / `dev:todo` scripts. Every other way to boot reaches the server
* without passing it: `objectstack dev` run inside an example dir, an
* example's own `dev` script (`objectstack dev --seed-admin`), a
* `.claude/launch.json` config driving `pnpm exec objectstack dev`. A QA sweep
* booted that way and spent the run measuring a console two days behind the
* repo's pin, with a warning the boot log scrolled past.
*
* `decideConsoleMount` is what closes it: on drift it can *prove*, the dev
* server does not mount the Console at all, so the stale bundle is
* unreachable rather than silently authoritative. Detection semantics
* (`detectConsoleShaDrift`) are pinned in `test/console-resolve.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import {
decideConsoleMount,
formatConsoleShaDriftRefusal,
formatConsoleShaDriftWarning,
DRIFT_OVERRIDE_ENV,
} from './console.js';

/** The exact gap #7752 measured: pin two days ahead of the served bundle. */
const drift = {
pin: '6314e87f2d49b1ff3b158c296f1b2a52d14dff68',
stamp: '09987b680aa1c3e4f5061d2b7c8a9e0f1a2b3c4d',
pinFile: '/repo/.objectui-sha',
};

describe('decideConsoleMount', () => {
it('refuses to mount a drifted console under `os dev`', () => {
expect(decideConsoleMount({ hasDist: true, drift, isDev: true, env: {} })).toEqual({
mount: false,
refusedForDrift: true,
});
});

it('mounts when the dist matches the pin — no false positive', () => {
expect(decideConsoleMount({ hasDist: true, drift: null, isDev: true, env: {} })).toEqual({
mount: true,
refusedForDrift: false,
});
});

it('leaves non-dev serves advisory — a published install carries no pin anyway', () => {
expect(decideConsoleMount({ hasDist: true, drift, isDev: false, env: {} })).toEqual({
mount: true,
refusedForDrift: false,
});
});

it(`boots the stale bundle deliberately when ${DRIFT_OVERRIDE_ENV} is set`, () => {
for (const value of ['1', 'true', 'yes', 'on', 'TRUE']) {
expect(
decideConsoleMount({
hasDist: true,
drift,
isDev: true,
env: { [DRIFT_OVERRIDE_ENV]: value },
}),
).toEqual({ mount: true, refusedForDrift: false });
}
});

it('does not read an unset-looking value as an override', () => {
for (const value of ['0', 'false', 'no', '']) {
expect(
decideConsoleMount({
hasDist: true,
drift,
isDev: true,
env: { [DRIFT_OVERRIDE_ENV]: value },
}),
).toEqual({ mount: false, refusedForDrift: true });
}
});

it('keeps "no dist at all" distinct from "refused for drift"', () => {
// Both leave the Console unmounted, but they need different messages —
// "run objectui:build" vs the existing "console dist not found" hint.
expect(decideConsoleMount({ hasDist: false, drift: null, isDev: true, env: {} })).toEqual({
mount: false,
refusedForDrift: false,
});
});
});

describe('drift messages', () => {
it('names the rebuild, never the pin bump — refresh would move the pin instead', () => {
for (const message of [
formatConsoleShaDriftWarning(drift),
formatConsoleShaDriftRefusal(drift),
]) {
expect(message).toContain('pnpm objectui:build');
}
// `objectui:refresh` appears only as the explicitly-labelled wrong turn.
expect(formatConsoleShaDriftRefusal(drift)).toContain(
"(Use 'pnpm objectui:refresh' only when you intend to move the pin",
);
});

it('shows both SHAs and the escape hatch, so the boot log is self-explaining', () => {
const message = formatConsoleShaDriftRefusal(drift);
expect(message).toContain(drift.pin.slice(0, 12));
expect(message).toContain(drift.stamp.slice(0, 12));
expect(message).toContain(drift.pinFile);
expect(message).toContain(DRIFT_OVERRIDE_ENV);
});
});
130 changes: 113 additions & 17 deletions packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,23 @@ export interface ResolveConsoleOptions {
cliVersion?: string;
/** Warning sink; defaults to `console.warn`. */
warn?: (message: string) => void;
/**
* Called when the dist about to be served was built from a different
* objectui SHA than the repo pins. Defaults to emitting the advisory
* one-liner on `warn`. A caller that enforces the pin itself — the dev
* server refuses to mount a drifted console, see `decideConsoleMount` —
* passes a collector instead, so the drift is reported exactly once and
* by whoever decides what to do about it.
*/
onDrift?: (drift: ConsoleShaDrift) => void;
}

export function resolveConsolePath(options?: ResolveConsoleOptions): string | null {
const cwd = options?.cwd ?? process.cwd();
const cliVersion = options?.cliVersion ?? getCliVersion();
const warn = options?.warn ?? ((message: string) => console.warn(message));
const onDrift =
options?.onDrift ?? ((drift: ConsoleShaDrift) => warn(formatConsoleShaDriftWarning(drift)));

/** Version guard for vendored-package candidates (strategies 1 & 2). */
const versionOk = (dir: string, candidateVersion: unknown): boolean => {
Expand Down Expand Up @@ -241,10 +252,12 @@ 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)) {
// 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);
// Report 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. What happens next is
// the caller's call: advisory by default, fail-closed under `os dev`.
const drift = detectConsoleShaDrift(dir);
if (drift) onDrift(drift);
return dir;
}
}
Expand Down Expand Up @@ -300,32 +313,115 @@ function findObjectuiPin(startDir: string): { pin: string; file: string } | null
return null;
}

export function warnOnConsoleShaDrift(
consoleDir: string,
warn: (message: string) => void,
): void {
/** A dist proven to be built from a different objectui commit than the pin. */
export interface ConsoleShaDrift {
/** objectui SHA the dist was built from (`dist/.objectui-sha`). */
stamp: string;
/** objectui SHA the repo pins (`<root>/.objectui-sha`). */
pin: string;
/** Absolute path of the pin file the comparison used. */
pinFile: string;
}

/**
* Compare a resolved console package's dist stamp against the repo pin.
* Returns `null` for every unprovable case — no monorepo pin (published
* install), no stamp (pre-guard build or the sibling-repo dev fallback),
* unreadable files — and only reports drift it can prove.
*/
export function detectConsoleShaDrift(consoleDir: string): ConsoleShaDrift | null {
const found = findObjectuiPin(consoleDir);
if (!found) return; // published install / no monorepo pin — nothing to compare
if (!found) return null; // 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
return null; // 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;
// Unstamped dist: can't prove drift; `pnpm check:console-sha` surfaces it.
if (!stamp || stamp === found.pin) return null;

return { stamp, pin: found.pin, pinFile: found.file };
}

warn(
` ⚠ Console version drift: serving @objectstack/console built from objectui@${stamp.slice(0, 12)}, ` +
`but ${found.file} pins objectui@${found.pin.slice(0, 12)}. ` +
/** Advisory one-liner — used where the drifted console is still served. */
export function formatConsoleShaDriftWarning(drift: ConsoleShaDrift): string {
return (
` ⚠ Console version drift: serving @objectstack/console built from objectui@${drift.stamp.slice(0, 12)}, ` +
`but ${drift.pinFile} pins objectui@${drift.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'.`,
`rebuild it with 'pnpm objectui:build'.`
);
}

/**
* The refusal block — `os dev` declines to mount a drifted console. Mirrors
* the remediation of `scripts/check-console-sha.mjs` verbatim (rebuild at the
* *pinned* SHA with `pnpm objectui:build`; `objectui:refresh` would re-bump
* the pin to the local ../objectui HEAD, which is the opposite of the fix).
*/
export function formatConsoleShaDriftRefusal(drift: ConsoleShaDrift): string {
return (
`\n ✗ Console version drift — refusing to serve /_console in dev.\n\n` +
` pinned (${drift.pinFile}): objectui@${drift.pin.slice(0, 12)}\n` +
` built (console/dist/.objectui-sha): objectui@${drift.stamp.slice(0, 12)}\n\n` +
` packages/console/dist is a gitignored local build that 'turbo run build' does NOT refresh,\n` +
` so this server would serve a Console SPA the repo no longer pins — and anything you\n` +
` observed in it would describe a different objectui commit.\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` +
` To boot anyway with the stale bundle: ${DRIFT_OVERRIDE_ENV}=1 — the API still serves either way.\n`
);
}

/** Env switch that downgrades the dev refusal back to a warning. */
export const DRIFT_OVERRIDE_ENV = 'OS_ALLOW_CONSOLE_DRIFT';

function driftOverridden(env: NodeJS.ProcessEnv): boolean {
const v = String(env[DRIFT_OVERRIDE_ENV] ?? '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}

/**
* Decide whether the Console SPA may mount, given what resolution found.
*
* The drift guard has two seats and this is the second one. The first —
* `pnpm check:console-sha` — is wired into the root `pnpm dev` /
* `dev:showcase` / `dev:crm` / `dev:todo` scripts, and every boot that does
* not go through those scripts misses it: `objectstack dev` run directly in
* an example dir, an example's own `dev` script, a `.claude/launch.json`
* config, `pnpm exec objectstack dev`. That is how a QA sweep came to measure
* a console two days behind the pin (#7752) — the pin had moved, the local
* dist had not, and the boot path carried no guard. So the dev server itself
* refuses to mount a console it can prove is not a build of the pin: the
* stale bundle is unreachable rather than silently authoritative, and the API
* keeps serving so api/cli work is unaffected.
*
* Scope is deliberately narrow — `isDev` only, and only on proven drift
* (see `detectConsoleShaDrift`). Published installs carry no pin, so no
* production or cloud deployment can reach the refusal.
*/
export function decideConsoleMount(input: {
/** Whether the resolved package has a built `dist/index.html`. */
hasDist: boolean;
/** Proven drift, or null. */
drift: ConsoleShaDrift | null;
/** True under `objectstack dev` (`serve --dev`). */
isDev: boolean;
/** Defaults to `process.env`. */
env?: NodeJS.ProcessEnv;
}): { mount: boolean; refusedForDrift: boolean } {
const { hasDist, drift, isDev } = input;
if (!hasDist) return { mount: false, refusedForDrift: false };
if (drift && isDev && !driftOverridden(input.env ?? process.env)) {
return { mount: false, refusedForDrift: true };
}
return { mount: true, refusedForDrift: false };
}

// ─── Plugin Factory ─────────────────────────────────────────────────

/**
Expand Down
Loading
Loading