diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index 8266b872ee..64262d2a95 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -1046,6 +1046,7 @@ module.exports.detectWorkspace = nativeBinding.detectWorkspace; module.exports.documentationUrl = nativeBinding.documentationUrl; module.exports.downloadPackageManager = nativeBinding.downloadPackageManager; module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio; +module.exports.findMonorepoRoot = nativeBinding.findMonorepoRoot; module.exports.getVpDirs = nativeBinding.getVpDirs; module.exports.hasConfigKey = nativeBinding.hasConfigKey; module.exports.isDirectoryGitignored = nativeBinding.isDirectoryGitignored; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 1950b32369..a609a294a1 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3696,6 +3696,13 @@ export interface DownloadPackageManagerResult { /** Re-enable blocking stdio after Node.js has initialized its lazy standard streams. */ export declare function ensureBlockingStdio(): void; +/** + * Find the monorepo root containing `cwd` with the same rules as `vp run` and `vp install`. + * + * Returns `null` when `cwd` is not inside a pnpm or `package.json` workspace. + */ +export declare function findMonorepoRoot(cwd: string): string | null; + /** * Resolved on-disk category roots from [`vp_shared::EnvConfig`]. * diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index 9b0eea2f2a..76e7da788b 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -178,6 +178,24 @@ pub async fn detect_workspace(cwd: String) -> Result { } } +/// Find the monorepo root containing `cwd` with the same rules as `vp run` and `vp install`. +/// +/// Returns `null` when `cwd` is not inside a pnpm or `package.json` workspace. +#[napi] +pub fn find_monorepo_root(cwd: String) -> Result> { + let cwd = AbsolutePathBuf::new(cwd.into()).ok_or(Error::from_reason("invalid cwd"))?; + match find_workspace_root(&cwd) { + Ok((workspace_root, _)) => match workspace_root.workspace_file { + WorkspaceFile::PnpmWorkspaceYaml(_) | WorkspaceFile::NpmWorkspaceJson(_) => { + Ok(Some(workspace_root.path.as_path().to_string_lossy().to_string())) + } + WorkspaceFile::NonWorkspacePackage(_) => Ok(None), + }, + Err(PackageJsonNotFound(_)) => Ok(None), + Err(e) => Err(anyhow::Error::from(e).into()), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index e73a5187ce..ab82618d1f 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -63,6 +63,15 @@ describe('findViteConfigUp', () => { expect(result).toBeUndefined(); }); + it('should not search above stopDir when starting from its parent', () => { + fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), ''); + const stopDir = path.join(tempDir, 'monorepo'); + fs.mkdirSync(stopDir); + + const result = findViteConfigUp(tempDir, stopDir); + expect(result).toBeUndefined(); + }); + it('should prefer the closest config file', () => { const subDir = path.join(tempDir, 'packages', 'my-lib'); fs.mkdirSync(subDir, { recursive: true }); diff --git a/packages/cli/src/create/org-resolve.ts b/packages/cli/src/create/org-resolve.ts index 2aed63a82d..b3aa4fa29e 100644 --- a/packages/cli/src/create/org-resolve.ts +++ b/packages/cli/src/create/org-resolve.ts @@ -1,6 +1,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import { findWorkspaceRoot, hasViteConfig, resolveViteConfig } from '../resolve-vite-config.ts'; +import { findMonorepoRoot } from '../../binding/index.js'; +import { hasViteConfig, resolveViteConfig } from '../resolve-vite-config.ts'; import { cancelAndExit } from '../utils/prompts.ts'; import { CreateConfigSchemaError, @@ -204,9 +205,9 @@ export async function resolveOrgManifestForCreate(args: { * Read the `create` config (`defaultTemplate` + validated `templates`) from * a workspace's `vite.config.ts` in a single config evaluation. * - * By default, walks up from `startDir` via `findWorkspaceRoot` (monorepo - * markers only — `pnpm-workspace.yaml`, `workspaces` in `package.json`, - * `lerna.json`) so monorepo invocations from any subdirectory still pick up + * By default, walks up from `startDir` via `findMonorepoRoot` (monorepo + * markers only — `pnpm-workspace.yaml` or `workspaces` in `package.json`) + * so monorepo invocations from any subdirectory still pick up * the root config. Pass `walkUp: false` to read `startDir` directly when the * caller already holds the exact workspace root. * @@ -224,7 +225,7 @@ export async function getConfiguredCreate( options?: { walkUp?: boolean; throwOnReadError?: boolean }, ): Promise<{ defaultTemplate?: string; templates: CreateTemplateEntry[] }> { const projectRoot = - options?.walkUp === false ? startDir : (findWorkspaceRoot(startDir) ?? startDir); + options?.walkUp === false ? startDir : (findMonorepoRoot(startDir) ?? startDir); if (!hasViteConfig(projectRoot)) { return { templates: [] }; } diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 0632f109b1..4e3b177e91 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { findMonorepoRoot } from '../binding/index.js'; import { withConfigMetadataResolution } from './define-config.ts'; import { VITE_CONFIG_FILES } from './utils/constants.ts'; @@ -11,6 +12,11 @@ import { VITE_CONFIG_FILES } from './utils/constants.ts'; export function findViteConfigUp(startDir: string, stopDir: string): string | undefined { let dir = path.resolve(startDir); const stop = path.resolve(stopDir); + // Callers may start above `stopDir`, e.g. at the parent of a workspace root. + const relative = path.relative(stop, dir); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return undefined; + } while (true) { for (const filename of VITE_CONFIG_FILES) { @@ -20,7 +26,7 @@ export function findViteConfigUp(startDir: string, stopDir: string): string | un } } const parent = path.dirname(dir); - if (parent === dir || !parent.startsWith(stop)) { + if (dir === stop || parent === dir) { break; } dir = parent; @@ -42,39 +48,6 @@ export function hasViteConfig(dir: string): boolean { return findViteConfig(dir) !== undefined; } -/** - * Find the workspace root by walking up from `startDir` looking for - * monorepo indicators (pnpm-workspace.yaml, workspaces in package.json, lerna.json). - */ -export function findWorkspaceRoot(startDir: string): string | undefined { - let dir = path.resolve(startDir); - while (true) { - if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { - return dir; - } - const pkgPath = path.join(dir, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - if (pkg.workspaces) { - return dir; - } - } catch { - // Skip malformed package.json and continue searching parent directories - } - } - if (fs.existsSync(path.join(dir, 'lerna.json'))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; - } - return undefined; -} - export interface ResolveViteConfigOptions { traverseUp?: boolean; } @@ -89,7 +62,7 @@ export async function resolveViteConfig(cwd: string, options?: ResolveViteConfig // staged/create…), so skip the user's plugin factory while it evaluates. return withConfigMetadataResolution(async () => { if (options?.traverseUp && !hasViteConfig(cwd)) { - const workspaceRoot = findWorkspaceRoot(cwd); + const workspaceRoot = findMonorepoRoot(cwd); if (workspaceRoot) { const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); if (configFile) {