diff --git a/.changeset/cli-workspace-alias-derivation-3890.md b/.changeset/cli-workspace-alias-derivation-3890.md new file mode 100644 index 0000000000..13c0d382f9 --- /dev/null +++ b/.changeset/cli-workspace-alias-derivation-3890.md @@ -0,0 +1,25 @@ +--- +'@object-ui/cli': patch +--- + +Inside a pnpm workspace, `objectui dev` / `serve` / `build` now resolve every platform package +from workspace source (objectui#3890). + +The temp app these commands generate installs nothing inside a workspace — it resolves by +hoisting, and the repo root declares no `@object-ui/*` — so a Vite alias table is the only thing +that resolves a platform package there. That table was a hand-kept list of eleven names in +`dev`, which is not a list of what the app imports but of what it imports *transitively*: +measured on the reported commit, the generated entry closes over 21 packages, ten were unlisted, +and every module whose transform hit one of them answered 500 with a blank page behind it. Vite's +dependency scan named only four of the ten, because a scan stops at the first layer it cannot +resolve. + +The table is now derived from `pnpm-workspace.yaml` — every scoped workspace package that exposes +a source barrel, targeting its `src` directory — and a test reconciles it against the manifest so +it cannot drift again. `serve` and `build` had no workspace branch at all (no aliases, and an +unconditional `npm install` against a manifest that is empty here); all three commands now share +one helper. The `lucide-react` entry moved from a resolved entry file to the package root, so +subpath imports of it stop being rewritten into a path that cannot exist. + +Measured with the reported repro, from the repo root: 8 of the first 400 modules a browser walk +reaches answered 500 before, 0 of 2498 after, and the page renders its schema instead of nothing. diff --git a/packages/cli/src/__tests__/workspace-vite.test.ts b/packages/cli/src/__tests__/workspace-vite.test.ts new file mode 100644 index 0000000000..fd704fc121 --- /dev/null +++ b/packages/cli/src/__tests__/workspace-vite.test.ts @@ -0,0 +1,295 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Pins the module-resolution surface of the generated temp app (objectui#3890). + * + * Inside a workspace nothing is installed for the temp app — the alias table is + * the only thing that resolves a platform package — and the table used to be a + * hand-kept list of eleven names inside `commands/dev.ts`. Measured on the + * commit that filed the card: the generated entry's transitive value-imports + * close over 21 packages, so ten were unaliased, and every module whose + * transform hit one of them answered 500 (8 of the first 400 modules a browser + * walk reaches, `packages/plugin-grid/src/ObjectGrid.tsx` among them). Vite's + * dependency scan named only four of the ten, because a scan stops at the first + * layer it cannot resolve — which is why the gate below reconciles the table + * against `pnpm-workspace.yaml` rather than against a list of known-missing + * names. + * + * The reconciliation walks the workspace independently of the code it judges: + * the helper expands the manifest's patterns with `glob`, this file expands them + * with its own `readdirSync`. A gate that re-derives its expectation by calling + * the function under test proves only that the function is deterministic. + * + * `serve` and `build` had no workspace branch at all, so the last group asserts + * that all three commands reach this table through the one shared helper — the + * property that keeps them from drifting apart a second time. + */ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + isWorkspaceRoot, + resolveLucideAlias, + workspacePackageDirs, + workspaceSourceAliases +} from '../utils/workspace-vite.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +/** packages/cli/src/__tests__ -> repo root */ +const REPO_ROOT = resolve(__dirname, '../../../..'); + +const COMMAND_DIR = join(REPO_ROOT, 'packages/cli/src/commands'); +/** The three commands that generate and serve a temp app. */ +const TEMP_APP_COMMANDS = ['dev.ts', 'serve.ts', 'build.ts']; + +/** Extensions a bundler resolves a barrel through, in Vite's own order. */ +const BARREL_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx']; + +/** + * The workspace's package directories, expanded WITHOUT the helper under test. + * + * Deliberately hand-rolled and deliberately narrow: it understands the two + * pattern shapes this repo's manifest actually uses (a `dir/*` fan-out and a + * literal directory) and throws on anything else, so a manifest that grows a + * shape this expectation cannot read fails loudly here instead of quietly + * agreeing with whatever the helper returned. + */ +function independentWorkspaceDirs(): string[] { + const manifest = readFileSync(join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf-8'); + const patterns: string[] = []; + for (const line of manifest.split('\n')) { + const match = /^\s*-\s*['"]?([^'"#\s]+)['"]?\s*$/.exec(line); + if (match) patterns.push(match[1]); + } + expect(patterns.length).toBeGreaterThan(0); + + const dirs: string[] = []; + for (const pattern of patterns) { + if (pattern.endsWith('/*')) { + const parent = join(REPO_ROOT, pattern.slice(0, -2)); + if (!existsSync(parent)) continue; + for (const entry of readdirSync(parent)) { + const full = join(parent, entry); + if (statSync(full).isDirectory()) dirs.push(full); + } + } else if (!pattern.includes('*')) { + const full = join(REPO_ROOT, pattern); + if (existsSync(full)) dirs.push(full); + } else { + throw new Error( + `pnpm-workspace.yaml grew the pattern ${JSON.stringify(pattern)} — teach this expectation how to expand it.` + ); + } + } + return dirs; +} + +/** Every `@object-ui/*` workspace package that can be consumed from source. */ +function independentAliasablePackages(): Map { + const found = new Map(); + for (const dir of independentWorkspaceDirs()) { + const manifestPath = join(dir, 'package.json'); + if (!existsSync(manifestPath)) continue; + const name = (JSON.parse(readFileSync(manifestPath, 'utf-8')) as { name?: unknown }).name; + if (typeof name !== 'string' || !name.startsWith('@object-ui/')) continue; + const srcDir = join(dir, 'src'); + if (!BARREL_EXTENSIONS.some((ext) => existsSync(join(srcDir, `index${ext}`)))) continue; + found.set(name, srcDir); + } + return found; +} + +const aliases = workspaceSourceAliases(REPO_ROOT); + +describe('workspace alias table (objectui#3890)', () => { + it('covers every @object-ui package the workspace manifest declares', () => { + const expected = independentAliasablePackages(); + + // Sanity: the reconciliation is worthless if either side is empty, and the + // hand-kept table it replaces held eleven names. + expect(expected.size).toBeGreaterThan(11); + + expect(Object.keys(aliases).sort()).toEqual([...expected.keys()].sort()); + for (const [name, srcDir] of expected) { + expect(aliases[name]).toBe(srcDir); + } + }); + + it('aliases the four packages the card measured as missing', () => { + // Named individually because these are the ones a browser proved 500 — + // the reconciliation above would still pass if `pnpm-workspace.yaml` and + // the table drifted together. + for (const name of [ + '@object-ui/fields', + '@object-ui/permissions', + '@object-ui/mobile', + '@object-ui/plugin-detail' + ]) { + expect(aliases[name]).toBeDefined(); + expect(existsSync(aliases[name])).toBe(true); + } + }); + + it('aliases the six the dependency scan could not reach past them', () => { + // A Vite dependency scan reports only the first layer it fails on, so the + // card's list of four was the visible half. These six sit behind them in + // the same import graph and would have stayed broken under a backfill. + for (const name of [ + '@object-ui/data-objectstack', + '@object-ui/i18n', + '@object-ui/plugin-map', + '@object-ui/providers', + '@object-ui/react-runtime', + '@object-ui/sdui-parser' + ]) { + expect(aliases[name]).toBeDefined(); + } + }); + + it('resolves barrels spelled .ts and .tsx alike', () => { + // The entry-inference rule was the stated cost of deriving the table. + // Probing in the resolver's extension order settles it: both spellings are + // present in this repo and both must be found. + expect(existsSync(join(aliases['@object-ui/core'], 'index.ts'))).toBe(true); + expect(existsSync(join(aliases['@object-ui/fields'], 'index.tsx'))).toBe(true); + }); + + it('targets source directories, so a subpath import lands in the tree', () => { + // An alias rewrites the matched prefix and keeps the rest. A file target + // turns `/` into `/`, which cannot + // exist; a directory target lands in the package's own sources. + for (const [name, target] of Object.entries(aliases)) { + expect(statSync(target).isDirectory(), `${name} must alias a directory`).toBe(true); + expect(BARREL_EXTENSIONS.some((ext) => existsSync(join(target, `index${ext}`)))).toBe(true); + } + }); + + it('excludes a workspace package with no source barrel', () => { + // `@object-ui/runner` has a `src/` and no barrel: an alias to it would only + // trade one resolver error for another, so the rule is "consumable from + // source", not "is a directory". + const runner = join(REPO_ROOT, 'packages/runner'); + expect(existsSync(join(runner, 'src'))).toBe(true); + expect(BARREL_EXTENSIONS.some((ext) => existsSync(join(runner, 'src', `index${ext}`)))).toBe(false); + expect(aliases['@object-ui/runner']).toBeUndefined(); + }); +}); + +describe('lucide-react alias (objectui#3890)', () => { + const lucide = resolveLucideAlias(REPO_ROOT); + + it('points at the package root rather than a resolved entry file', () => { + expect(lucide).toBeDefined(); + const manifest = JSON.parse(readFileSync(join(lucide as string, 'package.json'), 'utf-8')) as { name?: string }; + expect(manifest.name).toBe('lucide-react'); + }); + + it('keeps the subpath the component library imports resolvable', () => { + // The specifier is read out of the importer instead of being written here, + // so this pins the real consumer rather than a copy of it. With the entry + // file as the alias target, this rewrite produced `/` and + // `packages/components/src/lib/lazy-icon.tsx` answered 500 once the + // platform aliases made it reachable at all. + const importer = readFileSync(join(REPO_ROOT, 'packages/components/src/lib/lazy-icon.tsx'), 'utf-8'); + const match = /['"]lucide-react\/([^'"]+)['"]/.exec(importer); + expect(match, 'lazy-icon.tsx no longer imports a lucide-react subpath').not.toBeNull(); + + const rewritten = join(lucide as string, (match as RegExpExecArray)[1]); + expect(existsSync(rewritten), `${rewritten} must exist for the aliased subpath to resolve`).toBe(true); + }); +}); + +describe('the derivation rule itself', () => { + /** A throwaway workspace, so the rule is judged on inputs this repo lacks. */ + function withFixture(run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), 'objectui-ws-alias-')); + try { + writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n - 'tools'\n"); + const write = (dir: string, name: string, barrel?: string): void => { + mkdirSync(join(root, dir, 'src'), { recursive: true }); + writeFileSync(join(root, dir, 'package.json'), JSON.stringify({ name, version: '0.0.0' })); + if (barrel) writeFileSync(join(root, dir, 'src', barrel), 'export {};\n'); + }; + write('packages/alpha', '@object-ui/alpha', 'index.ts'); + write('packages/beta', '@object-ui/beta', 'index.tsx'); + write('packages/gamma', '@object-ui/gamma'); // no barrel + write('packages/outsider', 'unscoped-package', 'index.ts'); + write('tools', '@object-ui/tools', 'index.ts'); // literal pattern, not a fan-out + mkdirSync(join(root, 'packages/not-a-package'), { recursive: true }); // no manifest + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + + it('takes scoped packages with a barrel, from both pattern shapes', () => { + withFixture((root) => { + expect(Object.keys(workspaceSourceAliases(root)).sort()).toEqual([ + '@object-ui/alpha', + '@object-ui/beta', + '@object-ui/tools' + ]); + }); + }); + + it('drops a scoped package the moment its barrel goes away', () => { + // The planted-defect direction: a table that is green because it produced + // nothing is not a table. Removing the barrel must remove the entry. + withFixture((root) => { + expect(workspaceSourceAliases(root)['@object-ui/alpha']).toBeDefined(); + rmSync(join(root, 'packages/alpha/src/index.ts')); + expect(workspaceSourceAliases(root)['@object-ui/alpha']).toBeUndefined(); + }); + }); + + it('reads the manifest, not the directory layout', () => { + withFixture((root) => { + expect(workspacePackageDirs(root).length).toBe(5); + rmSync(join(root, 'pnpm-workspace.yaml')); + expect(isWorkspaceRoot(root)).toBe(false); + expect(workspacePackageDirs(root)).toEqual([]); + expect(workspaceSourceAliases(root)).toEqual({}); + }); + }); +}); + +describe('dev / serve / build consistency (objectui#3890)', () => { + const sources = new Map( + TEMP_APP_COMMANDS.map((file) => [file, readFileSync(join(COMMAND_DIR, file), 'utf-8')] as const) + ); + + it('routes all three commands through the shared workspace helper', () => { + for (const [file, source] of sources) { + expect(source, `${file} must build its workspace config from the shared helper`).toContain( + 'prepareWorkspaceTempApp' + ); + } + }); + + it('gives all three a workspace branch', () => { + // `serve` and `build` had none: they ran `npm install` unconditionally + // against a manifest that is empty inside a workspace, then handed Vite a + // config with no aliases at all. + for (const [file, source] of sources) { + expect(source, `${file} must detect a workspace root`).toContain('isWorkspaceRoot'); + } + }); + + it('leaves no hand-written alias entry behind in any of them', () => { + // The shape that drifted: a quoted platform specifier used as an object key. + const handWritten = /['"]@object-ui\/[a-z0-9-]+['"]\s*:/; + for (const [file, source] of sources) { + expect(handWritten.test(source), `${file} still spells an alias entry by hand`).toBe(false); + } + }); +}); diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 63f6744b84..77867229f1 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -13,6 +13,7 @@ import { join, resolve } from 'path'; import chalk from 'chalk'; import { execSync } from 'child_process'; import { scanPagesDirectory, createTempAppWithRouting, createTempApp, parseSchemaFile, type RouteInfo } from '../utils/app-generator.js'; +import { isWorkspaceRoot, prepareWorkspaceTempApp } from '../utils/workspace-vite.js'; interface BuildOptions { outDir?: string; @@ -81,16 +82,31 @@ export async function buildApp(schemaPath: string, options: BuildOptions) { } // Install dependencies - console.log(chalk.blue('📦 Installing dependencies...')); - try { - execSync('npm install --silent --prefer-offline', { - cwd: tmpDir, - stdio: 'pipe', - }); - console.log(chalk.green('✓ Dependencies installed')); - } catch { - throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); + const isMonorepo = isWorkspaceRoot(cwd); + + if (isMonorepo) { + console.log(chalk.blue('📦 Detected monorepo - using root node_modules')); + } else { + console.log(chalk.blue('📦 Installing dependencies...')); + try { + execSync('npm install --silent --prefer-offline', { + cwd: tmpDir, + stdio: 'pipe', + }); + console.log(chalk.green('✓ Dependencies installed')); + } catch { + throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); + } + } + + // Everything the temp app needs to resolve platform packages from workspace + // source — the alias table, and the PostCSS pipeline that replaces the + // generated config file. Shared with `dev` and `serve` so the three cannot + // drift apart (objectui#3890); see `utils/workspace-vite.ts`. + if (isMonorepo) { + console.log(chalk.blue('📦 Detected monorepo - configuring workspace aliases')); } + const workspaceConfig = isMonorepo ? await prepareWorkspaceTempApp(cwd, tmpDir) : {}; console.log(chalk.blue('⚙️ Building with Vite...')); console.log(); @@ -112,6 +128,7 @@ export async function buildApp(schemaPath: string, options: BuildOptions) { }, plugins: [react()], logLevel: 'info', + ...workspaceConfig, }); // Copy built files to output directory diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 141f7e0a06..5e3778ca2a 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -8,12 +8,12 @@ import { createServer } from 'vite'; import react from '@vitejs/plugin-react'; -import { existsSync, mkdirSync, unlinkSync, statSync } from 'fs'; +import { existsSync, mkdirSync, statSync } from 'fs'; import { join, resolve, dirname } from 'path'; import chalk from 'chalk'; import { execSync } from 'child_process'; -import { createRequire } from 'module'; import { scanPagesDirectory, createTempAppWithRouting, createTempApp, parseSchemaFile, type RouteInfo } from '../utils/app-generator.js'; +import { isWorkspaceRoot, prepareWorkspaceTempApp } from '../utils/workspace-vite.js'; interface DevOptions { port: string; @@ -21,51 +21,6 @@ interface DevOptions { open?: boolean; } -/** - * The PostCSS plugins the workspace-local temp app is served with. - * - * Inside a workspace the generated app installs nothing (it resolves everything - * by hoisting) and its own `postcss.config.js` is removed, so this is the only - * thing that compiles its stylesheet. Two things about it are deliberate: - * - * 1. **`@tailwindcss/postcss`, not `tailwindcss`.** Tailwind 4 moved the PostCSS - * plugin into its own package; calling `tailwindcss()` as a plugin — which - * this did until objectui#3852, passing it the generated - * `tailwind.config.js` — hits a shim whose only job is to throw ("It looks - * like you're trying to use `tailwindcss` directly as a PostCSS plugin"). The - * config-file argument goes away with it: v4 is CSS-first and the generated - * `src/index.css` carries its own `@source`/`@theme` (see `app-generator.ts`). - * 2. **Resolved from this CLI, and loud when it cannot be.** Both plugins are - * declared by `@object-ui/cli` itself, so a bare `import` finds them wherever - * the CLI is installed — rather than depending on what the invoking project - * happens to hoist (this repo's root declares `tailwindcss` but NOT - * `@tailwindcss/postcss`, so resolving from the cwd cannot work here at all). - * A failure throws with the reason attached: the previous `catch` warned one - * yellow line and served the app with no stylesheet, which is exactly how a - * dead CSS pipeline stayed unnoticed long enough to be filed as - * objectui#3852. - */ -async function loadTempAppPostcssPlugins(): Promise { - try { - const [tailwindPostcss, autoprefixer] = await Promise.all([ - import('@tailwindcss/postcss'), - import('autoprefixer') - ]); - return [tailwindPostcss.default(), autoprefixer.default()]; - } catch (error) { - // The caught error's message is inlined below. We can't pass it as the - // `Error` `cause` option because this package targets ES2020, whose lib - // types the 1-arg `Error` constructor only; hence the scoped disable. - // eslint-disable-next-line preserve-caught-error - throw new Error( - `Failed to load the Tailwind CSS PostCSS pipeline: ${error instanceof Error ? error.message : error}\n` + - ` Both '@tailwindcss/postcss' and 'autoprefixer' are dependencies of @object-ui/cli — a\n` + - ` broken install of the CLI is the likeliest cause; reinstall it and try again.\n` + - ` (Refusing to start unstyled: that failure is silent in the browser.)` - ); - } -} - export async function dev(schemaPath: string, options: DevOptions) { const cwd = process.cwd(); @@ -119,8 +74,6 @@ export async function dev(schemaPath: string, options: DevOptions) { } } - const require = createRequire(join(cwd, 'package.json')); - let routes: RouteInfo[] = []; let schema: unknown = null; let useFileSystemRouting = false; @@ -171,8 +124,8 @@ export async function dev(schemaPath: string, options: DevOptions) { // Install dependencies - const isMonorepo = existsSync(join(cwd, 'pnpm-workspace.yaml')); - + const isMonorepo = isWorkspaceRoot(cwd); + if (isMonorepo) { console.log(chalk.blue('📦 Detected monorepo - using root node_modules')); } else { @@ -192,6 +145,15 @@ export async function dev(schemaPath: string, options: DevOptions) { console.log(chalk.green('✓ Schema loaded successfully')); console.log(chalk.blue('🚀 Starting development server...\n')); + // Everything the temp app needs to resolve platform packages from workspace + // source — the alias table, and the PostCSS pipeline that replaces the + // generated config file. Shared with `serve` and `build` so the three cannot + // drift apart (objectui#3890); see `utils/workspace-vite.ts`. + if (isMonorepo) { + console.log(chalk.blue('📦 Detected monorepo - configuring workspace aliases')); + } + const workspaceConfig = isMonorepo ? await prepareWorkspaceTempApp(cwd, tmpDir) : {}; + // Create Vite config const viteConfig: any = { root: tmpDir, @@ -204,61 +166,10 @@ export async function dev(schemaPath: string, options: DevOptions) { allow: [cwd], } }, - resolve: { - alias: {} - }, plugins: [react()], + ...workspaceConfig, }; - if (isMonorepo) { - console.log(chalk.blue('📦 Detected monorepo - configuring workspace aliases')); - - // Remove postcss.config.js: the programmatic `css.postcss` below takes over - // (an inline config makes Vite skip config-file discovery entirely), and the - // generated file names `@tailwindcss/postcss` — a package the temp app never - // installs inside a workspace, and one this repo's root does not declare - // either, so leaving the file for a later Vite pass to find would only - // reintroduce an unresolvable plugin. - const postcssPath = join(tmpDir, 'postcss.config.js'); - if (existsSync(postcssPath)) { - unlinkSync(postcssPath); - } - - // Add aliases for workspace packages - viteConfig.resolve.alias = { - '@object-ui/react': join(cwd, 'packages/react/src/index.ts'), - '@object-ui/components': join(cwd, 'packages/components/src/index.ts'), - '@object-ui/core': join(cwd, 'packages/core/src/index.ts'), - '@object-ui/types': join(cwd, 'packages/types/src/index.ts'), - '@object-ui/plugin-charts': join(cwd, 'packages/plugin-charts/src/index.tsx'), - '@object-ui/plugin-editor': join(cwd, 'packages/plugin-editor/src/index.tsx'), - '@object-ui/plugin-kanban': join(cwd, 'packages/plugin-kanban/src/index.tsx'), - '@object-ui/plugin-markdown': join(cwd, 'packages/plugin-markdown/src/index.tsx'), - '@object-ui/plugin-form': join(cwd, 'packages/plugin-form/src/index.tsx'), - '@object-ui/plugin-grid': join(cwd, 'packages/plugin-grid/src/index.tsx'), - '@object-ui/plugin-view': join(cwd, 'packages/plugin-view/src/index.tsx'), - }; - - // Fix: Resolve lucide-react from components package to avoid "dependency not found" in temp app - try { - // Trying to find lucide-react in the components' node_modules or hoist - // checking specifically in packages/components context - const lucidePath = require.resolve('lucide-react', { paths: [join(cwd, 'packages/components')] }); - // We might get the cjs entry, but for aliasing usually fine. - // Better yet, if we can find the package root, but require.resolve gives file. - // Let's just use what require.resolve gives. - viteConfig.resolve.alias['lucide-react'] = lucidePath; - } catch (e) { - console.warn('⚠️ Could not resolve lucide-react automatically:', e); - } - - // Debug aliases - // console.log('Aliases:', viteConfig.resolve.alias); - - // Configure PostCSS programmatically — see `loadTempAppPostcssPlugins`. - viteConfig.css = { postcss: { plugins: await loadTempAppPostcssPlugins() } }; - } - // Create Vite server const server = await createServer(viteConfig); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index bbd952256c..d26aa94b39 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -13,6 +13,7 @@ import { join, resolve, relative } from 'path'; import chalk from 'chalk'; import { execSync } from 'child_process'; import { scanPagesDirectory, createTempAppWithRouting, createTempApp, parseSchemaFile, type RouteInfo } from '../utils/app-generator.js'; +import { isWorkspaceRoot, prepareWorkspaceTempApp } from '../utils/workspace-vite.js'; interface ServeOptions { port: string; @@ -79,21 +80,36 @@ export async function serve(schemaPath: string, options: ServeOptions) { } // Install dependencies - console.log(chalk.blue('📦 Installing dependencies...')); - console.log(chalk.dim(' This may take a moment on first run...')); - try { - execSync('npm install --silent --prefer-offline', { - cwd: tmpDir, - stdio: 'inherit', - }); - console.log(chalk.green('✓ Dependencies installed')); - } catch { - throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); + const isMonorepo = isWorkspaceRoot(cwd); + + if (isMonorepo) { + console.log(chalk.blue('📦 Detected monorepo - using root node_modules')); + } else { + console.log(chalk.blue('📦 Installing dependencies...')); + console.log(chalk.dim(' This may take a moment on first run...')); + try { + execSync('npm install --silent --prefer-offline', { + cwd: tmpDir, + stdio: 'inherit', + }); + console.log(chalk.green('✓ Dependencies installed')); + } catch { + throw new Error('Failed to install dependencies. Please check your internet connection and try again.'); + } } console.log(chalk.green('✓ Schema loaded successfully')); console.log(chalk.blue('🚀 Starting development server...\n')); + // Everything the temp app needs to resolve platform packages from workspace + // source — the alias table, and the PostCSS pipeline that replaces the + // generated config file. Shared with `dev` and `build` so the three cannot + // drift apart (objectui#3890); see `utils/workspace-vite.ts`. + if (isMonorepo) { + console.log(chalk.blue('📦 Detected monorepo - configuring workspace aliases')); + } + const workspaceConfig = isMonorepo ? await prepareWorkspaceTempApp(cwd, tmpDir) : {}; + // Create Vite config const viteConfig = { root: tmpDir, @@ -101,8 +117,13 @@ export async function serve(schemaPath: string, options: ServeOptions) { port: parseInt(options.port), host: options.host, open: true, + fs: { + // Allow serving the workspace sources the aliases point at + allow: [cwd], + }, }, plugins: [react()], + ...workspaceConfig, }; // Create Vite server diff --git a/packages/cli/src/utils/workspace-vite.ts b/packages/cli/src/utils/workspace-vite.ts new file mode 100644 index 0000000000..c99ad9789f --- /dev/null +++ b/packages/cli/src/utils/workspace-vite.ts @@ -0,0 +1,268 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * How the generated temp app resolves its imports when it runs INSIDE this + * workspace — shared by `dev`, `serve` and `build` (objectui#3890). + * + * Outside a workspace the generated `package.json` declares every platform + * package and the command installs it; inside one, both dependency maps are + * written empty on purpose and nothing is installed, because the app is created + * under `/.objectui-tmp` and resolves by hoisting (objectui#3742 / + * objectui#3827). The repo root hoists React and the toolchain, but it declares + * no `@object-ui/*` at all — `node_modules/@object-ui/` does not exist here — so + * the ONLY thing that resolves a platform package in a workspace is the alias + * table below. + * + * ## Why the table is derived rather than written out + * + * It used to be a hand-kept list of eleven packages in `commands/dev.ts`. A + * hand-kept list is not a list of what the app imports — it is a list of what + * the app imports TRANSITIVELY, which is the import graph of every source file + * of every package already in it. Nothing enforced that, so it drifted, and the + * drift is silent until a browser opens the page: every module whose transform + * hits an unlisted specifier answers 500, and the page renders blank. + * + * Measured on the commit that filed objectui#3890: the app's own entry names + * nine packages, whose transitive value-imports close over 21, of which the + * table held 11. Vite's dependency scan reported only 4 of the 10 missing ones, + * because a scan stops at the first layer it cannot resolve — which is exactly + * why backfilling "the missing four" would have left six more behind and the + * page still blank. Deriving the table from the workspace removes the class: + * a cross-package import added tomorrow is already covered. + * + * ## Completeness is the safe direction + * + * The table names every workspace package rather than the reachable subset, + * because the two errors are not symmetric: an alias for a package nothing + * imports is inert (an alias only applies to an import that matches it), while + * a missing alias for a package something imports is a 500. So the rule errs + * toward listing too much, and the test beside it reconciles the table against + * `pnpm-workspace.yaml` so a new package cannot be missing from it. + */ + +import { existsSync, readFileSync, statSync, unlinkSync } from 'fs'; +import { createRequire } from 'module'; +import { dirname, join } from 'path'; + +import { globSync } from 'glob'; +import { load as loadYaml } from 'js-yaml'; + +/** The scope whose packages the generated app resolves from workspace source. */ +const PLATFORM_SCOPE = '@object-ui/'; + +/** + * Extensions probed for a package's source barrel, in Vite's own + * `resolve.extensions` order. + * + * The order matters for the same reason `scripts/__tests__/side-effects- + * declaration-consistency.test.ts` says it does: barrels in this repo are + * spelled both `.ts` and `.tsx` (`@object-ui/core` vs `@object-ui/fields`), and + * assuming either one is what made the entry-inference rule look like a cost of + * deriving the table. Probing in the resolver's order is not a guess — it is + * the answer the resolver itself would give. + */ +const BARREL_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx']; + +/** Whether `cwd` is a pnpm workspace root, i.e. whether aliasing applies. */ +export function isWorkspaceRoot(cwd: string): boolean { + return existsSync(join(cwd, 'pnpm-workspace.yaml')); +} + +/** The `packages:` patterns declared by `pnpm-workspace.yaml`, verbatim. */ +function workspacePatterns(cwd: string): string[] { + const manifestPath = join(cwd, 'pnpm-workspace.yaml'); + if (!existsSync(manifestPath)) return []; + const parsed = loadYaml(readFileSync(manifestPath, 'utf-8')) as { packages?: unknown } | null; + const patterns = parsed?.packages; + if (!Array.isArray(patterns)) return []; + return patterns.filter((p): p is string => typeof p === 'string'); +} + +/** + * Every directory the workspace manifest's patterns match that holds a manifest. + * + * Expanded with `glob` rather than a hand-rolled `endsWith('/*')` walk so a + * workspace spelled with `**` or with a `!` exclusion — both legal pnpm, neither + * used by this repo today — resolves correctly instead of throwing at a user + * whose only mistake was a different workspace layout. + */ +export function workspacePackageDirs(cwd: string): string[] { + const patterns = workspacePatterns(cwd); + const include = patterns.filter((p) => !p.startsWith('!')); + const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)); + if (include.length === 0) return []; + + const matches = globSync(include, { + cwd, + absolute: true, + ignore: ['**/node_modules/**', ...exclude] + }); + + return matches + .filter((dir) => existsSync(join(dir, 'package.json')) && statSync(dir).isDirectory()) + .sort(); +} + +/** The source barrel inside `srcDir`, or `undefined` when it has none. */ +function findSourceBarrel(srcDir: string): string | undefined { + for (const ext of BARREL_EXTENSIONS) { + const candidate = join(srcDir, `index${ext}`); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +/** + * The `@object-ui/*` alias table, derived from the workspace manifest. + * + * Targets the package's `src` DIRECTORY, not the barrel file inside it — + * matching how every checked-in bundler config in this repo aliases the same + * packages. A directory target is also the only one that survives a subpath + * import: an alias replaces the matched prefix, so a file target rewrites + * `//` into `/`, a path that cannot + * exist. With a directory the same rewrite lands in the source tree, and Vite + * resolves the bare specifier through the directory's barrel by itself. + * + * A package is included when it exposes a barrel, which is the property that + * makes it consumable from source at all — `@object-ui/runner` has a `src/` and + * no barrel, and an alias pointing at it would only turn one resolver error + * into another. + */ +export function workspaceSourceAliases(cwd: string): Record { + const aliases: Record = {}; + + for (const dir of workspacePackageDirs(cwd)) { + let name: unknown; + try { + name = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) as { name?: unknown }).name; + } catch { + continue; + } + if (typeof name !== 'string' || !name.startsWith(PLATFORM_SCOPE)) continue; + + const srcDir = join(dir, 'src'); + if (!existsSync(srcDir) || !findSourceBarrel(srcDir)) continue; + + aliases[name] = srcDir; + } + + return aliases; +} + +/** + * Where `lucide-react` is resolved from for the temp app. + * + * The generated sources import it directly, and inside a workspace the temp app + * has no `node_modules` of its own while the repo root declares no icon library + * either — so it is resolved out of `@object-ui/components`, which does declare + * it. + * + * Returns the package ROOT, not the entry file `require.resolve` reports. The + * entry-file spelling this replaces made the alias hijack every subpath import + * in the workspace and rewrite it into `/`: with the + * platform packages aliased and their sources finally reachable, + * `packages/components/src/lib/lazy-icon.tsx` answered 500 for exactly that + * reason (it imports the library's dynamic-icon subpath). Aliasing the root + * makes bare and subpath specifiers land on the same directory the importer + * would have reached without an alias, and lets Vite pick the entry the + * package's own `exports` names rather than pinning the app to whichever build + * CommonJS resolution happens to report. + */ +export function resolveLucideAlias(cwd: string): string | undefined { + const require = createRequire(join(cwd, 'package.json')); + try { + return dirname(require.resolve('lucide-react/package.json', { paths: [join(cwd, 'packages/components')] })); + } catch { + return undefined; + } +} + +/** + * The PostCSS plugins the workspace-local temp app is served with. + * + * Inside a workspace the generated app installs nothing (it resolves everything + * by hoisting) and its own `postcss.config.js` is removed, so this is the only + * thing that compiles its stylesheet. Two things about it are deliberate: + * + * 1. **`@tailwindcss/postcss`, not `tailwindcss`.** Tailwind 4 moved the PostCSS + * plugin into its own package; calling `tailwindcss()` as a plugin — which + * this did until objectui#3852, passing it the generated + * `tailwind.config.js` — hits a shim whose only job is to throw ("It looks + * like you're trying to use `tailwindcss` directly as a PostCSS plugin"). The + * config-file argument goes away with it: v4 is CSS-first and the generated + * `src/index.css` carries its own `@source`/`@theme` (see `app-generator.ts`). + * 2. **Resolved from this CLI, and loud when it cannot be.** Both plugins are + * declared by `@object-ui/cli` itself, so a bare `import` finds them wherever + * the CLI is installed — rather than depending on what the invoking project + * happens to hoist (this repo's root declares `tailwindcss` but NOT + * `@tailwindcss/postcss`, so resolving from the cwd cannot work here at all). + * A failure throws with the reason attached: the previous `catch` warned one + * yellow line and served the app with no stylesheet, which is exactly how a + * dead CSS pipeline stayed unnoticed long enough to be filed as + * objectui#3852. + */ +export async function loadTempAppPostcssPlugins(): Promise { + try { + const [tailwindPostcss, autoprefixer] = await Promise.all([ + import('@tailwindcss/postcss'), + import('autoprefixer') + ]); + return [tailwindPostcss.default(), autoprefixer.default()]; + } catch (error) { + // The caught error's message is inlined below. We can't pass it as the + // `Error` `cause` option because this package targets ES2020, whose lib + // types the 1-arg `Error` constructor only; hence the scoped disable. + // eslint-disable-next-line preserve-caught-error + throw new Error( + `Failed to load the Tailwind CSS PostCSS pipeline: ${error instanceof Error ? error.message : error}\n` + + ` Both '@tailwindcss/postcss' and 'autoprefixer' are dependencies of @object-ui/cli — a\n` + + ` broken install of the CLI is the likeliest cause; reinstall it and try again.\n` + + ` (Refusing to start unstyled: that failure is silent in the browser.)` + ); + } +} + +/** The Vite settings a temp app needs to run from workspace source. */ +export interface WorkspaceViteConfig { + resolve: { alias: Record }; + css: { postcss: { plugins: unknown[] } }; +} + +/** + * Everything `dev`, `serve` and `build` must add to their Vite config when the + * temp app is generated inside a workspace, plus the one file they must remove. + * + * Kept as a single call the three commands spread into their config so they + * cannot drift apart again: `serve` and `build` had no workspace branch at all + * (objectui#3890), so both were incapable of resolving a platform package here + * — a defect no amount of correctness in `dev` could cover. + * + * The removal is of the generated `postcss.config.js`: the programmatic + * `css.postcss` returned here takes over (an inline config makes Vite skip + * config-file discovery entirely), and the generated file names a plugin the + * temp app never installs inside a workspace, so leaving it for a later Vite + * pass to find would only reintroduce an unresolvable plugin. + */ +export async function prepareWorkspaceTempApp(cwd: string, tmpDir: string): Promise { + const postcssPath = join(tmpDir, 'postcss.config.js'); + if (existsSync(postcssPath)) { + unlinkSync(postcssPath); + } + + const alias: Record = { ...workspaceSourceAliases(cwd) }; + const lucide = resolveLucideAlias(cwd); + if (lucide) { + alias['lucide-react'] = lucide; + } + + return { + resolve: { alias }, + css: { postcss: { plugins: await loadTempAppPostcssPlugins() } } + }; +}