From adf30711a453f2576feb038f0cf9e5265d8ede3f Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Thu, 22 Oct 2026 01:01:49 -0400 Subject: [PATCH] fix(cli): load task and tool metadata without resolving Vite --- .../src/__tests__/resolve-vite-config.spec.ts | 193 +++++++++++++++++- packages/cli/src/define-config.ts | 2 +- packages/cli/src/resolve-vite-config.ts | 37 +++- packages/cli/src/staged/bin.ts | 2 +- 4 files changed, 222 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index 9dc4b89427..4dab1fe5af 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -2,10 +2,16 @@ import fs from 'node:fs'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; -import { findViteConfigUp } from '../resolve-vite-config.js'; +import { normalizePath, resolveConfig } from '../index.js'; +import { + findViteConfigUp, + resolveUniversalViteConfig, + resolveViteConfig, +} from '../resolve-vite-config.js'; describe('findViteConfigUp', () => { let tempDir: string; @@ -91,3 +97,186 @@ describe('findViteConfigUp', () => { expect(result).toBe(path.join(tempDir, `vite.config.${extension}`)); }); }); + +describe('resolveViteConfig metadata', () => { + let tempDir: string; + let originalNodeEnv: string | undefined; + + beforeEach(() => { + tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-metadata-'))); + fs.writeFileSync(path.join(tempDir, 'package.json'), '{"type":"module"}'); + originalNodeEnv = process.env.NODE_ENV; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + function writeConfig(source: string): string { + const configFile = path.join(tempDir, 'vite.config.ts'); + fs.writeFileSync(configFile, source); + return normalizePath(configFile); + } + + it('loads tool metadata without running Vite plugin hooks', async () => { + const configFile = writeConfig(` + export default { + run: { tasks: { hello: { command: 'echo hello' } } }, + lint: { rules: { eqeqeq: 'error' } }, + fmt: { semi: false }, + check: { lint: false }, + staged: { '*.ts': 'vp check' }, + pack: { entry: ['src/index.ts'] }, + create: { defaultTemplate: '@example' }, + plugins: [{ + name: 'build-only', + config() { throw new Error('config hook ran'); }, + configResolved() { throw new Error('configResolved hook ran'); }, + }], + }; + `); + + const config = await resolveViteConfig(tempDir); + expect(config).toMatchObject({ + configFile, + pack: { entry: ['src/index.ts'] }, + create: { defaultTemplate: '@example' }, + }); + expect(JSON.parse(await resolveUniversalViteConfig(null, tempDir))).toEqual({ + configFile, + run: { tasks: { hello: { command: 'echo hello' } } }, + lint: { rules: { eqeqeq: 'error' } }, + fmt: { semi: false }, + check: { lint: false }, + staged: { '*.ts': 'vp check' }, + }); + }, 15_000); + + it('returns identical metadata across equivalent pnpm installation states', async () => { + const metadata = { run: { tasks: { hello: { command: 'echo hello' } } }, fmt: {} }; + const configFile = writeConfig(`export default ${JSON.stringify(metadata)};`); + fs.mkdirSync(path.join(tempDir, 'node_modules')); + const modulesFile = path.join(tempDir, 'node_modules', '.modules.yaml'); + const results = []; + for (const install of ['first', 'second']) { + const storeDir = path.join(tempDir, install); + const virtualStoreDir = path.join(storeDir, '.pnpm'); + fs.mkdirSync(storeDir); + // pnpm >= 10.29 writes JSON to this manifest. The lockfile and + // installed packages are unchanged; only install-local state differs. + fs.writeFileSync( + modulesFile, + JSON.stringify({ + layoutVersion: 5, + packageManager: 'pnpm@12.11.2', + prunedAt: install === 'first' ? '2026-09-28T12:55:07Z' : '2026-09-28T12:58:26Z', + storeDir, + virtualStoreDir, + }), + ); + results.push(await resolveViteConfig(tempDir)); + // The same manifest still affects callers that need full resolution. + const resolved = await resolveConfig({ root: tempDir }, 'build'); + expect(resolved.server.fs.allow).toContain(normalizePath(virtualStoreDir)); + } + + expect(results[0]).toEqual({ ...metadata, configFile }); + expect(results[1]).toEqual(results[0]); + }); + + it.each([undefined, '', 'production'])( + 'preserves config evaluation with NODE_ENV=%s', + async (nodeEnv) => { + if (nodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = nodeEnv; + } + writeConfig(` + export default async (env) => ({ + run: { tasks: { hello: { + command: JSON.stringify([ + env.command, env.mode, env.isSsrBuild, env.isPreview, process.env.NODE_ENV, + ]), + } } }, + }); + `); + + const config = await resolveViteConfig(tempDir); + expect(config.run).toEqual({ + tasks: { + hello: { + command: JSON.stringify([ + 'build', + 'development', + false, + false, + nodeEnv || 'development', + ]), + }, + }, + }); + }, + ); + + it('leaves full resolution and lazy plugins available after reading metadata', async () => { + fs.mkdirSync(path.join(tempDir, 'node_modules')); + fs.symlinkSync( + fileURLToPath(new URL('../..', import.meta.url)), + path.join(tempDir, 'node_modules', 'vite-plus'), + 'junction', + ); + writeConfig(` + import { writeFileSync } from 'node:fs'; + import { defineConfig, lazyPlugins } from 'vite-plus'; + export default defineConfig({ + fmt: { semi: false }, + plugins: lazyPlugins(() => [{ + name: 'build-only', + config() { return { define: { __FROM_PLUGIN__: 'true' } }; }, + configResolved(config) { + writeFileSync(new URL('./resolved.txt', import.meta.url), config.command); + }, + }]), + }); + `); + + const metadata = await resolveViteConfig(tempDir); + expect(metadata.fmt).toEqual({ semi: false }); + expect(fs.existsSync(path.join(tempDir, 'resolved.txt'))).toBe(false); + + const config = await resolveConfig({ root: tempDir }, 'build'); + expect(config.define).toHaveProperty('__FROM_PLUGIN__', 'true'); + expect(config.plugins.some((plugin) => plugin.name === 'build-only')).toBe(true); + expect(fs.readFileSync(path.join(tempDir, 'resolved.txt'), 'utf8')).toBe('build'); + }); + + it('returns no metadata when no config exists', async () => { + expect(await resolveViteConfig(tempDir)).toEqual({ configFile: undefined }); + }); + + it('uses the closest config when traversing within a workspace', async () => { + fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'packages: [packages/*]\n'); + const configFile = writeConfig('export default { pack: { entry: ["root.ts"] } };'); + const packageDir = path.join(tempDir, 'packages', 'app'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"app","type":"module"}'); + expect(await resolveViteConfig(packageDir)).toEqual({ configFile: undefined }); + expect(await resolveViteConfig(packageDir, { traverseUp: true })).toEqual({ + configFile, + pack: { entry: ['root.ts'] }, + }); + + const localConfig = path.join(packageDir, 'vite.config.mts'); + fs.writeFileSync(localConfig, 'export default { pack: { entry: ["local.ts"] } };'); + expect(await resolveViteConfig(packageDir, { traverseUp: true })).toEqual({ + configFile: normalizePath(localConfig), + pack: { entry: ['local.ts'] }, + }); + }); +}); diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 930c2ca9e4..a49c6f63db 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1015,7 +1015,7 @@ let configMetadataDepth = 0; let configMetadataSavedValue: string | undefined; /** - * Run a config-metadata resolution (a `resolveConfig` call that loads the + * Run a config-metadata resolution (a config load that evaluates the * user's config purely to read a non-plugin block) with the metadata marker * set, so any `lazyPlugins` evaluated during it skips the plugin factory. * diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 4e3b177e91..49314433a7 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -1,6 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; +import { getEnv } from '@voidzero-dev/vite-task-client'; +import type { UserConfig } from 'vite'; + import { findMonorepoRoot } from '../binding/index.js'; import { withConfigMetadataResolution } from './define-config.ts'; import { VITE_CONFIG_FILES } from './utils/constants.ts'; @@ -53,25 +56,43 @@ export interface ResolveViteConfigOptions { } /** - * Resolve vite.config.ts and return the config object. + * Load vite.config.ts metadata without resolving Vite's plugins or server options. */ -export async function resolveViteConfig(cwd: string, options?: ResolveViteConfigOptions) { - const { resolveConfig } = await import('./index.js'); +export async function resolveViteConfig( + cwd: string, + options?: ResolveViteConfigOptions, +): Promise { + const { loadConfigFromFile } = await import('./index.js'); // This loads the config purely to read a non-plugin block (lint/fmt/pack/run/ // staged/create…), so skip the user's plugin factory while it evaluates. return withConfigMetadataResolution(async () => { + // Preserve resolveConfig's environment defaults, including Vite Task's + // tracked NODE_ENV, before evaluating function configs and their imports. + if (process.env.NODE_ENV === undefined) { + const nodeEnv = getEnv('NODE_ENV'); + if (nodeEnv !== undefined) { + process.env.NODE_ENV = nodeEnv; + } + } + if (!process.env.NODE_ENV) { + process.env.NODE_ENV = 'development'; + } + + let configFile: string | undefined; if (options?.traverseUp && !hasViteConfig(cwd)) { const workspaceRoot = findMonorepoRoot(cwd); if (workspaceRoot) { - const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); - if (configFile) { - return resolveConfig({ root: cwd, configFile }, 'build'); - } + configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); } } - return resolveConfig({ root: cwd }, 'build'); + const result = await loadConfigFromFile( + { command: 'build', mode: 'development', isSsrBuild: false, isPreview: false }, + configFile, + cwd, + ); + return { ...result?.config, configFile: result?.path }; }); } diff --git a/packages/cli/src/staged/bin.ts b/packages/cli/src/staged/bin.ts index a8da2a7cd0..a895c2cec3 100644 --- a/packages/cli/src/staged/bin.ts +++ b/packages/cli/src/staged/bin.ts @@ -1,7 +1,7 @@ // Runs staged linters on staged files using the lint-staged programmatic API. // Bundled by rolldown — no runtime dependency needed in user projects. // -// Reads the "staged" key from vite.config.ts via resolveConfig() and passes it +// Loads the "staged" metadata from vite.config.ts and passes it // to lint-staged as an explicit config object. Exits with a warning if no // staged config is found. //