diff --git a/.objectui-sha b/.objectui-sha index 4b1609af97..2f1a4d2778 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -18594c2ffcee72c02da99dfa8588146f78672fd0 +e95cc25b2c0d2fa680e232151721e71c19630659 diff --git a/AGENTS.md b/AGENTS.md index dee98cceb1..4d4a77000d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,9 +230,14 @@ export default { ## Post-Task Checklist 1. `pnpm test` — verify nothing broke. -2. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset. -3. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural. -4. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes. +2. **Land it — don't leave passing work in the working tree.** Once tests pass, + create a feature branch, commit, push, open a PR, and merge it after remote + CI is fully green (see Multi-agent discipline: never straight to `main`, + never `gh pr merge --auto`). A finished task = a merged PR, not a dirty + working tree. +3. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset. +4. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural. +5. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes. --- diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index c035085d1a..9200d99305 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -35,7 +35,7 @@ import path from 'path'; import fs from 'fs'; import { createRequire } from 'module'; -import { pathToFileURL } from 'url'; +import { pathToFileURL, fileURLToPath } from 'url'; // ─── Constants ────────────────────────────────────────────────────── @@ -45,6 +45,66 @@ export const CONSOLE_PATH = '/_console'; /** Canonical npm package name that ships the Console SPA. */ const CONSOLE_PACKAGE = '@objectstack/console'; +// ─── Version Guard ────────────────────────────────────────────────── + +/** + * The vendored `@objectstack/console` package is version-locked to the + * framework release, so a healthy install always carries the same major + * as the CLI. Node module resolution from the consumer cwd, however, + * climbs `node_modules` directories all the way up the filesystem — a + * stray install outside the workspace (e.g. a leftover + * `~/node_modules/@objectstack/console` from an old npm experiment) can + * shadow the bundled build and silently serve a stale Console. + * + * Guard: skip any candidate whose major version differs from the CLI's + * own, and warn so the stray install is discoverable. + */ +export function isConsoleVersionCompatible( + candidateVersion: unknown, + cliVersion: string, +): boolean { + if (typeof candidateVersion !== 'string') return false; + const candidateMajor = majorOf(candidateVersion); + const cliMajor = majorOf(cliVersion); + return candidateMajor !== null && candidateMajor === cliMajor; +} + +function majorOf(version: string): number | null { + const match = /^v?(\d+)[.-]/.exec(version.trim()) ?? /^v?(\d+)$/.exec(version.trim()); + return match ? Number(match[1]) : null; +} + +let cachedCliVersion: string | null | undefined; + +/** + * Read this CLI's own version by walking up from the compiled module to + * the nearest `package.json`. Returns null (guard disabled, fail open) + * if it can't be determined — never let the version check break + * resolution outright. + */ +function getCliVersion(): string | null { + if (cachedCliVersion !== undefined) return cachedCliVersion; + let version: string | null = null; + try { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let depth = 0; depth < 6; depth++) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (typeof pkg.version === 'string') version = pkg.version; + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } catch { + // Unreadable own package.json — leave the guard disabled. + } + cachedCliVersion = version; + return version; +} + // ─── Path Resolution ──────────────────────────────────────────────── /** @@ -70,9 +130,40 @@ const CONSOLE_PACKAGE = '@objectstack/console'; * 3. Sibling-repo dev fallback — `../objectui/apps/console` — matched * by the package name on disk so an unrelated `apps/console` * doesn't get picked up by accident. + * + * Strategies 1 and 2 additionally require the candidate's major version + * to match the CLI's own (see `isConsoleVersionCompatible`) — node + * resolution climbs past the workspace root, so a stale install higher + * up the filesystem must not shadow the version-locked bundle. + * Mismatches are skipped with a warning. The sibling-repo fallback is + * exempt: the objectui workspace versions independently and is an + * explicit dev opt-in. */ -export function resolveConsolePath(): string | null { - const cwd = process.cwd(); +export interface ResolveConsoleOptions { + /** Resolution origin; defaults to `process.cwd()`. */ + cwd?: string; + /** Override the CLI's own version (tests). */ + cliVersion?: string; + /** Warning sink; defaults to `console.warn`. */ + warn?: (message: string) => 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)); + + /** Version guard for vendored-package candidates (strategies 1 & 2). */ + const versionOk = (dir: string, candidateVersion: unknown): boolean => { + if (!cliVersion) return true; // own version unknown — fail open + if (isConsoleVersionCompatible(candidateVersion, cliVersion)) return true; + const shown = typeof candidateVersion === 'string' ? candidateVersion : 'unknown'; + warn( + ` ⚠ Ignoring ${CONSOLE_PACKAGE}@${shown} at ${dir} — major version does not match this CLI (${cliVersion}). ` + + `This is usually a stale install outside your workspace (e.g. a leftover ~/node_modules); remove it or install a matching version.`, + ); + return false; + }; const resolutionBases = [ pathToFileURL(path.join(cwd, 'package.json')).href, // consumer workspace @@ -91,7 +182,11 @@ export function resolveConsolePath(): string | null { const dir = path.dirname(resolvedPkgJson); try { const pkg = JSON.parse(fs.readFileSync(resolvedPkgJson, 'utf-8')); - if (pkg.name === CONSOLE_PACKAGE && !candidates.includes(dir)) { + if ( + pkg.name === CONSOLE_PACKAGE && + versionOk(dir, pkg.version) && + !candidates.includes(dir) + ) { candidates.push(dir); } } catch { @@ -104,11 +199,16 @@ export function resolveConsolePath(): string | null { // 2: direct filesystem check in cwd/node_modules. const directPath = path.join(cwd, 'node_modules', ...CONSOLE_PACKAGE.split('/')); - if ( - fs.existsSync(path.join(directPath, 'package.json')) && - !candidates.includes(directPath) - ) { - candidates.push(directPath); + const directPkgJson = path.join(directPath, 'package.json'); + if (fs.existsSync(directPkgJson) && !candidates.includes(directPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(directPkgJson, 'utf-8')); + if (pkg.name === CONSOLE_PACKAGE && versionOk(directPath, pkg.version)) { + candidates.push(directPath); + } + } catch { + // Skip invalid package.json + } } // 3: sibling-repo dev fallback. Useful when iterating on the Console diff --git a/packages/cli/test/console-resolve.test.ts b/packages/cli/test/console-resolve.test.ts new file mode 100644 index 0000000000..d483047e7b --- /dev/null +++ b/packages/cli/test/console-resolve.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * resolveConsolePath() hardening — stale out-of-workspace installs. + * + * Node module resolution from the consumer cwd climbs `node_modules` + * directories all the way up the filesystem. A stray + * `~/node_modules/@objectstack/console` left behind by an old npm + * experiment used to win over the version-locked bundle and serve a + * stale Console (browser-side OBJUI-001 "Unknown component type"). + * These tests pin the major-version guard that skips such candidates. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + resolveConsolePath, + isConsoleVersionCompatible, +} from '../src/utils/console.js'; + +const CLI_VERSION = '9.2.0'; + +function writeConsolePackage( + dir: string, + { name = '@objectstack/console', version, withDist = true }: { + name?: string; + version: string; + withDist?: boolean; + }, +): string { + const pkgDir = path.join(dir, 'node_modules', '@objectstack', 'console'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name, version })); + if (withDist) { + fs.mkdirSync(path.join(pkgDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'dist', 'index.html'), ''); + } + return pkgDir; +} + +/** Fresh sandbox: /home/project is the cwd, /home simulates $HOME. */ +function makeSandbox(): { home: string; project: string } { + // realpath: node's require.resolve returns symlink-resolved paths, and + // macOS tmpdir lives behind the /var -> /private/var symlink. + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-resolve-'))); + const home = path.join(root, 'home'); + const project = path.join(home, 'project'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ name: 'consumer-app', version: '1.0.0' }), + ); + return { home, project }; +} + +describe('isConsoleVersionCompatible', () => { + it('accepts the same major', () => { + expect(isConsoleVersionCompatible('9.2.0', '9.2.0')).toBe(true); + expect(isConsoleVersionCompatible('9.0.1', '9.5.0')).toBe(true); + expect(isConsoleVersionCompatible('9.3.0-beta.1', '9.2.0')).toBe(true); + }); + + it('rejects a different major', () => { + expect(isConsoleVersionCompatible('7.8.0', '9.2.0')).toBe(false); + expect(isConsoleVersionCompatible('10.0.0', '9.2.0')).toBe(false); + }); + + it('rejects missing or malformed versions', () => { + expect(isConsoleVersionCompatible(undefined, '9.2.0')).toBe(false); + expect(isConsoleVersionCompatible('', '9.2.0')).toBe(false); + expect(isConsoleVersionCompatible('not-a-version', '9.2.0')).toBe(false); + }); +}); + +describe('resolveConsolePath version guard', () => { + it('skips a stale major-mismatched install climbed to outside the project, with a warning', () => { + const { home, project } = makeSandbox(); + // The incident shape: ~/node_modules/@objectstack/console@7.8.0 with a + // built dist, reachable from the project cwd by climbing node_modules. + const stale = writeConsolePackage(home, { version: '7.8.0' }); + + const warnings: string[] = []; + const result = resolveConsolePath({ + cwd: project, + cliVersion: CLI_VERSION, + warn: (m) => warnings.push(m), + }); + + expect(result).not.toBe(stale); + expect(warnings.some((m) => m.includes('7.8.0') && m.includes(stale))).toBe(true); + }); + + it('accepts a same-major install climbed to from the project cwd', () => { + const { home, project } = makeSandbox(); + const ok = writeConsolePackage(home, { version: '9.0.0' }); + + const warnings: string[] = []; + const result = resolveConsolePath({ + cwd: project, + cliVersion: CLI_VERSION, + warn: (m) => warnings.push(m), + }); + + expect(result).toBe(ok); + expect(warnings).toEqual([]); + }); + + it('prefers a matching local install over a stale parent-directory one', () => { + const { home, project } = makeSandbox(); + writeConsolePackage(home, { version: '7.8.0' }); + const local = writeConsolePackage(project, { version: '9.2.0' }); + + const result = resolveConsolePath({ + cwd: project, + cliVersion: CLI_VERSION, + warn: () => {}, + }); + + expect(result).toBe(local); + }); + + it('skips an install whose package.json carries no version', () => { + const { home, project } = makeSandbox(); + const unversionedDir = path.join(home, 'node_modules', '@objectstack', 'console'); + fs.mkdirSync(path.join(unversionedDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(unversionedDir, 'package.json'), + JSON.stringify({ name: '@objectstack/console' }), + ); + fs.writeFileSync(path.join(unversionedDir, 'dist', 'index.html'), ''); + + const warnings: string[] = []; + const result = resolveConsolePath({ + cwd: project, + cliVersion: CLI_VERSION, + warn: (m) => warnings.push(m), + }); + + expect(result).not.toBe(unversionedDir); + expect(warnings.some((m) => m.includes('unknown'))).toBe(true); + }); +});