Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/build-info/src/build-systems/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ export const buildSystems = [
Pants,
Rush,
Turbo,
VitePlus,

// JavaScript Package managers that offer building from a workspace
PNPM,
Expand Down
64 changes: 64 additions & 0 deletions packages/build-info/src/build-systems/vite-plus.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
])
})
40 changes: 40 additions & 0 deletions packages/build-info/src/build-systems/vite-plus.ts
Original file line number Diff line number Diff line change
@@ -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<this | undefined> {
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<Record<string, Record<string, string>>>(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<Command[]> {
try {
const { scripts } = await this.project.fs.readJSON<Record<string, Record<string, string>>>(
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 []
}
}
130 changes: 130 additions & 0 deletions packages/build/src/plugins_core/vite_plus_setup/index.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
})
})
105 changes: 105 additions & 0 deletions packages/build/src/plugins_core/vite_plus_setup/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
const { packageJson } = await getPackageJson(buildDir)
const version = packageJson.devDependencies?.[NPM_PACKAGE_NAME] ?? packageJson.dependencies?.[NPM_PACKAGE_NAME]

Comment on lines +20 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getVitePlusVersion can fail after condition already passed.

At Line 21, root getPackageJson(buildDir) is not guarded. If root package.json is unreadable but workspace has vite-plus, condition returns true and coreStep still crashes during version resolution.

Proposed fix
 export const getVitePlusVersion = async (buildDir: string, packagePath?: string): Promise<string> => {
-  const { packageJson } = await getPackageJson(buildDir)
-  const version = packageJson.devDependencies?.[NPM_PACKAGE_NAME] ?? packageJson.dependencies?.[NPM_PACKAGE_NAME]
-
-  if (version) {
-    return version
-  }
+  try {
+    const { packageJson } = await getPackageJson(buildDir)
+    const version = packageJson.devDependencies?.[NPM_PACKAGE_NAME] ?? packageJson.dependencies?.[NPM_PACKAGE_NAME]
+    if (version) {
+      return version
+    }
+  } catch {
+    // noop β€” root package.json missing or invalid
+  }

   if (packagePath) {
-    const { packageJson: workspacePackageJson } = await getPackageJson(join(buildDir, packagePath))
-    return (
-      workspacePackageJson.devDependencies?.[NPM_PACKAGE_NAME] ??
-      workspacePackageJson.dependencies?.[NPM_PACKAGE_NAME] ??
-      'latest'
-    )
+    try {
+      const { packageJson: workspacePackageJson } = await getPackageJson(join(buildDir, packagePath))
+      return (
+        workspacePackageJson.devDependencies?.[NPM_PACKAGE_NAME] ??
+        workspacePackageJson.dependencies?.[NPM_PACKAGE_NAME] ??
+        'latest'
+      )
+    } catch {
+      // noop β€” workspace package.json missing or invalid
+    }
   }

   return 'latest'
 }

Also applies to: 28-35

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/build/src/plugins_core/vite_plus_setup/index.ts` around lines 20 -
23, The getVitePlusVersion function currently calls getPackageJson(buildDir)
without guarding for read errors which can crash coreStep even when a workspace
package contains NPM_PACKAGE_NAME; wrap the root getPackageJson(buildDir) call
in a try/catch (or otherwise handle its failure) so a missing/unreadable root
package.json falls back to scanning workspace packageJsons for NPM_PACKAGE_NAME,
and ensure the code checks for a defined packageJson before accessing
devDependencies/dependencies; reference getVitePlusVersion, getPackageJson, and
NPM_PACKAGE_NAME when making the change.

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<string> => {
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
Comment on lines +90 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n packages/build/src/plugins_core/vite_plus_setup/index.ts | head -100

Repository: netlify/build

Length of output: 3791


🏁 Script executed:

find . -type f -name "*.test.*" -o -name "*.spec.*" | grep -i "vite" | head -20

Repository: netlify/build

Length of output: 233


🏁 Script executed:

cat -n packages/build/src/plugins_core/vite_plus_setup/index.test.ts

Repository: netlify/build

Length of output: 5086


Use path.delimiter and avoid trailing delimiters when PATH is unset.

The hard-coded : delimiter is not cross-platform (Windows uses ;), and ${process.env.PATH ?? ''} creates a trailing delimiter when PATH is unset, resulting in an empty PATH segment. Prepend to PATH conditionally using path.delimiter:

Fix
-import { join } from 'node:path'
+import { delimiter, join } from 'node:path'
...
-  const newPath = `${vitePlusBinDir}:${process.env.PATH ?? ''}`
+  const newPath = process.env.PATH ? `${vitePlusBinDir}${delimiter}${process.env.PATH}` : vitePlusBinDir
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/build/src/plugins_core/vite_plus_setup/index.ts` around lines 90 -
91, The code currently builds newPath using a hard-coded ':' and may append a
trailing delimiter when process.env.PATH is undefined; update the PATH prepend
logic to use Node's path.delimiter and only add the existing PATH segment if
process.env.PATH is non-empty: compute delimiter = require('path').delimiter (or
import path) and set process.env.PATH = vitePlusBinDir + (process.env.PATH ?
delimiter + process.env.PATH : '') so newPath (and assignment to
process.env.PATH) is cross-platform and avoids trailing delimiters.


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,
}
2 changes: 2 additions & 0 deletions packages/build/src/steps/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -78,6 +79,7 @@ const getEventSteps = function (eventHandlers?: any[]) {
const addCoreSteps = function (steps): CoreStep[] {
return [
preCleanup,
vitePlusSetup,
dbSetup,
buildCommandCore,
applyDeployConfig,
Expand Down
Loading