From d922be195965270b7dd13466769c5972b9b89840 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 17:31:46 +0800 Subject: [PATCH 1/6] feat(cli): fail fast when the vite alias skews from the CLI version vp create / vp migrate scaffold two entries that must move in lockstep: the vite-plus dependency and the vite alias (npm:@voidzero-dev/vite-plus-core@). A dependency bot sees two unrelated packages and bumps them in separate PRs, leaving a project on a CLI/core pairing that was never published together. The skew is silent: the CLI executes its own core dependency while plugins and configs importing vite load the aliased copy at the other version. The vite and test resolvers now check what vite resolves to from the project and error when it is @voidzero-dev/vite-plus-core at a version different from the CLI, so vp dev/build/preview/test fail the mismatched bot PR in CI instead of shipping the pairing. The check skips real Vite installs, projects without vite, preview flows (VP_VERSION), and the VP_SKIP_CORE_VERSION_CHECK=1 escape hatch. Refs #2356 --- .../fixtures/core_version_guard/index.html | 6 + .../fixtures/core_version_guard/package.json | 4 + .../core_version_guard/snapshots.toml | 12 ++ .../snapshots/core_version_guard.md | 29 ++++ packages/cli/src/resolve-test.ts | 6 + packages/cli/src/resolve-vite.ts | 5 + .../__tests__/core-version-guard.spec.ts | 144 ++++++++++++++++++ packages/cli/src/utils/core-version-guard.ts | 103 +++++++++++++ 8 files changed, 309 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md create mode 100644 packages/cli/src/utils/__tests__/core-version-guard.spec.ts create mode 100644 packages/cli/src/utils/core-version-guard.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html new file mode 100644 index 0000000000..9cdb9b29a3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html @@ -0,0 +1,6 @@ + + + +

core version guard

+ + diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json new file mode 100644 index 0000000000..737587eccc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json @@ -0,0 +1,4 @@ +{ + "name": "core-version-guard-test", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml new file mode 100644 index 0000000000..9fbc476952 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml @@ -0,0 +1,12 @@ +# The vendored node_modules/vite/package.json shadows the runner's run-root +# `vite` -> core link with a core at a version the CLI never shipped with, +# simulating a dependency-bot bump of the `vite` alias without the matching +# vite-plus bump (issue #2356). +[[case]] +name = "core_version_guard" +vp = "local" +steps = [ + { argv = ["vp", "build"], continue-on-failure = true }, + { argv = ["vp", "test"], continue-on-failure = true }, + { argv = ["vp", "build"], comment = "VP_SKIP_CORE_VERSION_CHECK=1 skips the guard", envs = [["VP_SKIP_CORE_VERSION_CHECK", "1"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md new file mode 100644 index 0000000000..56aafb7e3d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md @@ -0,0 +1,29 @@ +# core_version_guard + +## `vp build` + +**Exit code:** 1 + +``` +error: Failed to resolve vite command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (pnpm catalog, overrides, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. +``` + +## `vp test` + +**Exit code:** 1 + +``` +error: Failed to resolve test command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (pnpm catalog, overrides, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. +``` + +## `VP_SKIP_CORE_VERSION_CHECK=1 vp build` + +VP_SKIP_CORE_VERSION_CHECK=1 skips the guard + +``` +✓ 2 modules transformed. +computing gzip size... +dist/index.html kB │ gzip: kB + +✓ built in +``` diff --git a/packages/cli/src/resolve-test.ts b/packages/cli/src/resolve-test.ts index 42e4d6f850..aa55360c16 100644 --- a/packages/cli/src/resolve-test.ts +++ b/packages/cli/src/resolve-test.ts @@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { DEFAULT_ENVS, resolveBundled } from './utils/constants.ts'; +import { checkCoreVersionMatch } from './utils/core-version-guard.ts'; interface VitestPackageJson { bin?: string | Record; @@ -38,6 +39,11 @@ export async function test(): Promise<{ binPath: string; envs: Record; }> { + // Fail fast when a dependency bot moved the project's `vite` alias out of + // lockstep with the CLI: the bundled Vitest would load that skewed core as + // its `vite`, a pairing that was never released together. + checkCoreVersionMatch(); + const pkgJsonPath = resolveBundled('vitest/package.json'); const pkgRoot = dirname(pkgJsonPath); const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as VitestPackageJson; diff --git a/packages/cli/src/resolve-vite.ts b/packages/cli/src/resolve-vite.ts index 506cbe8588..b048b21d61 100644 --- a/packages/cli/src/resolve-vite.ts +++ b/packages/cli/src/resolve-vite.ts @@ -12,6 +12,7 @@ import { dirname, join } from 'node:path'; import { DEFAULT_ENVS, resolve } from './utils/constants.ts'; +import { checkCoreVersionMatch } from './utils/core-version-guard.ts'; /** * Resolves the Vite binary path and environment variables. @@ -28,6 +29,10 @@ export async function vite(): Promise<{ binPath: string; envs: Record; }> { + // Fail fast when a dependency bot moved the project's `vite` alias out of + // lockstep with the CLI: the pairing about to run was never released together. + checkCoreVersionMatch(); + // Vite's CLI binary is located at bin/vite.js relative to the package root const vitePackagePath = dirname(resolve('@voidzero-dev/vite-plus-core')); const binPath = join(vitePackagePath, 'cli.js'); diff --git a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts new file mode 100644 index 0000000000..c004553a14 --- /dev/null +++ b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import cliPkg from '../../../package.json' with { type: 'json' }; +import { + assertCoreVersionMatch, + checkCoreVersionMatch, + CORE_PACKAGE_NAME, + readProjectVitePackage, + SKIP_CORE_VERSION_CHECK_ENV, +} from '../core-version-guard.ts'; + +/** + * Build injectable deps: `vite/package.json` resolves from any anchor and + * reads back the given package identity. Mirrors `makeCoverageDeps` in + * `define-config-plugins.spec.ts`. + */ +function makeViteDeps(pkg: { name: string; version: string } | null): { + createRequire: (from: string) => { resolve: (id: string) => string }; + readFile: (file: string) => string; +} { + return { + createRequire: (_from: string) => ({ + resolve(id: string) { + if (pkg && id === 'vite/package.json') { + return '/project/node_modules/vite/package.json'; + } + throw new Error(`Cannot resolve ${id}`); + }, + }), + readFile: () => JSON.stringify(pkg), + }; +} + +describe('assertCoreVersionMatch', () => { + it('does not throw when the aliased core matches the CLI version', () => { + expect(() => + assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '1.2.3' }, '1.2.3'), + ).not.toThrow(); + }); + + it('throws when the aliased core version is skewed from the CLI', () => { + expect(() => + assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '1.2.0' }, '1.2.3'), + ).toThrow(new RegExp(`npm:${CORE_PACKAGE_NAME}@1\\.2\\.3`)); + }); + + it('names both versions and the escape hatch in the error', () => { + expect(() => + assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '1.2.0' }, '1.2.3'), + ).toThrow( + expect.objectContaining({ + message: expect.stringMatching( + new RegExp( + `${CORE_PACKAGE_NAME}@1\\.2\\.0.*${CORE_PACKAGE_NAME}@1\\.2\\.3.*${SKIP_CORE_VERSION_CHECK_ENV}`, + 's', + ), + ), + }), + ); + }); + + it('does not throw when vite resolves to real Vite instead of the alias', () => { + expect(() => + assertCoreVersionMatch({ name: 'vite', version: '99.0.0' }, '1.2.3'), + ).not.toThrow(); + }); + + it('does not throw when vite is not installed', () => { + expect(() => assertCoreVersionMatch(null, '1.2.3')).not.toThrow(); + }); + + it('does not throw when the resolved package has no version field', () => { + expect(() => assertCoreVersionMatch({ name: CORE_PACKAGE_NAME }, '1.2.3')).not.toThrow(); + }); + + it('defaults the expected version to the CLI package version', () => { + expect(() => + assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: cliPkg.version }), + ).not.toThrow(); + expect(() => + assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }), + ).toThrow(new RegExp(`npm:${CORE_PACKAGE_NAME}@`)); + }); +}); + +describe('readProjectVitePackage', () => { + it('reads the package identity vite resolves to from the project', () => { + const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '1.2.0' }); + expect(readProjectVitePackage('/project', deps.createRequire, deps.readFile)).toEqual({ + name: CORE_PACKAGE_NAME, + version: '1.2.0', + }); + }); + + it('returns null when vite is not resolvable', () => { + const deps = makeViteDeps(null); + expect(readProjectVitePackage('/project', deps.createRequire, deps.readFile)).toBeNull(); + }); + + it('returns null when the resolved package.json is unreadable', () => { + const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '1.2.0' }); + expect( + readProjectVitePackage('/project', deps.createRequire, () => { + throw new Error('EACCES'); + }), + ).toBeNull(); + }); +}); + +describe('checkCoreVersionMatch', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('throws for a skewed aliased core', () => { + vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, ''); + vi.stubEnv('VP_VERSION', ''); + const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }); + expect(() => checkCoreVersionMatch('/project', deps)).toThrow( + new RegExp(`${CORE_PACKAGE_NAME}@0\\.0\\.1-never-published`), + ); + }); + + it(`skips the check when ${SKIP_CORE_VERSION_CHECK_ENV} is set`, () => { + vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, '1'); + vi.stubEnv('VP_VERSION', ''); + const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }); + expect(() => checkCoreVersionMatch('/project', deps)).not.toThrow(); + }); + + it('skips the check when VP_VERSION redefines the CLI version identity', () => { + vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, ''); + vi.stubEnv('VP_VERSION', 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'); + const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }); + expect(() => checkCoreVersionMatch('/project', deps)).not.toThrow(); + }); + + it('does not throw for a project on real Vite', () => { + vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, ''); + vi.stubEnv('VP_VERSION', ''); + const deps = makeViteDeps({ name: 'vite', version: '99.0.0' }); + expect(() => checkCoreVersionMatch('/project', deps)).not.toThrow(); + }); +}); diff --git a/packages/cli/src/utils/core-version-guard.ts b/packages/cli/src/utils/core-version-guard.ts new file mode 100644 index 0000000000..d306bab24f --- /dev/null +++ b/packages/cli/src/utils/core-version-guard.ts @@ -0,0 +1,103 @@ +/** + * Version-skew guard for the project's `vite` alias. + * + * `vp create` / `vp migrate` scaffold two entries that must move in lockstep: + * the `vite-plus` dependency and the `vite` alias + * (`npm:@voidzero-dev/vite-plus-core@`). A dependency bot sees + * two unrelated packages and bumps them in separate PRs, so a project can end + * up running a CLI/core pairing that was never published together (#2356). + * The skew is silent: `vp build`/`vp dev`/`vp test` execute the CLI's own + * core dependency, while plugins and configs that `import 'vite'` load the + * project's aliased copy at the other version. Fail fast instead, so a + * mismatched bot PR fails CI before the pairing ships. + */ + +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { VITE_PLUS_VERSION } from './constants.ts'; + +export const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core'; +export const SKIP_CORE_VERSION_CHECK_ENV = 'VP_SKIP_CORE_VERSION_CHECK'; + +interface InstalledVitePackage { + name?: string; + version?: string; +} + +/** + * Read the `package.json` of whatever `vite` resolves to from the project + * directory, the same copy the project's plugins and configs import. Returns + * `null` when `vite` is not resolvable (no install yet, or no `vite` + * dependency at all). + * + * The `_createRequire` / `_readFile` parameters let tests inject controlled + * resolvers without spying on Node's module/fs namespaces (same pattern as + * the coverage-provider guard in `define-config.ts`). + */ +export function readProjectVitePackage( + projectDir: string, + _createRequire: (from: string) => { resolve: (id: string) => string } = createRequire, + _readFile: (file: string) => string = (file) => readFileSync(file, 'utf8'), +): InstalledVitePackage | null { + try { + const req = _createRequire(path.join(projectDir, 'package.json')); + const pkgJsonPath = req.resolve('vite/package.json'); + return JSON.parse(_readFile(pkgJsonPath)) as InstalledVitePackage; + } catch { + return null; + } +} + +/** + * Throw when the project's `vite` alias resolves to a + * `@voidzero-dev/vite-plus-core` whose version differs from the running CLI. + * A no-op when `vite` is not installed, resolves to real Vite (a project that + * did not adopt the alias), or matches the CLI version. + * + * Exported for unit testing. + */ +export function assertCoreVersionMatch( + installed: InstalledVitePackage | null, + expectedVersion: string = VITE_PLUS_VERSION, +): void { + if (installed?.name !== CORE_PACKAGE_NAME || !installed.version) { + return; + } + if (installed.version !== expectedVersion) { + // Keep every version inside a `@voidzero-dev/vite-plus-core@` context: + // the PTY snapshot redactor masks the CLI's own version only in that form + // (a bare `vite-plus@` stays verbatim and would churn every release). + throw new Error( + `The project's \`vite\` alias resolves to ${CORE_PACKAGE_NAME}@${installed.version}, ` + + `but this vite-plus CLI requires ${CORE_PACKAGE_NAME}@${expectedVersion}: the two ` + + `packages are published in lockstep and other pairings are untested. A dependency ` + + `bot usually causes this by updating vite-plus and the \`vite\` alias in separate ` + + `PRs. Update the \`vite\` alias to npm:${CORE_PACKAGE_NAME}@${expectedVersion} ` + + `where it is declared (pnpm catalog, overrides, or dependencies), or run ` + + `\`vp migrate\` to realign it. Set ${SKIP_CORE_VERSION_CHECK_ENV}=1 to skip this check.`, + ); + } +} + +/** + * Orchestrates the guard: skip in preview/override flows where the CLI's + * version identity is redefined (`VP_VERSION` set, e.g. pkg.pr.new and + * registry-bridge installs pointing the alias at a tarball URL), skip on the + * explicit escape hatch, otherwise read the project's `vite` and assert. + * + * Exported (with injectable `deps`) for unit testing. + */ +export function checkCoreVersionMatch( + projectDir: string = process.cwd(), + deps: { + createRequire?: (from: string) => { resolve: (id: string) => string }; + readFile?: (file: string) => string; + } = {}, +): void { + if (process.env[SKIP_CORE_VERSION_CHECK_ENV] || process.env.VP_VERSION) { + return; + } + assertCoreVersionMatch(readProjectVitePackage(projectDir, deps.createRequire, deps.readFile)); +} From 29e8939539c47dd2c4000f93a1ffaf1ff8fd7a8c Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 17:32:13 +0800 Subject: [PATCH 2/6] test(snapshots): track the core_version_guard vendored vite package.json The fixture's node_modules/vite/package.json is the shadowed core copy the case depends on; the root node_modules gitignore entry excluded it from the previous commit. --- .../core_version_guard/node_modules/vite/package.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json new file mode 100644 index 0000000000..d446a2b458 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json @@ -0,0 +1,4 @@ +{ + "name": "@voidzero-dev/vite-plus-core", + "version": "0.0.1" +} From 3aafe6cf869016b6ff1f4d9a06fbc82fa9170487 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 18:14:43 +0800 Subject: [PATCH 3/6] fix(cli): make the skew guard hint package-manager neutral The alias can live in a catalog, overrides, resolutions, or a direct dependency spec depending on the package manager, so the error hint no longer names pnpm. --- .../core_version_guard/snapshots/core_version_guard.md | 4 ++-- packages/cli/src/utils/core-version-guard.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md index 56aafb7e3d..43e3c56ded 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md @@ -5,7 +5,7 @@ **Exit code:** 1 ``` -error: Failed to resolve vite command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (pnpm catalog, overrides, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. +error: Failed to resolve vite command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. ``` ## `vp test` @@ -13,7 +13,7 @@ error: Failed to resolve vite command: GenericFailure, Error: The project's `vit **Exit code:** 1 ``` -error: Failed to resolve test command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (pnpm catalog, overrides, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. +error: Failed to resolve test command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. ``` ## `VP_SKIP_CORE_VERSION_CHECK=1 vp build` diff --git a/packages/cli/src/utils/core-version-guard.ts b/packages/cli/src/utils/core-version-guard.ts index d306bab24f..4959bf2a47 100644 --- a/packages/cli/src/utils/core-version-guard.ts +++ b/packages/cli/src/utils/core-version-guard.ts @@ -75,7 +75,7 @@ export function assertCoreVersionMatch( `packages are published in lockstep and other pairings are untested. A dependency ` + `bot usually causes this by updating vite-plus and the \`vite\` alias in separate ` + `PRs. Update the \`vite\` alias to npm:${CORE_PACKAGE_NAME}@${expectedVersion} ` + - `where it is declared (pnpm catalog, overrides, or dependencies), or run ` + + `where it is declared (catalog, overrides, resolutions, or dependencies), or run ` + `\`vp migrate\` to realign it. Set ${SKIP_CORE_VERSION_CHECK_ENV}=1 to skip this check.`, ); } From 04273c332dd3006251d5cef956cf0ce7ceca7124 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 18:48:46 +0800 Subject: [PATCH 4/6] refactor(cli): simplify the core version guard Review cleanups from a four-angle pass: - Reuse detectPackageMetadata for the project vite lookup instead of a hand-rolled createRequire/readFile resolver; this also covers Yarn PnP and exports-blocked package.json layouts for free. - Derive the expected version by parsing the vite alias spec the CLI scaffolds (VITE_PLUS_OVERRIDE_PACKAGES.vite) instead of special-casing the VP_VERSION env var. The Rust CLI injects VP_VERSION into every child env, so nested vp runs would have silently lost the check; a non-exact spec (preview tarball, file:) now skips by shape instead. - Move the core package name into constants.ts next to VITE_PLUS_NAME. - Memoize the check for the resolver path: resolvers fire once per intercepted script command, so a workspace run repeated the same read. - Collapse the guard's input to the aliased core version (string or null), drop the injectable deps plumbing, and test the orchestrator against real temp fixture dirs. The user-facing error message is unchanged; the recorded PTY snapshot passes without re-recording. --- packages/cli/src/resolve-test.ts | 9 +- packages/cli/src/resolve-vite.ts | 7 +- .../__tests__/core-version-guard.spec.ts | 171 +++++++----------- packages/cli/src/utils/constants.ts | 3 +- packages/cli/src/utils/core-version-guard.ts | 110 +++++------ 5 files changed, 130 insertions(+), 170 deletions(-) diff --git a/packages/cli/src/resolve-test.ts b/packages/cli/src/resolve-test.ts index aa55360c16..ddd7c50ad8 100644 --- a/packages/cli/src/resolve-test.ts +++ b/packages/cli/src/resolve-test.ts @@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { DEFAULT_ENVS, resolveBundled } from './utils/constants.ts'; -import { checkCoreVersionMatch } from './utils/core-version-guard.ts'; +import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts'; interface VitestPackageJson { bin?: string | Record; @@ -39,10 +39,9 @@ export async function test(): Promise<{ binPath: string; envs: Record; }> { - // Fail fast when a dependency bot moved the project's `vite` alias out of - // lockstep with the CLI: the bundled Vitest would load that skewed core as - // its `vite`, a pairing that was never released together. - checkCoreVersionMatch(); + // Fail fast before the bundled Vitest loads a skewed `vite` alias as its + // vite (see core-version-guard.ts). + checkCoreVersionMatchOnce(); const pkgJsonPath = resolveBundled('vitest/package.json'); const pkgRoot = dirname(pkgJsonPath); diff --git a/packages/cli/src/resolve-vite.ts b/packages/cli/src/resolve-vite.ts index b048b21d61..092831989d 100644 --- a/packages/cli/src/resolve-vite.ts +++ b/packages/cli/src/resolve-vite.ts @@ -12,7 +12,7 @@ import { dirname, join } from 'node:path'; import { DEFAULT_ENVS, resolve } from './utils/constants.ts'; -import { checkCoreVersionMatch } from './utils/core-version-guard.ts'; +import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts'; /** * Resolves the Vite binary path and environment variables. @@ -29,9 +29,8 @@ export async function vite(): Promise<{ binPath: string; envs: Record; }> { - // Fail fast when a dependency bot moved the project's `vite` alias out of - // lockstep with the CLI: the pairing about to run was never released together. - checkCoreVersionMatch(); + // Fail fast on a `vite` alias that skews from the CLI (see core-version-guard.ts). + checkCoreVersionMatchOnce(); // Vite's CLI binary is located at bin/vite.js relative to the package root const vitePackagePath = dirname(resolve('@voidzero-dev/vite-plus-core')); diff --git a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts index c004553a14..187974b0a6 100644 --- a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts +++ b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts @@ -1,144 +1,99 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import cliPkg from '../../../package.json' with { type: 'json' }; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VITE_PLUS_CORE_PACKAGE_NAME as CORE } from '../constants.ts'; import { assertCoreVersionMatch, checkCoreVersionMatch, - CORE_PACKAGE_NAME, - readProjectVitePackage, + parseCoreAliasVersion, SKIP_CORE_VERSION_CHECK_ENV, } from '../core-version-guard.ts'; -/** - * Build injectable deps: `vite/package.json` resolves from any anchor and - * reads back the given package identity. Mirrors `makeCoverageDeps` in - * `define-config-plugins.spec.ts`. - */ -function makeViteDeps(pkg: { name: string; version: string } | null): { - createRequire: (from: string) => { resolve: (id: string) => string }; - readFile: (file: string) => string; -} { - return { - createRequire: (_from: string) => ({ - resolve(id: string) { - if (pkg && id === 'vite/package.json') { - return '/project/node_modules/vite/package.json'; - } - throw new Error(`Cannot resolve ${id}`); - }, - }), - readFile: () => JSON.stringify(pkg), - }; -} - -describe('assertCoreVersionMatch', () => { - it('does not throw when the aliased core matches the CLI version', () => { - expect(() => - assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '1.2.3' }, '1.2.3'), - ).not.toThrow(); +describe('parseCoreAliasVersion', () => { + it('extracts the exact version from the scaffolded alias spec', () => { + expect(parseCoreAliasVersion(`npm:${CORE}@1.2.3`)).toBe('1.2.3'); + expect(parseCoreAliasVersion(`npm:${CORE}@1.2.3-alpha.4`)).toBe('1.2.3-alpha.4'); }); - it('throws when the aliased core version is skewed from the CLI', () => { - expect(() => - assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '1.2.0' }, '1.2.3'), - ).toThrow(new RegExp(`npm:${CORE_PACKAGE_NAME}@1\\.2\\.3`)); + it('returns null for redefined alias specs with no exact version', () => { + expect( + parseCoreAliasVersion(`https://pkg.pr.new/voidzero-dev/vite-plus/${CORE}@1891`), + ).toBeNull(); + expect(parseCoreAliasVersion(`npm:${CORE}@https://pkg.pr.new/vite-plus@1891`)).toBeNull(); + expect(parseCoreAliasVersion('file:../vite-plus-core')).toBeNull(); + expect(parseCoreAliasVersion(undefined)).toBeNull(); }); +}); - it('names both versions and the escape hatch in the error', () => { - expect(() => - assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '1.2.0' }, '1.2.3'), - ).toThrow( - expect.objectContaining({ - message: expect.stringMatching( - new RegExp( - `${CORE_PACKAGE_NAME}@1\\.2\\.0.*${CORE_PACKAGE_NAME}@1\\.2\\.3.*${SKIP_CORE_VERSION_CHECK_ENV}`, - 's', - ), - ), - }), - ); +describe('assertCoreVersionMatch', () => { + it('does not throw when the aliased core matches the expected version', () => { + expect(() => assertCoreVersionMatch('1.2.3', '1.2.3')).not.toThrow(); }); - it('does not throw when vite resolves to real Vite instead of the alias', () => { - expect(() => - assertCoreVersionMatch({ name: 'vite', version: '99.0.0' }, '1.2.3'), - ).not.toThrow(); + it('throws with both versions, the fix spec, and the escape hatch on a skew', () => { + expect(() => assertCoreVersionMatch('1.2.0', '1.2.3')).toThrow( + new RegExp(`${CORE}@1\\.2\\.0.*npm:${CORE}@1\\.2\\.3.*${SKIP_CORE_VERSION_CHECK_ENV}`, 's'), + ); }); - it('does not throw when vite is not installed', () => { + it('does not throw when no aliased core is installed', () => { expect(() => assertCoreVersionMatch(null, '1.2.3')).not.toThrow(); - }); - - it('does not throw when the resolved package has no version field', () => { - expect(() => assertCoreVersionMatch({ name: CORE_PACKAGE_NAME }, '1.2.3')).not.toThrow(); - }); - - it('defaults the expected version to the CLI package version', () => { - expect(() => - assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: cliPkg.version }), - ).not.toThrow(); - expect(() => - assertCoreVersionMatch({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }), - ).toThrow(new RegExp(`npm:${CORE_PACKAGE_NAME}@`)); + expect(() => assertCoreVersionMatch(undefined, '1.2.3')).not.toThrow(); }); }); -describe('readProjectVitePackage', () => { - it('reads the package identity vite resolves to from the project', () => { - const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '1.2.0' }); - expect(readProjectVitePackage('/project', deps.createRequire, deps.readFile)).toEqual({ - name: CORE_PACKAGE_NAME, - version: '1.2.0', - }); - }); - - it('returns null when vite is not resolvable', () => { - const deps = makeViteDeps(null); - expect(readProjectVitePackage('/project', deps.createRequire, deps.readFile)).toBeNull(); - }); - - it('returns null when the resolved package.json is unreadable', () => { - const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '1.2.0' }); - expect( - readProjectVitePackage('/project', deps.createRequire, () => { - throw new Error('EACCES'); - }), - ).toBeNull(); +describe('checkCoreVersionMatch', () => { + let projectDir: string; + + // What `vite` resolves to in the project, shaped like a real install. + function writeVitePackage(pkg: { name: string; version: string }) { + const dir = join(projectDir, 'node_modules', 'vite'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg)); + } + + beforeEach(() => { + // realpath so resolution output matches (macOS tmpdir is /var -> /private/var). + projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'vp-core-guard-'))); }); -}); -describe('checkCoreVersionMatch', () => { afterEach(() => { vi.unstubAllEnvs(); + rmSync(projectDir, { recursive: true, force: true }); }); - it('throws for a skewed aliased core', () => { - vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, ''); - vi.stubEnv('VP_VERSION', ''); - const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }); - expect(() => checkCoreVersionMatch('/project', deps)).toThrow( - new RegExp(`${CORE_PACKAGE_NAME}@0\\.0\\.1-never-published`), - ); + it('throws when the installed aliased core skews from the alias spec', () => { + writeVitePackage({ name: CORE, version: '1.2.0' }); + expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).toThrow(`${CORE}@1.2.0`); + }); + + it('does not throw when the installed aliased core matches', () => { + writeVitePackage({ name: CORE, version: '1.2.3' }); + expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); }); it(`skips the check when ${SKIP_CORE_VERSION_CHECK_ENV} is set`, () => { vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, '1'); - vi.stubEnv('VP_VERSION', ''); - const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }); - expect(() => checkCoreVersionMatch('/project', deps)).not.toThrow(); + writeVitePackage({ name: CORE, version: '1.2.0' }); + expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); }); - it('skips the check when VP_VERSION redefines the CLI version identity', () => { - vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, ''); - vi.stubEnv('VP_VERSION', 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'); - const deps = makeViteDeps({ name: CORE_PACKAGE_NAME, version: '0.0.1-never-published' }); - expect(() => checkCoreVersionMatch('/project', deps)).not.toThrow(); + it('skips the check when the alias spec is redefined with no exact version', () => { + writeVitePackage({ name: CORE, version: '1.2.0' }); + expect(() => + checkCoreVersionMatch(projectDir, `https://pkg.pr.new/voidzero-dev/vite-plus/${CORE}@1891`), + ).not.toThrow(); }); it('does not throw for a project on real Vite', () => { - vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, ''); - vi.stubEnv('VP_VERSION', ''); - const deps = makeViteDeps({ name: 'vite', version: '99.0.0' }); - expect(() => checkCoreVersionMatch('/project', deps)).not.toThrow(); + writeVitePackage({ name: 'vite', version: '99.0.0' }); + expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); + }); + + it('does not throw when vite is not installed', () => { + expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); }); }); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 9561e2bd60..836516e53b 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -3,6 +3,7 @@ import { createRequire } from 'node:module'; import cliPkg from '../../package.json' with { type: 'json' }; export const VITE_PLUS_NAME = 'vite-plus'; +export const VITE_PLUS_CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core'; export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version; // Mirrors Vite's DEFAULT_CONFIG_FILES order so readers and writers target the same file. @@ -20,7 +21,7 @@ export const VITEST_VERSION = '4.1.10'; export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES) : { - vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_VERSION}`, + vite: `npm:${VITE_PLUS_CORE_PACKAGE_NAME}@${VITE_PLUS_VERSION}`, // Pin `vitest` only. The `@vitest/*` family (expect, runner, snapshot, spy, // utils, mocker, pretty-format) are EXACT (`4.1.9`) dependencies of `vitest` // itself, so a single `vitest` override cascades one consistent version to diff --git a/packages/cli/src/utils/core-version-guard.ts b/packages/cli/src/utils/core-version-guard.ts index 4959bf2a47..9bbf08b514 100644 --- a/packages/cli/src/utils/core-version-guard.ts +++ b/packages/cli/src/utils/core-version-guard.ts @@ -12,69 +12,52 @@ * mismatched bot PR fails CI before the pairing ships. */ -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; +import { VITE_PLUS_CORE_PACKAGE_NAME, VITE_PLUS_OVERRIDE_PACKAGES } from './constants.ts'; +import { detectPackageMetadata } from './package.ts'; -import { VITE_PLUS_VERSION } from './constants.ts'; - -export const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core'; export const SKIP_CORE_VERSION_CHECK_ENV = 'VP_SKIP_CORE_VERSION_CHECK'; -interface InstalledVitePackage { - name?: string; - version?: string; -} - /** - * Read the `package.json` of whatever `vite` resolves to from the project - * directory, the same copy the project's plugins and configs import. Returns - * `null` when `vite` is not resolvable (no install yet, or no `vite` - * dependency at all). + * Extract the exact core version from a `vite` alias spec + * (`npm:@voidzero-dev/vite-plus-core@`). Returns `null` for every + * other shape: preview and ecosystem flows redefine the alias to a tarball + * URL or `file:` spec (via `VP_VERSION` / `VP_OVERRIDE_PACKAGES`), and those + * carry no exact version to compare against. Deriving the skip from the spec + * instead of from env-var names keeps the guard active when `VP_VERSION` is + * merely a plain version (the Rust CLI injects one into every child env, so + * nested `vp` runs would otherwise silently lose the check). * - * The `_createRequire` / `_readFile` parameters let tests inject controlled - * resolvers without spying on Node's module/fs namespaces (same pattern as - * the coverage-provider guard in `define-config.ts`). + * Exported for unit testing. */ -export function readProjectVitePackage( - projectDir: string, - _createRequire: (from: string) => { resolve: (id: string) => string } = createRequire, - _readFile: (file: string) => string = (file) => readFileSync(file, 'utf8'), -): InstalledVitePackage | null { - try { - const req = _createRequire(path.join(projectDir, 'package.json')); - const pkgJsonPath = req.resolve('vite/package.json'); - return JSON.parse(_readFile(pkgJsonPath)) as InstalledVitePackage; - } catch { +export function parseCoreAliasVersion(aliasSpec: string | undefined): string | null { + const prefix = `npm:${VITE_PLUS_CORE_PACKAGE_NAME}@`; + if (!aliasSpec?.startsWith(prefix)) { return null; } + const version = aliasSpec.slice(prefix.length); + return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) ? version : null; } /** - * Throw when the project's `vite` alias resolves to a - * `@voidzero-dev/vite-plus-core` whose version differs from the running CLI. - * A no-op when `vite` is not installed, resolves to real Vite (a project that - * did not adopt the alias), or matches the CLI version. + * Throw when the project's aliased core version differs from the version the + * CLI expects. A no-op when no aliased core is installed. * * Exported for unit testing. */ export function assertCoreVersionMatch( - installed: InstalledVitePackage | null, - expectedVersion: string = VITE_PLUS_VERSION, + installedVersion: string | null | undefined, + expectedVersion: string, ): void { - if (installed?.name !== CORE_PACKAGE_NAME || !installed.version) { - return; - } - if (installed.version !== expectedVersion) { + if (installedVersion && installedVersion !== expectedVersion) { // Keep every version inside a `@voidzero-dev/vite-plus-core@` context: // the PTY snapshot redactor masks the CLI's own version only in that form // (a bare `vite-plus@` stays verbatim and would churn every release). throw new Error( - `The project's \`vite\` alias resolves to ${CORE_PACKAGE_NAME}@${installed.version}, ` + - `but this vite-plus CLI requires ${CORE_PACKAGE_NAME}@${expectedVersion}: the two ` + + `The project's \`vite\` alias resolves to ${VITE_PLUS_CORE_PACKAGE_NAME}@${installedVersion}, ` + + `but this vite-plus CLI requires ${VITE_PLUS_CORE_PACKAGE_NAME}@${expectedVersion}: the two ` + `packages are published in lockstep and other pairings are untested. A dependency ` + `bot usually causes this by updating vite-plus and the \`vite\` alias in separate ` + - `PRs. Update the \`vite\` alias to npm:${CORE_PACKAGE_NAME}@${expectedVersion} ` + + `PRs. Update the \`vite\` alias to npm:${VITE_PLUS_CORE_PACKAGE_NAME}@${expectedVersion} ` + `where it is declared (catalog, overrides, resolutions, or dependencies), or run ` + `\`vp migrate\` to realign it. Set ${SKIP_CORE_VERSION_CHECK_ENV}=1 to skip this check.`, ); @@ -82,22 +65,45 @@ export function assertCoreVersionMatch( } /** - * Orchestrates the guard: skip in preview/override flows where the CLI's - * version identity is redefined (`VP_VERSION` set, e.g. pkg.pr.new and - * registry-bridge installs pointing the alias at a tarball URL), skip on the - * explicit escape hatch, otherwise read the project's `vite` and assert. + * Orchestrates the guard: honor the escape hatch, derive the expected version + * from the alias spec the CLI itself scaffolds (skipping redefined preview + * specs), read what `vite` resolves to from the project (the copy plugins and + * configs import), and assert. A project on real Vite, or with no `vite` + * installed, passes. * - * Exported (with injectable `deps`) for unit testing. + * The `aliasSpec` parameter exists for unit tests; production callers use the + * default. */ export function checkCoreVersionMatch( projectDir: string = process.cwd(), - deps: { - createRequire?: (from: string) => { resolve: (id: string) => string }; - readFile?: (file: string) => string; - } = {}, + aliasSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite, ): void { - if (process.env[SKIP_CORE_VERSION_CHECK_ENV] || process.env.VP_VERSION) { + if (process.env[SKIP_CORE_VERSION_CHECK_ENV]) { + return; + } + const expectedVersion = parseCoreAliasVersion(aliasSpec); + if (!expectedVersion) { + return; + } + const installed = detectPackageMetadata(projectDir, 'vite'); + assertCoreVersionMatch( + installed && installed.name === VITE_PLUS_CORE_PACKAGE_NAME ? installed.version : null, + expectedVersion, + ); +} + +let coreVersionChecked = false; + +/** + * Memoized wrapper for the resolver path. The `vite`/`test` resolvers run + * once per intercepted script command, so a `vp run` across a large workspace + * would repeat the same read of an unchanging file; the project dir never + * changes within a process, so one check suffices. + */ +export function checkCoreVersionMatchOnce(): void { + if (coreVersionChecked) { return; } - assertCoreVersionMatch(readProjectVitePackage(projectDir, deps.createRequire, deps.readFile)); + coreVersionChecked = true; + checkCoreVersionMatch(); } From bc56e4d460466fbf28da6fa5a396b0fdea1934a5 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 20:02:46 +0800 Subject: [PATCH 5/6] fix(cli): compare against the running CLI version and check the task cwd Two review findings on the skew guard: - The expected core version came from VITE_PLUS_VERSION, which VP_VERSION overrides. The installer docs tell PowerShell users to set VP_VERSION for the session, and the Rust CLI injects it into every child env, so an aligned project could fail (or a stale pairing pass) against an inherited value. The guard now compares against the running CLI package's own version (CLI_PACKAGE_VERSION); preview and ecosystem builds publish CLI and core from one commit, so no spec-shape skip is needed and the alias parsing is gone. - The guard resolved vite from the process cwd, so retargeted runs (defaultPackage, vp run -r script commands) checked the invocation root instead of the package the command executes in. The Rust resolver now forwards each command's cwd to the JS resolvers, and the guard memoizes per directory. --- packages/cli/binding/index.d.cts | 12 ++-- packages/cli/binding/src/cli/execution.rs | 2 +- packages/cli/binding/src/cli/handler.rs | 3 +- packages/cli/binding/src/cli/resolver.rs | 27 +++++--- packages/cli/binding/src/cli/types.rs | 9 ++- packages/cli/binding/src/lib.rs | 22 ++++--- packages/cli/src/resolve-test.ts | 9 ++- packages/cli/src/resolve-vite.ts | 9 ++- .../__tests__/core-version-guard.spec.ts | 36 ++--------- packages/cli/src/utils/constants.ts | 9 +++ packages/cli/src/utils/core-version-guard.ts | 64 +++++++------------ 11 files changed, 97 insertions(+), 105 deletions(-) diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 54660f8ba8..87f8a6777f 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3370,12 +3370,12 @@ export interface BatchRewriteResult { /** Configuration options passed from JavaScript to Rust. */ export interface CliOptions { - lint: (err: Error | null) => Promise; - fmt: (err: Error | null) => Promise; - vite: (err: Error | null) => Promise; - test: (err: Error | null) => Promise; - pack: (err: Error | null) => Promise; - doc: (err: Error | null) => Promise; + lint: (err: Error | null, arg: string) => Promise; + fmt: (err: Error | null, arg: string) => Promise; + vite: (err: Error | null, arg: string) => Promise; + test: (err: Error | null, arg: string) => Promise; + pack: (err: Error | null, arg: string) => Promise; + doc: (err: Error | null, arg: string) => Promise; cwd?: string; /** CLI arguments (should be process.argv.slice(2) from JavaScript) */ args?: Array; diff --git a/packages/cli/binding/src/cli/execution.rs b/packages/cli/binding/src/cli/execution.rs index 322a88913a..c0ad16f4fe 100644 --- a/packages/cli/binding/src/cli/execution.rs +++ b/packages/cli/binding/src/cli/execution.rs @@ -22,7 +22,7 @@ async fn resolve_and_build_command( cwd: &AbsolutePathBuf, ) -> Result { let resolved = resolver - .resolve(subcommand, resolved_vite_config, envs) + .resolve(subcommand, resolved_vite_config, envs, cwd) .await .map_err(|e| Error::Anyhow(e))?; diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index 0811dd5091..a99fc70fa4 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -96,7 +96,8 @@ impl CommandHandler for VitePlusCommandHandler { if super::app_target::needs_elicitation(&subcmd, &command.cwd) { return Ok(HandledCommand::Verbatim); } - let resolved = self.resolver.resolve(subcmd, None, &command.envs).await?; + let resolved = + self.resolver.resolve(subcmd, None, &command.envs, &command.cwd).await?; Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request())) } CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)), diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs index d5130042ab..e006cea328 100644 --- a/packages/cli/binding/src/cli/resolver.rs +++ b/packages/cli/binding/src/cli/resolver.rs @@ -62,13 +62,16 @@ impl SubcommandResolver { } /// Resolve a synthesizable subcommand to a concrete program, args, cache config, and envs. + /// `cwd` is the directory the resolved command will run in (the task cwd + /// for intercepted script commands); it is forwarded to the JS resolvers. pub(super) async fn resolve( &self, subcommand: SynthesizableSubcommand, resolved_vite_config: Option<&ResolvedUniversalViteConfig>, envs: &Arc, Arc>>, + cwd: &AbsolutePath, ) -> anyhow::Result { - self.resolve_inner(subcommand, resolved_vite_config, envs).await + self.resolve_inner(subcommand, resolved_vite_config, envs, cwd).await } async fn resolve_inner( @@ -76,11 +79,17 @@ impl SubcommandResolver { subcommand: SynthesizableSubcommand, resolved_vite_config: Option<&ResolvedUniversalViteConfig>, envs: &Arc, Arc>>, + cwd: &AbsolutePath, ) -> anyhow::Result { + let cwd_string = cwd + .as_path() + .to_str() + .ok_or_else(|| anyhow::anyhow!("command cwd is not valid UTF-8"))? + .to_string(); match subcommand { SynthesizableSubcommand::Lint { mut args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.lint)().await?; + let resolved = (cli_options.lint)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -117,7 +126,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Fmt { mut args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.fmt)().await?; + let resolved = (cli_options.fmt)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -153,7 +162,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Build { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.vite)().await?; + let resolved = (cli_options.vite)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -182,7 +191,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Test { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.test)().await?; + let resolved = (cli_options.test)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -214,7 +223,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Pack { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.pack)().await?; + let resolved = (cli_options.pack)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -236,7 +245,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Dev { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.vite)().await?; + let resolved = (cli_options.vite)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -254,7 +263,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Preview { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.vite)().await?; + let resolved = (cli_options.vite)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -272,7 +281,7 @@ impl SubcommandResolver { } SynthesizableSubcommand::Doc { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.doc)().await?; + let resolved = (cli_options.doc)(cwd_string.clone()).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index c2fda6c821..e5c3f06c55 100644 --- a/packages/cli/binding/src/cli/types.rs +++ b/packages/cli/binding/src/cli/types.rs @@ -133,10 +133,13 @@ pub(super) enum CLIArgs { Toolchain(ToolchainArgs), } -/// Type alias for boxed async resolver function +/// Type alias for boxed async resolver function. Takes the directory the +/// resolved command will run in (the task cwd for intercepted script +/// commands), so JS-side checks can resolve against the right package. /// NOTE: Uses anyhow::Error to avoid NAPI type inference issues -pub type BoxedResolverFn = - Box Pin> + 'static>>>; +pub type BoxedResolverFn = Box< + dyn Fn(String) -> Pin> + 'static>>, +>; /// Type alias for vite config resolver function (takes package path, returns JSON string) /// Uses Arc for cloning and Send + Sync for use in UserConfigLoader diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 4c5b3a8f2f..12103642bb 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -65,12 +65,12 @@ pub fn ensure_blocking_stdio() { /// Configuration options passed from JavaScript to Rust. #[napi(object, object_to_js = false)] pub struct CliOptions { - pub lint: Arc>>, - pub fmt: Arc>>, - pub vite: Arc>>, - pub test: Arc>>, - pub pack: Arc>>, - pub doc: Arc>>, + pub lint: Arc>>, + pub fmt: Arc>>, + pub vite: Arc>>, + pub test: Arc>>, + pub pack: Arc>>, + pub doc: Arc>>, pub cwd: Option, /// CLI arguments (should be process.argv.slice(2) from JavaScript) pub args: Option>, @@ -98,18 +98,20 @@ impl From for ResolveCommandResult { } } -/// Create a boxed resolver function from a ThreadsafeFunction +/// Create a boxed resolver function from a ThreadsafeFunction. The `cwd` +/// argument is the directory the resolved command will run in; it reaches the +/// JS resolver as its second (callee-handled) argument. /// NOTE: Uses anyhow::Error to avoid NAPI type interference with vp_error::Error fn create_resolver( - tsf: Arc>>, + tsf: Arc>>, error_message: &'static str, ) -> BoxedResolverFn { - Box::new(move || { + Box::new(move |cwd: String| { let tsf = tsf.clone(); Box::pin(async move { // Call JS function - map napi::Error to anyhow::Error let promise: Promise = tsf - .call_async(Ok(())) + .call_async(Ok(cwd)) .await .map_err(|e| anyhow::anyhow!("{}: {}", error_message, e))?; diff --git a/packages/cli/src/resolve-test.ts b/packages/cli/src/resolve-test.ts index ddd7c50ad8..b342aa1f05 100644 --- a/packages/cli/src/resolve-test.ts +++ b/packages/cli/src/resolve-test.ts @@ -35,13 +35,18 @@ interface VitestPackageJson { * unreachable. See `resolveBundled` for the rationale (avoiding dual-copy * Vitest internal-state / mock-hoisting mismatches). */ -export async function test(): Promise<{ +export async function test( + // Callee-handled NAPI callback: the payload (the command's cwd) is the + // second argument, after the error slot. + _err?: unknown, + taskDir?: string, +): Promise<{ binPath: string; envs: Record; }> { // Fail fast before the bundled Vitest loads a skewed `vite` alias as its // vite (see core-version-guard.ts). - checkCoreVersionMatchOnce(); + checkCoreVersionMatchOnce(taskDir); const pkgJsonPath = resolveBundled('vitest/package.json'); const pkgRoot = dirname(pkgJsonPath); diff --git a/packages/cli/src/resolve-vite.ts b/packages/cli/src/resolve-vite.ts index 092831989d..cbc3d758dd 100644 --- a/packages/cli/src/resolve-vite.ts +++ b/packages/cli/src/resolve-vite.ts @@ -25,12 +25,17 @@ import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts'; * to vite package (for direct vite installations). * It constructs the path to the CLI binary within the resolved package. */ -export async function vite(): Promise<{ +export async function vite( + // Callee-handled NAPI callback: the payload (the command's cwd) is the + // second argument, after the error slot. + _err?: unknown, + taskDir?: string, +): Promise<{ binPath: string; envs: Record; }> { // Fail fast on a `vite` alias that skews from the CLI (see core-version-guard.ts). - checkCoreVersionMatchOnce(); + checkCoreVersionMatchOnce(taskDir); // Vite's CLI binary is located at bin/vite.js relative to the package root const vitePackagePath = dirname(resolve('@voidzero-dev/vite-plus-core')); diff --git a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts index 187974b0a6..e7d5d280bd 100644 --- a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts +++ b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts @@ -8,26 +8,9 @@ import { VITE_PLUS_CORE_PACKAGE_NAME as CORE } from '../constants.ts'; import { assertCoreVersionMatch, checkCoreVersionMatch, - parseCoreAliasVersion, SKIP_CORE_VERSION_CHECK_ENV, } from '../core-version-guard.ts'; -describe('parseCoreAliasVersion', () => { - it('extracts the exact version from the scaffolded alias spec', () => { - expect(parseCoreAliasVersion(`npm:${CORE}@1.2.3`)).toBe('1.2.3'); - expect(parseCoreAliasVersion(`npm:${CORE}@1.2.3-alpha.4`)).toBe('1.2.3-alpha.4'); - }); - - it('returns null for redefined alias specs with no exact version', () => { - expect( - parseCoreAliasVersion(`https://pkg.pr.new/voidzero-dev/vite-plus/${CORE}@1891`), - ).toBeNull(); - expect(parseCoreAliasVersion(`npm:${CORE}@https://pkg.pr.new/vite-plus@1891`)).toBeNull(); - expect(parseCoreAliasVersion('file:../vite-plus-core')).toBeNull(); - expect(parseCoreAliasVersion(undefined)).toBeNull(); - }); -}); - describe('assertCoreVersionMatch', () => { it('does not throw when the aliased core matches the expected version', () => { expect(() => assertCoreVersionMatch('1.2.3', '1.2.3')).not.toThrow(); @@ -65,35 +48,28 @@ describe('checkCoreVersionMatch', () => { rmSync(projectDir, { recursive: true, force: true }); }); - it('throws when the installed aliased core skews from the alias spec', () => { + it('throws when the installed aliased core skews from the expected version', () => { writeVitePackage({ name: CORE, version: '1.2.0' }); - expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).toThrow(`${CORE}@1.2.0`); + expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).toThrow(`${CORE}@1.2.0`); }); it('does not throw when the installed aliased core matches', () => { writeVitePackage({ name: CORE, version: '1.2.3' }); - expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); + expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow(); }); it(`skips the check when ${SKIP_CORE_VERSION_CHECK_ENV} is set`, () => { vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, '1'); writeVitePackage({ name: CORE, version: '1.2.0' }); - expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); - }); - - it('skips the check when the alias spec is redefined with no exact version', () => { - writeVitePackage({ name: CORE, version: '1.2.0' }); - expect(() => - checkCoreVersionMatch(projectDir, `https://pkg.pr.new/voidzero-dev/vite-plus/${CORE}@1891`), - ).not.toThrow(); + expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow(); }); it('does not throw for a project on real Vite', () => { writeVitePackage({ name: 'vite', version: '99.0.0' }); - expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); + expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow(); }); it('does not throw when vite is not installed', () => { - expect(() => checkCoreVersionMatch(projectDir, `npm:${CORE}@1.2.3`)).not.toThrow(); + expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow(); }); }); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 836516e53b..eb09937499 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -6,6 +6,15 @@ export const VITE_PLUS_NAME = 'vite-plus'; export const VITE_PLUS_CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core'; export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version; +/** + * The version of the CLI package that is actually running, untouched by + * `VP_VERSION`. The installer docs tell users to set `VP_VERSION` (and on + * PowerShell it persists for the session), and the Rust CLI injects it into + * every child env, so anything that must describe the running CLI (not the + * install/migrate target) has to read this instead of {@link VITE_PLUS_VERSION}. + */ +export const CLI_PACKAGE_VERSION: string = cliPkg.version; + // Mirrors Vite's DEFAULT_CONFIG_FILES order so readers and writers target the same file. export const VITE_CONFIG_FILES = [ 'vite.config.js', diff --git a/packages/cli/src/utils/core-version-guard.ts b/packages/cli/src/utils/core-version-guard.ts index 9bbf08b514..c23f4bfa07 100644 --- a/packages/cli/src/utils/core-version-guard.ts +++ b/packages/cli/src/utils/core-version-guard.ts @@ -10,34 +10,19 @@ * core dependency, while plugins and configs that `import 'vite'` load the * project's aliased copy at the other version. Fail fast instead, so a * mismatched bot PR fails CI before the pairing ships. + * + * The expected version is the running CLI package's own version + * ({@link CLI_PACKAGE_VERSION}), never an env-derived one: `VP_VERSION` can + * linger from the installer session or arrive injected by a parent `vp` + * process, and preview builds publish CLI and core from one commit with equal + * versions, so the package version is correct for every flow. */ -import { VITE_PLUS_CORE_PACKAGE_NAME, VITE_PLUS_OVERRIDE_PACKAGES } from './constants.ts'; +import { CLI_PACKAGE_VERSION, VITE_PLUS_CORE_PACKAGE_NAME } from './constants.ts'; import { detectPackageMetadata } from './package.ts'; export const SKIP_CORE_VERSION_CHECK_ENV = 'VP_SKIP_CORE_VERSION_CHECK'; -/** - * Extract the exact core version from a `vite` alias spec - * (`npm:@voidzero-dev/vite-plus-core@`). Returns `null` for every - * other shape: preview and ecosystem flows redefine the alias to a tarball - * URL or `file:` spec (via `VP_VERSION` / `VP_OVERRIDE_PACKAGES`), and those - * carry no exact version to compare against. Deriving the skip from the spec - * instead of from env-var names keeps the guard active when `VP_VERSION` is - * merely a plain version (the Rust CLI injects one into every child env, so - * nested `vp` runs would otherwise silently lose the check). - * - * Exported for unit testing. - */ -export function parseCoreAliasVersion(aliasSpec: string | undefined): string | null { - const prefix = `npm:${VITE_PLUS_CORE_PACKAGE_NAME}@`; - if (!aliasSpec?.startsWith(prefix)) { - return null; - } - const version = aliasSpec.slice(prefix.length); - return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) ? version : null; -} - /** * Throw when the project's aliased core version differs from the version the * CLI expects. A no-op when no aliased core is installed. @@ -65,26 +50,21 @@ export function assertCoreVersionMatch( } /** - * Orchestrates the guard: honor the escape hatch, derive the expected version - * from the alias spec the CLI itself scaffolds (skipping redefined preview - * specs), read what `vite` resolves to from the project (the copy plugins and - * configs import), and assert. A project on real Vite, or with no `vite` - * installed, passes. + * Orchestrates the guard: honor the escape hatch, read what `vite` resolves + * to from the command's directory (the copy plugins and configs import), and + * assert it against the running CLI's version. A project on real Vite, or + * with no `vite` installed, passes. * - * The `aliasSpec` parameter exists for unit tests; production callers use the - * default. + * The `expectedVersion` parameter exists for unit tests; production callers + * use the default. */ export function checkCoreVersionMatch( projectDir: string = process.cwd(), - aliasSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite, + expectedVersion: string = CLI_PACKAGE_VERSION, ): void { if (process.env[SKIP_CORE_VERSION_CHECK_ENV]) { return; } - const expectedVersion = parseCoreAliasVersion(aliasSpec); - if (!expectedVersion) { - return; - } const installed = detectPackageMetadata(projectDir, 'vite'); assertCoreVersionMatch( installed && installed.name === VITE_PLUS_CORE_PACKAGE_NAME ? installed.version : null, @@ -92,18 +72,20 @@ export function checkCoreVersionMatch( ); } -let coreVersionChecked = false; +const checkedDirs = new Set(); /** * Memoized wrapper for the resolver path. The `vite`/`test` resolvers run * once per intercepted script command, so a `vp run` across a large workspace - * would repeat the same read of an unchanging file; the project dir never - * changes within a process, so one check suffices. + * would repeat the same read; one check per execution directory suffices. + * The directory comes from the Rust side (the task cwd), because retargeted + * runs (`defaultPackage`, `vp run -r`) execute in a package dir while the + * Node process cwd stays at the invocation root. */ -export function checkCoreVersionMatchOnce(): void { - if (coreVersionChecked) { +export function checkCoreVersionMatchOnce(projectDir: string = process.cwd()): void { + if (checkedDirs.has(projectDir)) { return; } - coreVersionChecked = true; - checkCoreVersionMatch(); + checkedDirs.add(projectDir); + checkCoreVersionMatch(projectDir); } From 8e0b89b44c4ac9cd3470ae4e70e676fa483f1593 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 17 Aug 2026 16:03:02 +0800 Subject: [PATCH 6/6] fix(cli): aim the guard at Vite's selected root vp build apps/web (or an explicit -c apps/web/vite.config.ts) rebases Vite's config lookup onto the selected root while the process cwd stays put, so the guard checked the wrong directory: a skewed app alias could pass and a skewed root alias could reject an aligned app. The vite resolver arms now derive the guard directory from the args with the same cac/mri walk app_target already uses for elicitation: the parent of an explicit -c/--config file wins, else the [root] positional, else the command cwd. The snapshot case gains a positional-root step that vendors a real-vite-shaped package in app/, so it only passes when the guard checks the selected root instead of the workspace cwd. --- .../core_version_guard/app/index.html | 6 ++ .../app/node_modules/vite/package.json | 4 + .../core_version_guard/snapshots.toml | 5 +- .../snapshots/core_version_guard.md | 13 +++ packages/cli/binding/src/cli/app_target.rs | 94 +++++++++++++++++++ packages/cli/binding/src/cli/resolver.rs | 28 +++++- 6 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html new file mode 100644 index 0000000000..e302a8d292 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html @@ -0,0 +1,6 @@ + + + +

positional root app

+ + diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json new file mode 100644 index 0000000000..ab9435ac0e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite", + "version": "99.0.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml index 9fbc476952..f4ea887cfe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml @@ -1,12 +1,15 @@ # The vendored node_modules/vite/package.json shadows the runner's run-root # `vite` -> core link with a core at a version the CLI never shipped with, # simulating a dependency-bot bump of the `vite` alias without the matching -# vite-plus bump (issue #2356). +# vite-plus bump (issue #2356). The app/ subdir vendors a real-vite-shaped +# package, so the positional-root step passes only when the guard checks the +# selected root instead of the workspace cwd. [[case]] name = "core_version_guard" vp = "local" steps = [ { argv = ["vp", "build"], continue-on-failure = true }, { argv = ["vp", "test"], continue-on-failure = true }, + { argv = ["vp", "build", "app"], comment = "the guard checks the positional root, where vite is real Vite" }, { argv = ["vp", "build"], comment = "VP_SKIP_CORE_VERSION_CHECK=1 skips the guard", envs = [["VP_SKIP_CORE_VERSION_CHECK", "1"]] }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md index 43e3c56ded..6105332d38 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md @@ -16,6 +16,19 @@ error: Failed to resolve vite command: GenericFailure, Error: The project's `vit error: Failed to resolve test command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. ``` +## `vp build app` + +the guard checks the positional root, where vite is real Vite + +``` +note: `vp build app` sets Vite's root without changing the working directory. To run as if started there, use `vp -C app build`. +✓ 2 modules transformed. +computing gzip size... +app/dist/index.html kB │ gzip: kB + +✓ built in +``` + ## `VP_SKIP_CORE_VERSION_CHECK=1 vp build` VP_SKIP_CORE_VERSION_CHECK=1 skips the guard diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 6c687ba7cd..5561666333 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -153,6 +153,62 @@ fn classify_args<'a>(command: &str, args: &'a [String]) -> ArgTarget<'a> { ArgTarget::Bare } +/// The directory Vite loads the app config from, when the args select one: +/// the parent of an explicit `-c`/`--config` file (which wins over a +/// positional), else the `[root]` positional. `None` means the command's cwd. +/// Walks the args like [`classify_args`] (cac/mri value consumption), but +/// scans them all instead of returning at the first target, because Vite +/// accepts `--config` and `[root]` in either order. +/// +/// Used to aim the core-version guard at the copy of `vite` the app's config +/// and plugins will import: Vite keeps the process cwd unchanged and rebases +/// config lookup onto the selected root. +pub(super) fn vite_config_dir(args: &[String], cwd: &AbsolutePath) -> Option { + let mut positional: Option<&str> = None; + let mut config: Option<&str> = None; + let mut iter = args.iter().peekable(); + while let Some(arg) = iter.next() { + if !arg.starts_with('-') { + positional.get_or_insert(arg); + continue; + } + // `--` terminates options: the first following token is the + // positional, and nothing after it can be a flag. + if arg == "--" { + if positional.is_none() { + positional = iter.next().map(String::as_str); + } + break; + } + if arg == "-c" || arg == "--config" { + if let Some(next) = iter.peek() { + if !next.starts_with('-') { + config = iter.next().map(String::as_str); + } + } + continue; + } + if let Some(value) = arg.strip_prefix("-c=").or_else(|| arg.strip_prefix("--config=")) { + config = Some(value); + continue; + } + let is_boolean = VITE_BOOLEAN_FLAGS.contains(&arg.as_str()) || arg.starts_with("--no-"); + if !is_boolean + && !arg.contains('=') + && iter.peek().is_some_and(|next| !next.starts_with('-')) + { + iter.next(); + } + } + if let Some(config) = config { + // The config's parent dir, resolved like Vite resolves `--config` + // (relative to cwd). An empty parent means the config sits in cwd. + let parent = std::path::Path::new(config).parent().unwrap_or(std::path::Path::new("")); + return Some(cwd.join(parent).clean()); + } + positional.map(|root| cwd.join(root).clean()) +} + /// Heuristic ranking signal: does a directory look runnable for `command`? /// Used for ordering and single-candidate auto-selection, never for hiding. /// The rules are documented in rfcs/cwd-flag.md ("The likely-runnable @@ -513,6 +569,44 @@ pub(super) fn resolve_app_target( mod tests { use super::*; + #[test] + fn vite_config_dir_selects_the_app_dir() { + let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::>(); + let root = if cfg!(windows) { "C:\\ws" } else { "/ws" }; + let cwd = AbsolutePath::new(root).unwrap(); + let dir = |rel: &str| Some(cwd.join(rel).clean()); + + // No target selection: fall back to the command's cwd. + assert_eq!(vite_config_dir(&to_args(&[]), cwd), None); + assert_eq!(vite_config_dir(&to_args(&["--mode", "production"]), cwd), None); + + // The `[root]` positional, wherever cac would see one. + assert_eq!(vite_config_dir(&to_args(&["apps/web"]), cwd), dir("apps/web")); + assert_eq!( + vite_config_dir(&to_args(&["--mode", "production", "apps/web"]), cwd), + dir("apps/web") + ); + assert_eq!(vite_config_dir(&to_args(&["-w", "apps/web"]), cwd), dir("apps/web")); + assert_eq!(vite_config_dir(&to_args(&["--", "apps/web"]), cwd), dir("apps/web")); + + // An explicit config wins over the positional in either order; its + // parent is where the config's imports resolve from. + assert_eq!( + vite_config_dir(&to_args(&["-c", "apps/web/vite.config.ts"]), cwd), + dir("apps/web") + ); + assert_eq!( + vite_config_dir(&to_args(&["apps/web", "--config=conf/vite.config.ts"]), cwd), + dir("conf") + ); + assert_eq!( + vite_config_dir(&to_args(&["-c", "conf/vite.config.ts", "apps/web"]), cwd), + dir("conf") + ); + // A config in the cwd itself keeps the cwd as the guard dir. + assert_eq!(vite_config_dir(&to_args(&["-c", "vite.config.ts"]), cwd), dir("")); + } + #[test] fn bare_means_no_positional_target_and_no_help() { let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::>(); diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs index e006cea328..54c2ce4257 100644 --- a/packages/cli/binding/src/cli/resolver.rs +++ b/packages/cli/binding/src/cli/resolver.rs @@ -12,6 +12,25 @@ use super::{ types::{CliOptions, ResolvedSubcommand, ResolvedUniversalViteConfig, SynthesizableSubcommand}, }; +/// The directory string handed to the JS `vite` resolver: the Vite app dir +/// selected by a `[root]` positional or an explicit `-c`/`--config` file +/// (where the app's config and plugins resolve `vite` from), falling back to +/// the command's cwd. +fn vite_resolver_dir( + args: &[String], + cwd: &AbsolutePath, + cwd_string: &str, +) -> anyhow::Result { + match super::app_target::vite_config_dir(args, cwd) { + Some(dir) => Ok(dir + .as_path() + .to_str() + .ok_or_else(|| anyhow::anyhow!("vite root is not valid UTF-8"))? + .to_string()), + None => Ok(cwd_string.to_string()), + } +} + /// Resolves synthesizable subcommands to concrete programs and arguments. /// Used by both direct CLI execution and CommandHandler. pub struct SubcommandResolver { @@ -162,7 +181,8 @@ impl SubcommandResolver { } SynthesizableSubcommand::Build { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.vite)(cwd_string.clone()).await?; + let resolved = + (cli_options.vite)(vite_resolver_dir(&args, cwd, &cwd_string)?).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -245,7 +265,8 @@ impl SubcommandResolver { } SynthesizableSubcommand::Dev { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.vite)(cwd_string.clone()).await?; + let resolved = + (cli_options.vite)(vite_resolver_dir(&args, cwd, &cwd_string)?).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str() @@ -263,7 +284,8 @@ impl SubcommandResolver { } SynthesizableSubcommand::Preview { args } => { let cli_options = self.cli_options()?; - let resolved = (cli_options.vite)(cwd_string.clone()).await?; + let resolved = + (cli_options.vite)(vite_resolver_dir(&args, cwd, &cwd_string)?).await?; let js_path = resolved.bin_path; let js_path_str = js_path .to_str()