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..c93c4f183d --- /dev/null +++ b/packages/build-info/src/build-systems/vite-plus.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, 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() +}) + +test('detects Vite+ when vite-plus is in devDependencies', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { 'vite-plus': '^1.0.0' } }), + }) + const detected = await new Project(fs, cwd).detectBuildSystem() + + const vitePlus = detected.find((b) => b.name === 'Vite+') + expect(vitePlus).toBeDefined() + expect(vitePlus?.version).toBe('^1.0.0') +}) + +test('detects Vite+ when vite-plus is in dependencies', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ dependencies: { 'vite-plus': '^2.0.0' } }), + }) + const detected = await new Project(fs, cwd).detectBuildSystem() + + const vitePlus = detected.find((b) => b.name === 'Vite+') + expect(vitePlus).toBeDefined() + expect(vitePlus?.version).toBe('^2.0.0') +}) + +test('does not detect Vite+ when vite-plus is absent', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { vite: '^5.0.0' } }), + }) + const detected = await new Project(fs, cwd).detectBuildSystem() + + expect(detected.find((b) => b.name === 'Vite+')).toBeUndefined() +}) + +test('generates vp run commands from package.json scripts', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ + devDependencies: { 'vite-plus': '^1.0.0' }, + scripts: { + build: 'vite build', + dev: 'vite dev', + test: 'vitest', + }, + }), + }) + const project = new Project(fs, cwd) + const detected = await project.detectBuildSystem() + const vitePlus = detected.find((b) => b.name === 'Vite+') + + expect(vitePlus).toBeDefined() + const commands = await vitePlus!.getCommands!('') + expect(commands).toEqual([ + { type: 'build', command: 'vp run build' }, + { type: 'dev', command: 'vp run dev' }, + { type: 'unknown', command: 'vp run test' }, + ]) +}) 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..5cd4bd20b1 --- /dev/null +++ b/packages/build-info/src/build-systems/vite-plus.ts @@ -0,0 +1,40 @@ +import { isNpmBuildScript, isNpmDevScript } from '../get-commands.js' + +import { BaseBuildTool, type Command } from './build-system.js' + +export class VitePlus extends BaseBuildTool { + id = 'vite-plus' + name = 'Vite+' + + async detect(): Promise { + const pkgJsonPath = await this.project.fs.findUp('package.json', { + cwd: this.project.baseDirectory, + stopAt: this.project.root, + }) + if (pkgJsonPath) { + const pkg = await this.project.fs.readJSON>>(pkgJsonPath) + if (pkg.dependencies?.['vite-plus'] || pkg.devDependencies?.['vite-plus']) { + this.version = pkg.devDependencies?.['vite-plus'] ?? pkg.dependencies?.['vite-plus'] + return this + } + } + } + + async getCommands(packagePath: string): Promise { + try { + const { scripts } = await this.project.fs.readJSON>>( + this.project.resolveFromPackage(packagePath, 'package.json'), + ) + + if (scripts && Object.keys(scripts).length > 0) { + return Object.entries(scripts).map(([scriptName, value]) => ({ + type: isNpmDevScript(scriptName, value) ? 'dev' : isNpmBuildScript(scriptName, value) ? 'build' : 'unknown', + command: `vp run ${scriptName}`, + })) + } + } catch { + // noop + } + return [] + } +} diff --git a/packages/build/src/plugins_core/vite_plus_setup/index.test.ts b/packages/build/src/plugins_core/vite_plus_setup/index.test.ts new file mode 100644 index 0000000000..8afca6dd88 --- /dev/null +++ b/packages/build/src/plugins_core/vite_plus_setup/index.test.ts @@ -0,0 +1,130 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, test, vi, beforeEach } from 'vitest' + +import { hasVitePlusPackage, getVitePlusVersion, installVitePlusCli } from './index.js' + +vi.mock('execa', () => ({ + execa: vi.fn().mockResolvedValue({ exitCode: 0 }), +})) + +vi.mock('../../utils/package.js', () => ({ + getPackageJson: vi.fn().mockResolvedValue({ packageJson: {} }), +})) + +import { execa, type Options, type ExecaChildProcess } from 'execa' +import { getPackageJson } from '../../utils/package.js' + +const mockedExeca = vi.mocked(execa as (file: string, args?: readonly string[], options?: Options) => ExecaChildProcess) +const mockedGetPackageJson = vi.mocked(getPackageJson) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('hasVitePlusPackage', () => { + test('returns true when vite-plus is in dependencies', () => { + expect(hasVitePlusPackage({ dependencies: { 'vite-plus': '^1.0.0' } })).toBe(true) + }) + + test('returns true when vite-plus is in devDependencies', () => { + expect(hasVitePlusPackage({ devDependencies: { 'vite-plus': '^1.0.0' } })).toBe(true) + }) + + test('returns false when vite-plus is not present', () => { + expect(hasVitePlusPackage({ dependencies: { vue: '^3.0.0' } })).toBe(false) + }) + + test('returns false for empty package.json', () => { + expect(hasVitePlusPackage({})).toBe(false) + }) +}) + +describe('getVitePlusVersion', () => { + test('returns version from root devDependencies', async () => { + mockedGetPackageJson.mockResolvedValueOnce({ + packageJson: { devDependencies: { 'vite-plus': '^2.0.0' } }, + }) + + expect(await getVitePlusVersion('/project')).toBe('^2.0.0') + }) + + test('returns version from root dependencies', async () => { + mockedGetPackageJson.mockResolvedValueOnce({ + packageJson: { dependencies: { 'vite-plus': '1.5.0' } }, + }) + + expect(await getVitePlusVersion('/project')).toBe('1.5.0') + }) + + test('prefers devDependencies over dependencies', async () => { + mockedGetPackageJson.mockResolvedValueOnce({ + packageJson: { + dependencies: { 'vite-plus': '1.0.0' }, + devDependencies: { 'vite-plus': '2.0.0' }, + }, + }) + + expect(await getVitePlusVersion('/project')).toBe('2.0.0') + }) + + test('falls back to workspace package.json', async () => { + mockedGetPackageJson.mockResolvedValueOnce({ packageJson: {} }) + mockedGetPackageJson.mockResolvedValueOnce({ + packageJson: { devDependencies: { 'vite-plus': '3.0.0' } }, + }) + + expect(await getVitePlusVersion('/project', 'packages/app')).toBe('3.0.0') + }) + + test('returns latest when not found anywhere', async () => { + mockedGetPackageJson.mockResolvedValueOnce({ packageJson: {} }) + + expect(await getVitePlusVersion('/project')).toBe('latest') + }) + + test('returns latest when not found in workspace either', async () => { + mockedGetPackageJson.mockResolvedValueOnce({ packageJson: {} }) + mockedGetPackageJson.mockResolvedValueOnce({ packageJson: {} }) + + expect(await getVitePlusVersion('/project', 'packages/app')).toBe('latest') + }) +}) + +describe('installVitePlusCli', () => { + test('calls curl with the install script', async () => { + await installVitePlusCli('1.0.0') + + expect(mockedExeca).toHaveBeenCalledOnce() + const [cmd, args, opts] = mockedExeca.mock.calls[0] + expect(cmd).toBe('bash') + expect(args).toEqual(['-o', 'pipefail', '-c', 'curl -fsSL --max-time 120 https://vite.plus | bash']) + expect(opts).toMatchObject({ + env: { + VP_VERSION: '1.0.0', + VITE_PLUS_VERSION: '1.0.0', + }, + stdio: 'pipe', + }) + }) + + test('passes latest as version env vars', async () => { + await installVitePlusCli('latest') + + expect(mockedExeca).toHaveBeenCalledOnce() + const [, , opts] = mockedExeca.mock.calls[0] + expect(opts).toMatchObject({ + env: { + VP_VERSION: 'latest', + VITE_PLUS_VERSION: 'latest', + }, + }) + }) + + test('returns the vite-plus bin directory', async () => { + const binDir = await installVitePlusCli('1.0.0') + + expect(binDir).toBe(join(homedir(), '.vite-plus', 'bin')) + }) +}) diff --git a/packages/build/src/plugins_core/vite_plus_setup/index.ts b/packages/build/src/plugins_core/vite_plus_setup/index.ts new file mode 100644 index 0000000000..18f66a5e17 --- /dev/null +++ b/packages/build/src/plugins_core/vite_plus_setup/index.ts @@ -0,0 +1,105 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { execa } from 'execa' + +import { log } from '../../log/logger.js' +import { THEME } from '../../log/theme.js' +import { getPackageJson, type PackageJson } from '../../utils/package.js' +import { CoreStep, CoreStepCondition, CoreStepFunction } from '../types.js' + +const NPM_PACKAGE_NAME = 'vite-plus' +const INSTALL_URL = 'https://vite.plus' + +export const hasVitePlusPackage = (packageJSON: PackageJson): boolean => { + const { dependencies = {}, devDependencies = {} } = packageJSON + + return NPM_PACKAGE_NAME in dependencies || NPM_PACKAGE_NAME in devDependencies +} + +export const getVitePlusVersion = async (buildDir: string, packagePath?: string): Promise => { + const { packageJson } = await getPackageJson(buildDir) + const version = packageJson.devDependencies?.[NPM_PACKAGE_NAME] ?? packageJson.dependencies?.[NPM_PACKAGE_NAME] + + if (version) { + return version + } + + if (packagePath) { + const { packageJson: workspacePackageJson } = await getPackageJson(join(buildDir, packagePath)) + return ( + workspacePackageJson.devDependencies?.[NPM_PACKAGE_NAME] ?? + workspacePackageJson.dependencies?.[NPM_PACKAGE_NAME] ?? + 'latest' + ) + } + + return 'latest' +} + +export const installVitePlusCli = async (version: string): Promise => { + try { + await execa('bash', ['-o', 'pipefail', '-c', `curl -fsSL --max-time 120 ${INSTALL_URL} | bash`], { + env: { ...process.env, VP_VERSION: version, VITE_PLUS_VERSION: version }, + stdio: 'pipe', + }) + } catch (error) { + throw new Error(`Failed to install Vite+ CLI: ${error instanceof Error ? error.message : String(error)}`) + } + + const vitePlusBinDir = join(homedir(), '.vite-plus', 'bin') + return vitePlusBinDir +} + +const condition: CoreStepCondition = async ({ buildDir, packagePath, featureFlags }) => { + if (!featureFlags?.netlify_build_vite_plus_setup) { + return false + } + + try { + const { packageJson } = await getPackageJson(buildDir) + + if (hasVitePlusPackage(packageJson)) { + return true + } + } catch { + // noop — root package.json missing or invalid + } + + if (packagePath) { + try { + const { packageJson: workspacePackageJson } = await getPackageJson(join(buildDir, packagePath)) + + if (hasVitePlusPackage(workspacePackageJson)) { + return true + } + } catch { + // noop — workspace package.json missing or invalid + } + } + + return false +} + +const coreStep: CoreStepFunction = async ({ buildDir, packagePath, logs }) => { + const version = await getVitePlusVersion(buildDir, packagePath) + + log(logs, `Installing Vite+ CLI${version !== 'latest' ? ` (${THEME.highlightWords(version)})` : ''}`) + + const vitePlusBinDir = await installVitePlusCli(version) + const newPath = `${vitePlusBinDir}:${process.env.PATH ?? ''}` + process.env.PATH = newPath + + log(logs, `Vite+ CLI installed successfully`) + + return { newEnvChanges: { PATH: newPath } } +} + +export const vitePlusSetup: CoreStep = { + event: 'onPreBuild', + coreStep, + coreStepId: 'vite_plus_setup', + coreStepName: 'Vite+ setup', + coreStepDescription: () => 'Installing Vite+ CLI', + condition, +} diff --git a/packages/build/src/steps/get.ts b/packages/build/src/steps/get.ts index 4a5ef12876..c08ec201e2 100644 --- a/packages/build/src/steps/get.ts +++ b/packages/build/src/steps/get.ts @@ -8,6 +8,7 @@ import { bundleEdgeFunctions } from '../plugins_core/edge_functions/index.js' import { applyDeployConfig } from '../plugins_core/frameworks_api/index.js' import { bundleFunctions } from '../plugins_core/functions/index.js' import { dbSetup } from '../plugins_core/db_setup/index.js' +import { vitePlusSetup } from '../plugins_core/vite_plus_setup/index.js' import { copyDbMigrations } from '../plugins_core/db_setup/migrations.js' import { preCleanup } from '../plugins_core/pre_cleanup/index.js' import { preDevCleanup } from '../plugins_core/pre_dev_cleanup/index.js' @@ -78,6 +79,7 @@ const getEventSteps = function (eventHandlers?: any[]) { const addCoreSteps = function (steps): CoreStep[] { return [ preCleanup, + vitePlusSetup, dbSetup, buildCommandCore, applyDeployConfig,