From 5406aa1432602cecbc92dd44c9e6c90c082dfa83 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 17:31:46 +0800 Subject: [PATCH 1/7] 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 90a0f995a2387633ab9a04c52d4a9889c83b81db Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 17:32:13 +0800 Subject: [PATCH 2/7] 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 003b1f4743350a8cf28481957589a27e99d21820 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 18:14:43 +0800 Subject: [PATCH 3/7] 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 542bfce35f2e8f678f4fe71eeb5c220e6187c881 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 18:48:46 +0800 Subject: [PATCH 4/7] 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 8e8131d3d0..52b298d113 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. @@ -26,7 +27,7 @@ export const TSDOWN_MIGRATION_SKILL_URL = 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 9020161d210d37abc3dedbd1a338d2c0d3b44aae Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 20:02:46 +0800 Subject: [PATCH 5/7] 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 af6b9c8524..75c71f32b8 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3444,12 +3444,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; /** Whether the user supplied the global `-C` option. */ explicitChdir?: boolean; 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 f860d74d04..bcc48594c7 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, /// Whether the user supplied the global `-C` option. pub explicit_chdir: Option, @@ -100,18 +100,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 52b298d113..cb780143c0 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 e656bc25f5eb2cd050852bd4b4865d0c6039c472 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 17 Aug 2026 16:03:02 +0800 Subject: [PATCH 6/7] 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 | 111 ++++++++++++++++++ packages/cli/binding/src/cli/resolver.rs | 28 ++++- 6 files changed, 163 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 dc11244182..10fb150cb4 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -43,6 +43,79 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s } } +/// Boolean flags of the Vite CLI (dev/build/preview), from the shipped +/// `vp --help` (snap-tests/command-helper); keep in sync. Under +/// cac/mri parsing every OTHER flag — required-value, optional-value +/// (`--host [host]`), or unknown — consumes a following non-flag token as +/// its value, so only tokens no flag consumes are positional targets. +const VITE_BOOLEAN_FLAGS: &[&str] = &[ + "-w", + "--watch", + "--app", + "--clearScreen", + "--cors", + "--emptyOutDir", + "--experimentalBundle", + "--force", + "--profile", + "--strictPort", +]; + +/// 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 with cac/mri value consumption and scans them all 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()) +} + /// Does the workspace root have an intent signal for this app command? /// This signal selects the root. It does not prove that the command succeeds. /// The `defaultPackage` lookup passes the config that [`classify`] already @@ -423,6 +496,44 @@ mod tests { } } + #[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 vite_commands_share_the_source_index_signal() { let temp = tempfile::tempdir().expect("temporary directory should exist"); 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() From 940a08d41d50c5daa22cdeaa5dae0509035cdaa9 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 26 Aug 2026 01:18:47 +0800 Subject: [PATCH 7/7] fix(cli): clarify core version mismatch errors --- .../snapshots/core_version_guard.md | 19 +++++- packages/cli/binding/index.d.cts | 12 +++- packages/cli/binding/src/lib.rs | 64 ++++++++++++++++--- packages/cli/src/resolve-test.ts | 23 ++++--- packages/cli/src/resolve-vite.ts | 22 +++++-- .../__tests__/core-version-guard.spec.ts | 37 +++++++++-- packages/cli/src/utils/core-version-guard.ts | 51 ++++++++++++--- 7 files changed, 183 insertions(+), 45 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 6105332d38..a81e8796c1 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,14 @@ **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 (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. +error: Your `vite` alias uses @voidzero-dev/vite-plus-core@. +This Vite+ CLI requires @voidzero-dev/vite-plus-core@. + +Choose a fix: +- Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@. +- Run `vp migrate`. + +To skip this check, set VP_SKIP_CORE_VERSION_CHECK=1. ``` ## `vp test` @@ -13,7 +20,14 @@ 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 (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check. +error: Your `vite` alias uses @voidzero-dev/vite-plus-core@. +This Vite+ CLI requires @voidzero-dev/vite-plus-core@. + +Choose a fix: +- Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@. +- Run `vp migrate`. + +To skip this check, set VP_SKIP_CORE_VERSION_CHECK=1. ``` ## `vp build app` @@ -21,7 +35,6 @@ error: Failed to resolve test command: GenericFailure, Error: The project's `vit 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 diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 75c71f32b8..9a8a78c053 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3569,10 +3569,16 @@ export declare function getVpDirs(): VpDirsJs; */ export declare function hasConfigKey(viteConfigPath: string, configKey: string): boolean; -/** Result returned by JavaScript resolver functions. */ +/** + * A command or tagged user error returned by a JavaScript resolver. + * Successful results contain `binPath` and `envs`. User errors contain + * `errorKind` and `errorMessage`. + */ export interface JsCommandResolvedResult { - binPath: string; - envs: Record; + binPath?: string; + envs?: Record; + errorKind?: string; + errorMessage?: string; } /** diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index bcc48594c7..11dad7f14d 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -84,19 +84,63 @@ pub struct CliOptions { pub resolve_universal_vite_config: Arc>>, } -/// Result returned by JavaScript resolver functions. +/// A command or tagged user error returned by a JavaScript resolver. +/// Successful results contain `binPath` and `envs`. User errors contain +/// `errorKind` and `errorMessage`. #[napi(object, object_to_js = false)] pub struct JsCommandResolvedResult { - pub bin_path: String, - pub envs: HashMap, + pub bin_path: Option, + pub envs: Option>, + pub error_kind: Option, + pub error_message: Option, } -impl From for ResolveCommandResult { - fn from(value: JsCommandResolvedResult) -> Self { - Self { - bin_path: Arc::::from(OsStr::new(&value.bin_path).to_os_string()), - envs: value.envs.into_iter().collect(), - } +fn resolve_command_result( + value: JsCommandResolvedResult, + error_context: &str, +) -> anyhow::Result { + if let Some(error_kind) = value.error_kind { + let error_message = + value.error_message.unwrap_or_else(|| "resolver returned no error message".to_string()); + return if error_kind == "core-version-mismatch" { + Err(anyhow::anyhow!(error_message)) + } else { + Err(anyhow::anyhow!( + "{error_context}: unknown resolver error type '{error_kind}': {error_message}" + )) + }; + } + + let bin_path = value + .bin_path + .ok_or_else(|| anyhow::anyhow!("{error_context}: resolver returned no binary path"))?; + let envs = value + .envs + .ok_or_else(|| anyhow::anyhow!("{error_context}: resolver returned no environment"))?; + Ok(ResolveCommandResult { + bin_path: Arc::::from(OsStr::new(&bin_path).to_os_string()), + envs: envs.into_iter().collect(), + }) +} + +#[cfg(test)] +mod resolver_result_tests { + use super::*; + + #[test] + fn core_version_mismatch_keeps_only_the_user_message() { + let error = resolve_command_result( + JsCommandResolvedResult { + bin_path: None, + envs: None, + error_kind: Some("core-version-mismatch".to_string()), + error_message: Some("Update the `vite` alias.".to_string()), + }, + "Failed to resolve vite command", + ) + .expect_err("the tagged result should be an error"); + + assert_eq!(error.to_string(), "Update the `vite` alias."); } } @@ -121,7 +165,7 @@ fn create_resolver( let resolved: JsCommandResolvedResult = promise.await.map_err(|e| anyhow::anyhow!("{}: {}", error_message, e))?; - Ok(resolved.into()) + resolve_command_result(resolved, error_message) }) }) } diff --git a/packages/cli/src/resolve-test.ts b/packages/cli/src/resolve-test.ts index b342aa1f05..32b37cd1c5 100644 --- a/packages/cli/src/resolve-test.ts +++ b/packages/cli/src/resolve-test.ts @@ -14,7 +14,10 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { DEFAULT_ENVS, resolveBundled } from './utils/constants.ts'; -import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts'; +import { + checkCoreVersionMatchForResolver, + type CoreVersionResolverError, +} from './utils/core-version-guard.ts'; interface VitestPackageJson { bin?: string | Record; @@ -40,13 +43,17 @@ export async function test( // 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(taskDir); +): Promise< + | CoreVersionResolverError + | { + binPath: string; + envs: Record; + } +> { + const versionError = checkCoreVersionMatchForResolver(taskDir); + if (versionError) { + return versionError; + } 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 cbc3d758dd..8131fe1272 100644 --- a/packages/cli/src/resolve-vite.ts +++ b/packages/cli/src/resolve-vite.ts @@ -12,7 +12,10 @@ import { dirname, join } from 'node:path'; import { DEFAULT_ENVS, resolve } from './utils/constants.ts'; -import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts'; +import { + checkCoreVersionMatchForResolver, + type CoreVersionResolverError, +} from './utils/core-version-guard.ts'; /** * Resolves the Vite binary path and environment variables. @@ -30,12 +33,17 @@ export async function vite( // 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(taskDir); +): Promise< + | CoreVersionResolverError + | { + binPath: string; + envs: Record; + } +> { + const versionError = checkCoreVersionMatchForResolver(taskDir); + if (versionError) { + return versionError; + } // 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 e7d5d280bd..37f70bf425 100644 --- a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts +++ b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts @@ -7,7 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { VITE_PLUS_CORE_PACKAGE_NAME as CORE } from '../constants.ts'; import { assertCoreVersionMatch, + checkCoreVersionMatchForResolver, checkCoreVersionMatch, + CORE_VERSION_MISMATCH_ERROR_KIND, + CoreVersionMismatchError, SKIP_CORE_VERSION_CHECK_ENV, } from '../core-version-guard.ts'; @@ -16,10 +19,27 @@ describe('assertCoreVersionMatch', () => { expect(() => assertCoreVersionMatch('1.2.3', '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('throws with a readable explanation and fixes on a skew', () => { + const expectedMessage = [ + `Your \`vite\` alias uses ${CORE}@1.2.0.`, + `This Vite+ CLI requires ${CORE}@1.2.3.`, + '', + 'Choose a fix:', + `- Update the \`vite\` alias to npm:${CORE}@1.2.3.`, + '- Run `vp migrate`.', + '', + `To skip this check, set ${SKIP_CORE_VERSION_CHECK_ENV}=1.`, + ].join('\n'); + + let error: unknown; + try { + assertCoreVersionMatch('1.2.0', '1.2.3'); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CoreVersionMismatchError); + expect(error).toMatchObject({ message: expectedMessage }); }); it('does not throw when no aliased core is installed', () => { @@ -53,6 +73,15 @@ describe('checkCoreVersionMatch', () => { expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).toThrow(`${CORE}@1.2.0`); }); + it('returns a tagged resolver error when the installed core version differs', () => { + writeVitePackage({ name: CORE, version: '1.2.0' }); + + expect(checkCoreVersionMatchForResolver(projectDir, '1.2.3')).toMatchObject({ + errorKind: CORE_VERSION_MISMATCH_ERROR_KIND, + errorMessage: expect.stringContaining(`${CORE}@1.2.0`), + }); + }); + it('does not throw when the installed aliased core matches', () => { writeVitePackage({ name: CORE, version: '1.2.3' }); expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow(); diff --git a/packages/cli/src/utils/core-version-guard.ts b/packages/cli/src/utils/core-version-guard.ts index c23f4bfa07..401493baec 100644 --- a/packages/cli/src/utils/core-version-guard.ts +++ b/packages/cli/src/utils/core-version-guard.ts @@ -22,6 +22,16 @@ 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'; +export const CORE_VERSION_MISMATCH_ERROR_KIND = 'core-version-mismatch'; + +export class CoreVersionMismatchError extends Error { + override readonly name = 'CoreVersionMismatchError'; +} + +export interface CoreVersionResolverError { + errorKind: typeof CORE_VERSION_MISMATCH_ERROR_KIND; + errorMessage: string; +} /** * Throw when the project's aliased core version differs from the version the @@ -37,14 +47,13 @@ export function assertCoreVersionMatch( // 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 ${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:${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.`, + throw new CoreVersionMismatchError( + `Your \`vite\` alias uses ${VITE_PLUS_CORE_PACKAGE_NAME}@${installedVersion}.\n` + + `This Vite+ CLI requires ${VITE_PLUS_CORE_PACKAGE_NAME}@${expectedVersion}.\n\n` + + `Choose a fix:\n` + + `- Update the \`vite\` alias to npm:${VITE_PLUS_CORE_PACKAGE_NAME}@${expectedVersion}.\n` + + `- Run \`vp migrate\`.\n\n` + + `To skip this check, set ${SKIP_CORE_VERSION_CHECK_ENV}=1.`, ); } } @@ -82,10 +91,32 @@ const checkedDirs = new Set(); * runs (`defaultPackage`, `vp run -r`) execute in a package dir while the * Node process cwd stays at the invocation root. */ -export function checkCoreVersionMatchOnce(projectDir: string = process.cwd()): void { +export function checkCoreVersionMatchOnce( + projectDir: string = process.cwd(), + expectedVersion: string = CLI_PACKAGE_VERSION, +): void { if (checkedDirs.has(projectDir)) { return; } + checkCoreVersionMatch(projectDir, expectedVersion); checkedDirs.add(projectDir); - checkCoreVersionMatch(projectDir); +} + +/** Return a tagged error that Rust can distinguish from resolver failures. */ +export function checkCoreVersionMatchForResolver( + projectDir: string = process.cwd(), + expectedVersion: string = CLI_PACKAGE_VERSION, +): CoreVersionResolverError | undefined { + try { + checkCoreVersionMatchOnce(projectDir, expectedVersion); + return undefined; + } catch (error) { + if (error instanceof CoreVersionMismatchError) { + return { + errorKind: CORE_VERSION_MISMATCH_ERROR_KIND, + errorMessage: error.message, + }; + } + throw error; + } }