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
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
18594c2ffcee72c02da99dfa8588146f78672fd0
e95cc25b2c0d2fa680e232151721e71c19630659
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
118 changes: 109 additions & 9 deletions packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────

Expand All @@ -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 ────────────────────────────────────────────────

/**
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
143 changes: 143 additions & 0 deletions packages/cli/test/console-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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'), '<html></html>');
}
return pkgDir;
}

/** Fresh sandbox: <tmp>/home/project is the cwd, <tmp>/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'), '<html></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);
});
});