Skip to content

Commit 748e3eb

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(cli): guard console resolution against stale out-of-workspace installs (#1778)
* chore: bump objectui to e95cc25b2c0d fix(app-shell): NavigationSyncEffect baseline race — readiness gate, sys_ filter, ADR-0010 lock filter (#1668) objectui@e95cc25b2c0d2fa680e232151721e71c19630659 * fix(cli): guard console resolution against stale out-of-workspace installs resolveConsolePath() resolves @objectstack/console via createRequire from the consumer cwd, and Node module resolution climbs node_modules directories past the workspace root. A stray install higher up the filesystem (e.g. a leftover ~/node_modules/@objectstack/console@7.8.0 from an npm experiment) won over the version-locked bundle, so `serve --dev` served a stale Console (browser-side OBJUI-001 "Unknown component type: marketplace:installed-list"). The vendored console package is version-locked to the framework release, so a healthy install always shares the CLI's major version. Skip any candidate from node resolution or the cwd/node_modules direct check whose major differs from the CLI's own, and warn with the path and both versions so the stray install is discoverable. The sibling-repo objectui dev fallback is exempt (independent versioning, explicit opt-in). If the CLI's own version can't be read the guard fails open. resolveConsolePath() now takes optional { cwd, cliVersion, warn } for testability; the serve.ts call site is unchanged. Also documents in AGENTS.md that passing work must land via branch + PR, not sit in the working tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 965884b commit 748e3eb

4 files changed

Lines changed: 261 additions & 13 deletions

File tree

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
18594c2ffcee72c02da99dfa8588146f78672fd0
1+
e95cc25b2c0d2fa680e232151721e71c19630659

AGENTS.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,14 @@ export default {
230230
## Post-Task Checklist
231231

232232
1. `pnpm test` — verify nothing broke.
233-
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.
234-
3. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
235-
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.
233+
2. **Land it — don't leave passing work in the working tree.** Once tests pass,
234+
create a feature branch, commit, push, open a PR, and merge it after remote
235+
CI is fully green (see Multi-agent discipline: never straight to `main`,
236+
never `gh pr merge --auto`). A finished task = a merged PR, not a dirty
237+
working tree.
238+
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.
239+
4. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
240+
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.
236241

237242
---
238243

packages/cli/src/utils/console.ts

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import path from 'path';
3636
import fs from 'fs';
3737
import { createRequire } from 'module';
38-
import { pathToFileURL } from 'url';
38+
import { pathToFileURL, fileURLToPath } from 'url';
3939

4040
// ─── Constants ──────────────────────────────────────────────────────
4141

@@ -45,6 +45,66 @@ export const CONSOLE_PATH = '/_console';
4545
/** Canonical npm package name that ships the Console SPA. */
4646
const CONSOLE_PACKAGE = '@objectstack/console';
4747

48+
// ─── Version Guard ──────────────────────────────────────────────────
49+
50+
/**
51+
* The vendored `@objectstack/console` package is version-locked to the
52+
* framework release, so a healthy install always carries the same major
53+
* as the CLI. Node module resolution from the consumer cwd, however,
54+
* climbs `node_modules` directories all the way up the filesystem — a
55+
* stray install outside the workspace (e.g. a leftover
56+
* `~/node_modules/@objectstack/console` from an old npm experiment) can
57+
* shadow the bundled build and silently serve a stale Console.
58+
*
59+
* Guard: skip any candidate whose major version differs from the CLI's
60+
* own, and warn so the stray install is discoverable.
61+
*/
62+
export function isConsoleVersionCompatible(
63+
candidateVersion: unknown,
64+
cliVersion: string,
65+
): boolean {
66+
if (typeof candidateVersion !== 'string') return false;
67+
const candidateMajor = majorOf(candidateVersion);
68+
const cliMajor = majorOf(cliVersion);
69+
return candidateMajor !== null && candidateMajor === cliMajor;
70+
}
71+
72+
function majorOf(version: string): number | null {
73+
const match = /^v?(\d+)[.-]/.exec(version.trim()) ?? /^v?(\d+)$/.exec(version.trim());
74+
return match ? Number(match[1]) : null;
75+
}
76+
77+
let cachedCliVersion: string | null | undefined;
78+
79+
/**
80+
* Read this CLI's own version by walking up from the compiled module to
81+
* the nearest `package.json`. Returns null (guard disabled, fail open)
82+
* if it can't be determined — never let the version check break
83+
* resolution outright.
84+
*/
85+
function getCliVersion(): string | null {
86+
if (cachedCliVersion !== undefined) return cachedCliVersion;
87+
let version: string | null = null;
88+
try {
89+
let dir = path.dirname(fileURLToPath(import.meta.url));
90+
for (let depth = 0; depth < 6; depth++) {
91+
const pkgPath = path.join(dir, 'package.json');
92+
if (fs.existsSync(pkgPath)) {
93+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
94+
if (typeof pkg.version === 'string') version = pkg.version;
95+
break;
96+
}
97+
const parent = path.dirname(dir);
98+
if (parent === dir) break;
99+
dir = parent;
100+
}
101+
} catch {
102+
// Unreadable own package.json — leave the guard disabled.
103+
}
104+
cachedCliVersion = version;
105+
return version;
106+
}
107+
48108
// ─── Path Resolution ────────────────────────────────────────────────
49109

50110
/**
@@ -70,9 +130,40 @@ const CONSOLE_PACKAGE = '@objectstack/console';
70130
* 3. Sibling-repo dev fallback — `../objectui/apps/console` — matched
71131
* by the package name on disk so an unrelated `apps/console`
72132
* doesn't get picked up by accident.
133+
*
134+
* Strategies 1 and 2 additionally require the candidate's major version
135+
* to match the CLI's own (see `isConsoleVersionCompatible`) — node
136+
* resolution climbs past the workspace root, so a stale install higher
137+
* up the filesystem must not shadow the version-locked bundle.
138+
* Mismatches are skipped with a warning. The sibling-repo fallback is
139+
* exempt: the objectui workspace versions independently and is an
140+
* explicit dev opt-in.
73141
*/
74-
export function resolveConsolePath(): string | null {
75-
const cwd = process.cwd();
142+
export interface ResolveConsoleOptions {
143+
/** Resolution origin; defaults to `process.cwd()`. */
144+
cwd?: string;
145+
/** Override the CLI's own version (tests). */
146+
cliVersion?: string;
147+
/** Warning sink; defaults to `console.warn`. */
148+
warn?: (message: string) => void;
149+
}
150+
151+
export function resolveConsolePath(options?: ResolveConsoleOptions): string | null {
152+
const cwd = options?.cwd ?? process.cwd();
153+
const cliVersion = options?.cliVersion ?? getCliVersion();
154+
const warn = options?.warn ?? ((message: string) => console.warn(message));
155+
156+
/** Version guard for vendored-package candidates (strategies 1 & 2). */
157+
const versionOk = (dir: string, candidateVersion: unknown): boolean => {
158+
if (!cliVersion) return true; // own version unknown — fail open
159+
if (isConsoleVersionCompatible(candidateVersion, cliVersion)) return true;
160+
const shown = typeof candidateVersion === 'string' ? candidateVersion : 'unknown';
161+
warn(
162+
` ⚠ Ignoring ${CONSOLE_PACKAGE}@${shown} at ${dir} — major version does not match this CLI (${cliVersion}). ` +
163+
`This is usually a stale install outside your workspace (e.g. a leftover ~/node_modules); remove it or install a matching version.`,
164+
);
165+
return false;
166+
};
76167

77168
const resolutionBases = [
78169
pathToFileURL(path.join(cwd, 'package.json')).href, // consumer workspace
@@ -91,7 +182,11 @@ export function resolveConsolePath(): string | null {
91182
const dir = path.dirname(resolvedPkgJson);
92183
try {
93184
const pkg = JSON.parse(fs.readFileSync(resolvedPkgJson, 'utf-8'));
94-
if (pkg.name === CONSOLE_PACKAGE && !candidates.includes(dir)) {
185+
if (
186+
pkg.name === CONSOLE_PACKAGE &&
187+
versionOk(dir, pkg.version) &&
188+
!candidates.includes(dir)
189+
) {
95190
candidates.push(dir);
96191
}
97192
} catch {
@@ -104,11 +199,16 @@ export function resolveConsolePath(): string | null {
104199

105200
// 2: direct filesystem check in cwd/node_modules.
106201
const directPath = path.join(cwd, 'node_modules', ...CONSOLE_PACKAGE.split('/'));
107-
if (
108-
fs.existsSync(path.join(directPath, 'package.json')) &&
109-
!candidates.includes(directPath)
110-
) {
111-
candidates.push(directPath);
202+
const directPkgJson = path.join(directPath, 'package.json');
203+
if (fs.existsSync(directPkgJson) && !candidates.includes(directPath)) {
204+
try {
205+
const pkg = JSON.parse(fs.readFileSync(directPkgJson, 'utf-8'));
206+
if (pkg.name === CONSOLE_PACKAGE && versionOk(directPath, pkg.version)) {
207+
candidates.push(directPath);
208+
}
209+
} catch {
210+
// Skip invalid package.json
211+
}
112212
}
113213

114214
// 3: sibling-repo dev fallback. Useful when iterating on the Console
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* resolveConsolePath() hardening — stale out-of-workspace installs.
5+
*
6+
* Node module resolution from the consumer cwd climbs `node_modules`
7+
* directories all the way up the filesystem. A stray
8+
* `~/node_modules/@objectstack/console` left behind by an old npm
9+
* experiment used to win over the version-locked bundle and serve a
10+
* stale Console (browser-side OBJUI-001 "Unknown component type").
11+
* These tests pin the major-version guard that skips such candidates.
12+
*/
13+
import { describe, it, expect } from 'vitest';
14+
import fs from 'fs';
15+
import os from 'os';
16+
import path from 'path';
17+
import {
18+
resolveConsolePath,
19+
isConsoleVersionCompatible,
20+
} from '../src/utils/console.js';
21+
22+
const CLI_VERSION = '9.2.0';
23+
24+
function writeConsolePackage(
25+
dir: string,
26+
{ name = '@objectstack/console', version, withDist = true }: {
27+
name?: string;
28+
version: string;
29+
withDist?: boolean;
30+
},
31+
): string {
32+
const pkgDir = path.join(dir, 'node_modules', '@objectstack', 'console');
33+
fs.mkdirSync(pkgDir, { recursive: true });
34+
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name, version }));
35+
if (withDist) {
36+
fs.mkdirSync(path.join(pkgDir, 'dist'), { recursive: true });
37+
fs.writeFileSync(path.join(pkgDir, 'dist', 'index.html'), '<html></html>');
38+
}
39+
return pkgDir;
40+
}
41+
42+
/** Fresh sandbox: <tmp>/home/project is the cwd, <tmp>/home simulates $HOME. */
43+
function makeSandbox(): { home: string; project: string } {
44+
// realpath: node's require.resolve returns symlink-resolved paths, and
45+
// macOS tmpdir lives behind the /var -> /private/var symlink.
46+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-resolve-')));
47+
const home = path.join(root, 'home');
48+
const project = path.join(home, 'project');
49+
fs.mkdirSync(project, { recursive: true });
50+
fs.writeFileSync(
51+
path.join(project, 'package.json'),
52+
JSON.stringify({ name: 'consumer-app', version: '1.0.0' }),
53+
);
54+
return { home, project };
55+
}
56+
57+
describe('isConsoleVersionCompatible', () => {
58+
it('accepts the same major', () => {
59+
expect(isConsoleVersionCompatible('9.2.0', '9.2.0')).toBe(true);
60+
expect(isConsoleVersionCompatible('9.0.1', '9.5.0')).toBe(true);
61+
expect(isConsoleVersionCompatible('9.3.0-beta.1', '9.2.0')).toBe(true);
62+
});
63+
64+
it('rejects a different major', () => {
65+
expect(isConsoleVersionCompatible('7.8.0', '9.2.0')).toBe(false);
66+
expect(isConsoleVersionCompatible('10.0.0', '9.2.0')).toBe(false);
67+
});
68+
69+
it('rejects missing or malformed versions', () => {
70+
expect(isConsoleVersionCompatible(undefined, '9.2.0')).toBe(false);
71+
expect(isConsoleVersionCompatible('', '9.2.0')).toBe(false);
72+
expect(isConsoleVersionCompatible('not-a-version', '9.2.0')).toBe(false);
73+
});
74+
});
75+
76+
describe('resolveConsolePath version guard', () => {
77+
it('skips a stale major-mismatched install climbed to outside the project, with a warning', () => {
78+
const { home, project } = makeSandbox();
79+
// The incident shape: ~/node_modules/@objectstack/console@7.8.0 with a
80+
// built dist, reachable from the project cwd by climbing node_modules.
81+
const stale = writeConsolePackage(home, { version: '7.8.0' });
82+
83+
const warnings: string[] = [];
84+
const result = resolveConsolePath({
85+
cwd: project,
86+
cliVersion: CLI_VERSION,
87+
warn: (m) => warnings.push(m),
88+
});
89+
90+
expect(result).not.toBe(stale);
91+
expect(warnings.some((m) => m.includes('7.8.0') && m.includes(stale))).toBe(true);
92+
});
93+
94+
it('accepts a same-major install climbed to from the project cwd', () => {
95+
const { home, project } = makeSandbox();
96+
const ok = writeConsolePackage(home, { version: '9.0.0' });
97+
98+
const warnings: string[] = [];
99+
const result = resolveConsolePath({
100+
cwd: project,
101+
cliVersion: CLI_VERSION,
102+
warn: (m) => warnings.push(m),
103+
});
104+
105+
expect(result).toBe(ok);
106+
expect(warnings).toEqual([]);
107+
});
108+
109+
it('prefers a matching local install over a stale parent-directory one', () => {
110+
const { home, project } = makeSandbox();
111+
writeConsolePackage(home, { version: '7.8.0' });
112+
const local = writeConsolePackage(project, { version: '9.2.0' });
113+
114+
const result = resolveConsolePath({
115+
cwd: project,
116+
cliVersion: CLI_VERSION,
117+
warn: () => {},
118+
});
119+
120+
expect(result).toBe(local);
121+
});
122+
123+
it('skips an install whose package.json carries no version', () => {
124+
const { home, project } = makeSandbox();
125+
const unversionedDir = path.join(home, 'node_modules', '@objectstack', 'console');
126+
fs.mkdirSync(path.join(unversionedDir, 'dist'), { recursive: true });
127+
fs.writeFileSync(
128+
path.join(unversionedDir, 'package.json'),
129+
JSON.stringify({ name: '@objectstack/console' }),
130+
);
131+
fs.writeFileSync(path.join(unversionedDir, 'dist', 'index.html'), '<html></html>');
132+
133+
const warnings: string[] = [];
134+
const result = resolveConsolePath({
135+
cwd: project,
136+
cliVersion: CLI_VERSION,
137+
warn: (m) => warnings.push(m),
138+
});
139+
140+
expect(result).not.toBe(unversionedDir);
141+
expect(warnings.some((m) => m.includes('unknown'))).toBe(true);
142+
});
143+
});

0 commit comments

Comments
 (0)