From 603b50440702e33b19bea41e4c96bbb2ec9625b2 Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 17 Oct 2026 22:58:19 +0800 Subject: [PATCH 1/3] refactor(cli): find the monorepo root through the native binding --- packages/cli/binding/index.cjs | 1 + packages/cli/binding/index.d.cts | 7 ++++ packages/cli/binding/src/package_manager.rs | 18 ++++++++++ .../src/create/__tests__/org-resolve.spec.ts | 14 ++++++++ packages/cli/src/create/org-resolve.ts | 13 +++---- packages/cli/src/resolve-vite-config.ts | 36 ++----------------- 6 files changed, 49 insertions(+), 40 deletions(-) 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/create/__tests__/org-resolve.spec.ts b/packages/cli/src/create/__tests__/org-resolve.spec.ts index b1f480f8bd..29106bb70f 100644 --- a/packages/cli/src/create/__tests__/org-resolve.spec.ts +++ b/packages/cli/src/create/__tests__/org-resolve.spec.ts @@ -41,6 +41,20 @@ describe('getConfiguredDefaultTemplate', () => { expect(await getConfiguredDefaultTemplate(deep)).toBe('@your-org'); }); + it('walks up to a package.json workspace root saved with a BOM', async () => { + fs.writeFileSync( + path.join(repoRoot, 'vite.config.ts'), + "export default { create: { defaultTemplate: '@your-org' } };\n", + ); + fs.writeFileSync( + path.join(repoRoot, 'package.json'), + '{"name":"fixture","workspaces":["apps/*"]}', + ); + const deep = path.join(repoRoot, 'apps', 'web'); + fs.mkdirSync(deep, { recursive: true }); + expect(await getConfiguredDefaultTemplate(deep)).toBe('@your-org'); + }); + it('returns undefined when no vite.config exists anywhere up the tree', async () => { const deep = path.join(repoRoot, 'nested'); fs.mkdirSync(deep, { recursive: true }); diff --git a/packages/cli/src/create/org-resolve.ts b/packages/cli/src/create/org-resolve.ts index 2aed63a82d..5c05d7f6bb 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,10 +205,10 @@ 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 - * the root config. Pass `walkUp: false` to read `startDir` directly when the + * 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. * * Best-effort for resolution: a missing or unresolvable config reads as @@ -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..3762dc5b1d 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'; @@ -42,39 +43,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 +57,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) { From ec27ccc25b2782595483c2d5734a8f71d9e37c83 Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 17 Oct 2026 22:58:19 +0800 Subject: [PATCH 2/3] fix(cli): keep vite config lookup inside the workspace root --- .../src/__tests__/resolve-vite-config.spec.ts | 28 ++++++++++++++++++- packages/cli/src/resolve-vite-config.ts | 7 ++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index e73a5187ce..c9540ecb58 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findViteConfigUp } from '../resolve-vite-config.js'; +import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; describe('findViteConfigUp', () => { let tempDir: string; @@ -118,3 +118,29 @@ describe('findViteConfigUp', () => { expect(result).toBe(path.join(tempDir, 'vite.config.mjs')); }); }); + +describe('resolveViteConfig', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-'))); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should not use a config above the workspace root with traverseUp', async () => { + fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), 'export default {};\n'); + const workspaceRoot = path.join(tempDir, 'monorepo'); + fs.mkdirSync(workspaceRoot); + fs.writeFileSync( + path.join(workspaceRoot, 'pnpm-workspace.yaml'), + "packages:\n - 'packages/*'\n", + ); + fs.writeFileSync(path.join(workspaceRoot, 'package.json'), '{"name":"fixture"}'); + + const config = await resolveViteConfig(workspaceRoot, { traverseUp: true }); + expect(config.configFile).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 3762dc5b1d..4e3b177e91 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -12,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) { @@ -21,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; From bfdea4ca690d076af1dc5a9c926d0ffa586564d5 Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 17 Oct 2026 23:04:26 +0800 Subject: [PATCH 3/3] test(cli): trim workspace root lookup tests --- .../src/__tests__/resolve-vite-config.spec.ts | 37 +++++-------------- .../src/create/__tests__/org-resolve.spec.ts | 14 ------- packages/cli/src/create/org-resolve.ts | 4 +- 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index c9540ecb58..ab82618d1f 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; +import { findViteConfigUp } from '../resolve-vite-config.js'; describe('findViteConfigUp', () => { let tempDir: string; @@ -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 }); @@ -118,29 +127,3 @@ describe('findViteConfigUp', () => { expect(result).toBe(path.join(tempDir, 'vite.config.mjs')); }); }); - -describe('resolveViteConfig', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-'))); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('should not use a config above the workspace root with traverseUp', async () => { - fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), 'export default {};\n'); - const workspaceRoot = path.join(tempDir, 'monorepo'); - fs.mkdirSync(workspaceRoot); - fs.writeFileSync( - path.join(workspaceRoot, 'pnpm-workspace.yaml'), - "packages:\n - 'packages/*'\n", - ); - fs.writeFileSync(path.join(workspaceRoot, 'package.json'), '{"name":"fixture"}'); - - const config = await resolveViteConfig(workspaceRoot, { traverseUp: true }); - expect(config.configFile).toBeUndefined(); - }); -}); diff --git a/packages/cli/src/create/__tests__/org-resolve.spec.ts b/packages/cli/src/create/__tests__/org-resolve.spec.ts index 29106bb70f..b1f480f8bd 100644 --- a/packages/cli/src/create/__tests__/org-resolve.spec.ts +++ b/packages/cli/src/create/__tests__/org-resolve.spec.ts @@ -41,20 +41,6 @@ describe('getConfiguredDefaultTemplate', () => { expect(await getConfiguredDefaultTemplate(deep)).toBe('@your-org'); }); - it('walks up to a package.json workspace root saved with a BOM', async () => { - fs.writeFileSync( - path.join(repoRoot, 'vite.config.ts'), - "export default { create: { defaultTemplate: '@your-org' } };\n", - ); - fs.writeFileSync( - path.join(repoRoot, 'package.json'), - '{"name":"fixture","workspaces":["apps/*"]}', - ); - const deep = path.join(repoRoot, 'apps', 'web'); - fs.mkdirSync(deep, { recursive: true }); - expect(await getConfiguredDefaultTemplate(deep)).toBe('@your-org'); - }); - it('returns undefined when no vite.config exists anywhere up the tree', async () => { const deep = path.join(repoRoot, 'nested'); fs.mkdirSync(deep, { recursive: true }); diff --git a/packages/cli/src/create/org-resolve.ts b/packages/cli/src/create/org-resolve.ts index 5c05d7f6bb..b3aa4fa29e 100644 --- a/packages/cli/src/create/org-resolve.ts +++ b/packages/cli/src/create/org-resolve.ts @@ -207,8 +207,8 @@ export async function resolveOrgManifestForCreate(args: { * * 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 + * 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. * * Best-effort for resolution: a missing or unresolvable config reads as