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 + } }