Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 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,61 @@
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()

expect(detected[0]?.name).toBe('Vite+')
expect(detected[0]?.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()

expect(detected[0]?.name).toBe('Vite+')
expect(detected[0]?.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+')

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' },
])
})
36 changes: 36 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,36 @@
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[]> {
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}`,
}))
}
return []
}
}
128 changes: 128 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,128 @@
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 } from 'execa'
import { getPackageJson } from '../../utils/package.js'

const mockedExeca = vi.mocked(execa)
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).toHaveBeenCalledWith('bash', ['-c', 'curl -fsSL https://vite.plus | bash'], {
env: expect.objectContaining({
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).toHaveBeenCalledWith(
'bash',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
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'))
})
})
93 changes: 93 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,93 @@
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> => {
await execa('bash', ['-c', `curl -fsSL ${INSTALL_URL} | bash`], {
env: { ...process.env, VP_VERSION: version, VITE_PLUS_VERSION: version },
stdio: 'pipe',
})

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
}

const { packageJson } = await getPackageJson(buildDir)

if (hasVitePlusPackage(packageJson)) {
return true
}

if (packagePath) {
const { packageJson: workspacePackageJson } = await getPackageJson(join(buildDir, packagePath))

if (hasVitePlusPackage(workspacePackageJson)) {
return true
}
}

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