diff --git a/package-lock.json b/package-lock.json
index 528f05500e..46cfb983e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15886,6 +15886,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lockparse": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.5.2.tgz",
+ "integrity": "sha512-iu19W86kvrT2MLpvyXoIn/351Rb3PO7v7ljgFRQYj8tGpBRvNO9cZEI8x/7GGN+WN6BbrWpXc5CPrgPUjgX6Iw==",
+ "license": "MIT"
+ },
"node_modules/lodash": {
"version": "4.18.1",
"license": "MIT"
@@ -23139,6 +23145,7 @@
"@bugsnag/js": "^8.0.0",
"dot-prop": "^9.0.0",
"find-up": "^7.0.0",
+ "lockparse": "^0.5.2",
"minimatch": "^10.2.4",
"read-pkg": "^9.0.0",
"semver": "^7.3.8",
diff --git a/packages/build-info/assets/logos/vite-plus/icon-color-dark.svg b/packages/build-info/assets/logos/vite-plus/icon-color-dark.svg
new file mode 100644
index 0000000000..a7c3ce4897
--- /dev/null
+++ b/packages/build-info/assets/logos/vite-plus/icon-color-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/build-info/assets/logos/vite-plus/icon-color-light.svg b/packages/build-info/assets/logos/vite-plus/icon-color-light.svg
new file mode 100644
index 0000000000..bbbbf6f688
--- /dev/null
+++ b/packages/build-info/assets/logos/vite-plus/icon-color-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/build-info/package.json b/packages/build-info/package.json
index af14a5ba4e..3e29796807 100644
--- a/packages/build-info/package.json
+++ b/packages/build-info/package.json
@@ -48,6 +48,7 @@
"smol-toml": "^1.5.2",
"dot-prop": "^9.0.0",
"find-up": "^7.0.0",
+ "lockparse": "^0.5.2",
"minimatch": "^10.2.4",
"read-pkg": "^9.0.0",
"semver": "^7.3.8",
diff --git a/packages/build-info/src/build-systems/index.ts b/packages/build-info/src/build-systems/index.ts
index e8f50932bf..1f8bce9777 100644
--- a/packages/build-info/src/build-systems/index.ts
+++ b/packages/build-info/src/build-systems/index.ts
@@ -10,6 +10,7 @@ import { NPM, PNPM, Yarn } from './package-managers.js'
import { Pants } from './pants.js'
import { Rush } from './rush.js'
import { Turbo } from './turbo.js'
+import { VitePlus } from './vite-plus.js'
export const buildSystems = [
Bazel,
@@ -23,6 +24,7 @@ export const buildSystems = [
Pants,
Rush,
Turbo,
+ VitePlus,
// JavaScript Package managers that offer building from a workspace
PNPM,
diff --git a/packages/build-info/src/build-systems/vite-plus.test.ts b/packages/build-info/src/build-systems/vite-plus.test.ts
new file mode 100644
index 0000000000..0e8fc6507f
--- /dev/null
+++ b/packages/build-info/src/build-systems/vite-plus.test.ts
@@ -0,0 +1,190 @@
+import { join } from 'path'
+
+import { beforeEach, describe, expect, test } from 'vitest'
+
+import { mockFileSystem } from '../../tests/mock-file-system.js'
+import { NodeFS } from '../node/file-system.js'
+import { Project } from '../project.js'
+
+beforeEach((ctx) => {
+ ctx.fs = new NodeFS()
+})
+
+const NPM_LOCKFILE = JSON.stringify({
+ name: 'fixture',
+ version: '1.0.0',
+ lockfileVersion: 3,
+ requires: true,
+ packages: {
+ '': { name: 'fixture', version: '1.0.0', devDependencies: { 'vite-plus': '^0.2.0' } },
+ 'node_modules/vite-plus': {
+ version: '0.2.4',
+ resolved: 'https://registry.npmjs.org/vite-plus/-/vite-plus-0.2.4.tgz',
+ integrity: 'sha512-fixture',
+ },
+ },
+})
+
+const PNPM_LOCKFILE = `lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ vite-plus:
+ specifier: ^0.2.0
+ version: 0.2.4
+
+packages:
+
+ vite-plus@0.2.4:
+ resolution: {integrity: sha512-fixture}
+`
+
+const YARN_LOCKFILE = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+vite-plus@^0.2.0:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/vite-plus/-/vite-plus-0.2.4.tgz#fixture"
+ integrity sha512-fixture
+`
+
+test('detects vite-plus via a devDependency and reports the declared version without a lock file', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { 'vite-plus': '^0.2.0' } }),
+ })
+ const detected = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(detected[0]?.id).toBe('vite-plus')
+ expect(detected[0]?.name).toBe('Vite Plus')
+ expect(detected[0]?.version).toBe('^0.2.0')
+})
+
+test('detects vite-plus via a regular dependency', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ dependencies: { 'vite-plus': '0.2.4' } }),
+ })
+ const detected = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(detected[0]?.id).toBe('vite-plus')
+ expect(detected[0]?.version).toBe('0.2.4')
+})
+
+test('does not detect vite-plus without the dependency', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { vite: '^6.0.0' } }),
+ })
+ const detected = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(detected.find(({ id }) => id === 'vite-plus')).toBeUndefined()
+})
+
+describe.each([
+ { lockfileName: 'package-lock.json', lockfileContents: NPM_LOCKFILE },
+ { lockfileName: 'pnpm-lock.yaml', lockfileContents: PNPM_LOCKFILE },
+ { lockfileName: 'yarn.lock', lockfileContents: YARN_LOCKFILE },
+])('with a $lockfileName lock file', ({ lockfileName, lockfileContents }) => {
+ test('resolves the locked version', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { 'vite-plus': '^0.2.0' } }),
+ [lockfileName]: lockfileContents,
+ })
+ const detected = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(detected[0]?.id).toBe('vite-plus')
+ expect(detected[0]?.version).toBe('0.2.4')
+ })
+})
+
+test('detects vite-plus from a monorepo base directory with the dependency at the root', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({
+ workspaces: ['apps/*'],
+ devDependencies: { 'vite-plus': '^0.2.0' },
+ }),
+ 'package-lock.json': NPM_LOCKFILE,
+ 'apps/web/package.json': JSON.stringify({ name: 'web' }),
+ })
+ const detected = await new Project(fs, join(cwd, 'apps/web'), cwd).detectBuildSystem()
+
+ const vitePlus = detected.find(({ id }) => id === 'vite-plus')
+ expect(vitePlus?.version).toBe('0.2.4')
+})
+
+test('resolves against the lock file of the package.json with vite-plus dep', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({
+ workspaces: ['apps/*'],
+ devDependencies: { 'vite-plus': '^0.2.0' },
+ }),
+ 'package-lock.json': NPM_LOCKFILE,
+ 'apps/web/package.json': JSON.stringify({ name: 'web' }),
+ // stray nested lock file that does not lock the root's dependencies
+ 'apps/web/package-lock.json': JSON.stringify({
+ name: 'web',
+ version: '1.0.0',
+ lockfileVersion: 3,
+ requires: true,
+ packages: {
+ '': { name: 'web', version: '1.0.0' },
+ },
+ }),
+ })
+ const detected = await new Project(fs, join(cwd, 'apps/web'), cwd).detectBuildSystem()
+
+ const vitePlus = detected.find(({ id }) => id === 'vite-plus')
+ expect(vitePlus?.version).toBe('0.2.4')
+})
+
+test('provides vp run workspace commands for a package', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ workspaces: ['apps/*'], devDependencies: { 'vite-plus': '^0.2.0' } }),
+ 'apps/web/package.json': JSON.stringify({
+ name: 'web',
+ scripts: { dev: 'vite', build: 'vite build', lint: 'oxlint' },
+ }),
+ })
+ const [vitePlus] = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(vitePlus.id).toBe('vite-plus')
+ expect(await vitePlus.getCommands('apps/web')).toEqual([
+ { type: 'dev', command: 'vp run --filter web dev' },
+ { type: 'build', command: 'vp run --filter web build' },
+ { type: 'unknown', command: 'vp run --filter web lint' },
+ ])
+})
+
+test('returns no commands for a package without a name', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ workspaces: ['apps/*'], devDependencies: { 'vite-plus': '^0.2.0' } }),
+ 'apps/web/package.json': JSON.stringify({ scripts: { build: 'vite build' } }),
+ })
+ const [vitePlus] = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(await vitePlus.getCommands('apps/web')).toEqual([])
+})
+
+test('falls back to the declared version when the lock file does not contain the package', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { 'vite-plus': '^0.2.0' } }),
+ 'package-lock.json': JSON.stringify({
+ name: 'fixture',
+ version: '1.0.0',
+ lockfileVersion: 3,
+ requires: true,
+ packages: {
+ '': { name: 'fixture', version: '1.0.0' },
+ },
+ }),
+ })
+ const detected = await new Project(fs, cwd).detectBuildSystem()
+
+ expect(detected[0]?.id).toBe('vite-plus')
+ expect(detected[0]?.version).toBe('^0.2.0')
+})
diff --git a/packages/build-info/src/build-systems/vite-plus.ts b/packages/build-info/src/build-systems/vite-plus.ts
new file mode 100644
index 0000000000..e10cae320b
--- /dev/null
+++ b/packages/build-info/src/build-systems/vite-plus.ts
@@ -0,0 +1,101 @@
+import { parse, type PackageJsonLike } from 'lockparse'
+import type { PackageJson } from 'read-pkg'
+
+import { NPM_BUILD_SCRIPTS, NPM_DEV_SCRIPTS } from '../get-commands.js'
+
+import { BaseBuildTool, type Command } from './build-system.js'
+
+const PACKAGE_NAME = 'vite-plus'
+
+/** Lock files `lockparse` can read (bun.lockb is binary and unsupported) */
+const LOCK_FILES = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock']
+
+export class VitePlus extends BaseBuildTool {
+ id = 'vite-plus'
+ name = 'Vite Plus'
+ runFromRoot = true
+
+ logo = {
+ default: '/logos/vite-plus/icon-color-light.svg',
+ light: '/logos/vite-plus/icon-color-light.svg',
+ dark: '/logos/vite-plus/icon-color-dark.svg',
+ }
+
+ /** Retrieves a list of possible commands for a package */
+ async getCommands(packagePath: string): Promise {
+ const { scripts, name } = await this.project.fs.readJSON(
+ this.project.resolveFromPackage(packagePath, 'package.json'),
+ )
+
+ if (name && scripts && Object.values(scripts).length) {
+ return Object.keys(scripts).map((target) => {
+ let type: Command['type'] = 'unknown'
+
+ if (NPM_DEV_SCRIPTS.includes(target)) {
+ type = 'dev'
+ }
+
+ if (NPM_BUILD_SCRIPTS.includes(target)) {
+ type = 'build'
+ }
+
+ return {
+ type,
+ command: `vp run --filter ${name} ${target}`,
+ }
+ })
+ }
+ return []
+ }
+
+ /**
+ * Vite Plus has no unique config file (vite.config.* is shared with plain Vite), so
+ * it's detected through a dependency on the `vite-plus` package, declared anywhere
+ * from the base directory up to the repository root.
+ */
+ async detect(): Promise {
+ const pkgJsonPaths = await this.project.fs.findUpMultiple('package.json', {
+ cwd: this.project.baseDirectory,
+ stopAt: this.project.root,
+ })
+
+ for (const pkgJsonPath of pkgJsonPaths) {
+ const pkgJson = await this.project.fs.readJSON(pkgJsonPath)
+ const declared = pkgJson.devDependencies?.[PACKAGE_NAME] ?? pkgJson.dependencies?.[PACKAGE_NAME]
+ if (declared) {
+ // Unlike other build systems, report the exact locked version when resolvable:
+ // consumers install the vp CLI version matching the project's lock file.
+ // Note that, since vite-plus is also used as a package manager (... wrapper), we can't just *install* first and
+ // then read the installed version 🔁.
+ this.version = (await this.resolveLockedVersion(pkgJson, pkgJsonPath)) ?? declared
+ return this
+ }
+ }
+ }
+
+ private async resolveLockedVersion(pkgJson: Partial, pkgJsonPath: string): Promise {
+ const lockfilePath = await this.project.fs.findUp(LOCK_FILES, {
+ cwd: this.project.fs.dirname(pkgJsonPath),
+ stopAt: this.project.root,
+ })
+ if (!lockfilePath) {
+ return undefined
+ }
+
+ const content = await this.project.fs.gracefullyReadFile(lockfilePath)
+ if (!content) {
+ return undefined
+ }
+
+ try {
+ const fileName = this.project.fs.basename(lockfilePath)
+ // npm-shrinkwrap.json shares the package-lock.json format
+ const typeOrFileName = fileName === 'npm-shrinkwrap.json' ? 'npm' : fileName
+ const { packages } = await parse(content, typeOrFileName, pkgJson as PackageJsonLike)
+ return packages.find((pkg) => pkg.name === PACKAGE_NAME)?.version
+ } catch {
+ // An unparsable lock file shouldn't fail detection; fall back to the declared range
+ return undefined
+ }
+ }
+}
diff --git a/packages/build-info/src/frameworks/vite.test.ts b/packages/build-info/src/frameworks/vite.test.ts
new file mode 100644
index 0000000000..770802ca26
--- /dev/null
+++ b/packages/build-info/src/frameworks/vite.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, test } from 'vitest'
+
+import { mockFileSystem } from '../../tests/mock-file-system.js'
+import { NodeFS } from '../node/file-system.js'
+import { Project } from '../project.js'
+
+beforeEach((ctx) => {
+ ctx.fs = new NodeFS()
+})
+
+describe('vite', () => {
+ test('detects a vite project', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { vite: '^7.0.0' } }),
+ })
+ const detected = await new Project(fs, cwd).detectFrameworks()
+
+ expect(detected?.[0].id).toBe('vite')
+ expect(detected?.[0].build.command).toBe('vite build')
+ expect(detected?.[0].build.directory).toBe('dist')
+ expect(detected?.[0].dev?.command).toBe('vite')
+ })
+
+ test('uses the vp command surface for a vite-plus project', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { 'vite-plus': '^0.2.0' } }),
+ })
+ const detected = await new Project(fs, cwd).detectFrameworks()
+
+ expect(detected?.[0].id).toBe('vite')
+ expect(detected?.[0].build.command).toBe('vp build')
+ expect(detected?.[0].build.directory).toBe('dist')
+ expect(detected?.[0].dev?.command).toBe('vp dev')
+ })
+
+ test('prefers the vp command surface when both vite and vite-plus are present', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({ devDependencies: { vite: '^7.0.0', 'vite-plus': '^0.2.0' } }),
+ })
+ const detected = await new Project(fs, cwd).detectFrameworks()
+
+ expect(detected?.[0].id).toBe('vite')
+ expect(detected?.[0].build.command).toBe('vp build')
+ expect(detected?.[0].dev?.command).toBe('vp dev')
+ })
+
+ test('is not detected when a vite-based meta-framework is present', async ({ fs }) => {
+ const cwd = mockFileSystem({
+ 'package.json': JSON.stringify({
+ devDependencies: { 'vite-plus': '^0.2.0', '@sveltejs/kit': '^2.0.0', svelte: '^5.0.0' },
+ }),
+ })
+ const detected = await new Project(fs, cwd).detectFrameworks()
+
+ expect(detected?.find(({ id }) => id === 'vite')).toBeUndefined()
+ })
+})
diff --git a/packages/build-info/src/frameworks/vite.ts b/packages/build-info/src/frameworks/vite.ts
index ac27237b8f..8a5b57855e 100644
--- a/packages/build-info/src/frameworks/vite.ts
+++ b/packages/build-info/src/frameworks/vite.ts
@@ -1,9 +1,9 @@
-import { BaseFramework, Category, Framework } from './framework.js'
+import { BaseFramework, Category, DetectedFramework, Framework } from './framework.js'
export class Vite extends BaseFramework implements Framework {
readonly id = 'vite'
name = 'Vite'
- npmDependencies = ['vite']
+ npmDependencies = ['vite', 'vite-plus']
excludedNpmDependencies = [
'@remix-run/react',
'@remix-run/dev',
@@ -40,4 +40,21 @@ export class Vite extends BaseFramework implements Framework {
light: '/logos/vite/default.svg',
dark: '/logos/vite/default.svg',
}
+
+ async detect(): Promise {
+ const detected = await super.detect()
+
+ // Vite Plus projects drive Vite through the vp CLI (`vp migrate` may even remove
+ // the direct vite dependency), so prefer its command surface when present
+ const deps = {
+ ...this.detected?.packageJSON?.dependencies,
+ ...this.detected?.packageJSON?.devDependencies,
+ }
+ if (detected && deps['vite-plus']) {
+ this.dev.command = 'vp dev'
+ this.build.command = 'vp build'
+ }
+
+ return detected
+ }
}