-
Notifications
You must be signed in to change notification settings - Fork 92
feat(build): add vite-plus support #7014
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' }, | ||
| ]) | ||
| }) |
| 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 [] | ||
| } | ||
| } | ||
| 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')) | ||
| }) | ||
| }) |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
At Line 21, root 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 |
||
| 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 | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π§© Analysis chainπ Script executed: cat -n packages/build/src/plugins_core/vite_plus_setup/index.ts | head -100Repository: netlify/build Length of output: 3791 π Script executed: find . -type f -name "*.test.*" -o -name "*.spec.*" | grep -i "vite" | head -20Repository: netlify/build Length of output: 233 π Script executed: cat -n packages/build/src/plugins_core/vite_plus_setup/index.test.tsRepository: netlify/build Length of output: 5086 Use The hard-coded 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 |
||
|
|
||
| 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, | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.