From 569ed1696f4998d193a2d6286547734e4dd2df13 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 22 Oct 2026 01:21:03 +0800 Subject: [PATCH 1/3] fix(create): run child commands without a global vp --- .../assert-create.mjs | 46 ++++++++++++++++++ .../mock-manifest.json | 34 +++++++++++++ .../create_without_global_vp/snapshots.toml | 26 ++++++++++ .../snapshots/ignores_unrelated_global_vp.md | 8 +++ .../snapshots/pnpm_without_global_vp.md | 8 +++ .../snapshots/yarn_without_global_vp.md | 8 +++ .../tarballs/create-1.0.0.tgz | Bin 0 -> 436 bytes .../src/migration/__tests__/prettier.spec.ts | 8 ++- .../cli/src/migration/__tests__/tsup.spec.ts | 14 +++++- packages/cli/src/migration/migrator/eslint.ts | 9 +--- .../cli/src/migration/migrator/git-hooks.ts | 6 +-- .../cli/src/migration/migrator/prettier.ts | 8 +-- packages/cli/src/migration/migrator/tsup.ts | 11 ++--- .../utils/__tests__/run-vite-install.spec.ts | 25 ++++++++-- packages/cli/src/utils/approve-builds.ts | 11 ++--- packages/cli/src/utils/command.ts | 11 +++++ packages/cli/src/utils/prompts.ts | 8 ++- packages/cli/src/utils/tsconfig.ts | 5 +- 18 files changed, 199 insertions(+), 47 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/mock-manifest.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/ignores_unrelated_global_vp.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/pnpm_without_global_vp.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/yarn_without_global_vp.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/tarballs/create-1.0.0.tgz diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs new file mode 100644 index 0000000000..bd1cfeacce --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const [packageManager, mode] = process.argv.slice(2); +const env = { ...process.env }; +const pathKey = Object.keys(env).find((key) => key.toUpperCase() === 'PATH'); +// Keep system tools such as Git, but remove every directory that exposes vp. +env[pathKey] = [ + path.dirname(process.execPath), + ...env[pathKey] + .split(path.delimiter) + .filter((dir) => !['vp', 'vp.exe', 'vp.cmd', 'vp.bat'].some((bin) => existsSync(path.join(dir, bin)))), +].join(path.delimiter); +delete env.VP_CLI_BIN; +assert.equal(spawnSync('vp', ['--version'], { env }).error?.code, 'ENOENT'); + +if (mode === 'unrelated') { + const binDir = path.resolve('unrelated-bin'); + mkdirSync(binDir); + writeFileSync(path.join(binDir, 'vp'), '#!/bin/sh\necho unrelated-vp >&2\nexit 99\n', { mode: 0o755 }); + writeFileSync(path.join(binDir, 'vp.cmd'), '@echo unrelated-vp >&2\r\n@exit /b 99\r\n'); + env[pathKey] = `${binDir}${path.delimiter}${env[pathKey]}`; +} + +env.npm_config_user_agent = packageManager.replace('@', '/'); +const cli = fileURLToPath(import.meta.resolve('vite-plus/bin')); +// Reuse the local bundled-template fixture, without an external template download. +const result = spawnSync(process.execPath, [ + cli, 'create', '@your-org:demo', '--directory', 'projects with spaces/app', + '--no-interactive', '--no-agent', '--no-editor', '--git', '--hooks', '--verbose', +], { env, encoding: 'utf8', timeout: 210000 }); +const output = `${result.stdout}\n${result.stderr}`; +assert.equal(result.status, 0, output || result.error?.message); +assert.match(output, /Dependencies installed/); +assert.match(output, /Code formatted/); +assert.match(output, /Git hooks configured/); +assert.doesNotMatch(output, /unrelated-vp|Failed to install git hooks|Install failed|Format failed/); +assert.ok(existsSync('projects with spaces/app/node_modules/vite-plus/package.json')); +assert.match(readFileSync('projects with spaces/app/.git/config', 'utf8'), /hooksPath/); +const lockfile = packageManager.startsWith('pnpm@') ? 'pnpm-lock.yaml' : 'yarn.lock'; +assert.ok(existsSync(path.join('projects with spaces/app', lockfile))); +console.log(`Created a project with ${packageManager.split('@')[0]} and ${mode === 'missing' ? 'no' : 'an unrelated'} vp on PATH.`); +console.log('Dependencies installed, code formatted, and Git hooks configured.'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/mock-manifest.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/mock-manifest.json new file mode 100644 index 0000000000..67f065589e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/mock-manifest.json @@ -0,0 +1,34 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-swwZCXG8RDBJNTmiw3zCOBCQx9bCg7wHqoW37aV7BgGYfBUU7n7c+efbSCg0+FpdLhgeUjEMP5IAO+8exmeFKg==" + }, + "createConfig": { + "templates": [ + { + "name": "demo", + "description": "Bundled demo template", + "template": "./templates/demo" + } + ] + } + } + } + }, + "pnpm/latest": { + "name": "pnpm", + "version": "12.11.2" + }, + "@yarnpkg/cli-dist/latest": { + "name": "@yarnpkg/cli-dist", + "version": "4.18.1" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots.toml new file mode 100644 index 0000000000..95771702ba --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots.toml @@ -0,0 +1,26 @@ +[[case]] +name = "pnpm_without_global_vp" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL", "CI"] +steps = [ + { argv = ["node", "assert-create.mjs", "pnpm@12.11.2", "missing"], timeout = 240000 }, +] + +[[case]] +name = "yarn_without_global_vp" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL", "CI"] +steps = [ + { argv = ["node", "assert-create.mjs", "yarn@4.18.1", "missing"], timeout = 240000 }, +] + +[[case]] +name = "ignores_unrelated_global_vp" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL", "CI"] +steps = [ + { argv = ["node", "assert-create.mjs", "pnpm@12.11.2", "unrelated"], timeout = 240000 }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/ignores_unrelated_global_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/ignores_unrelated_global_vp.md new file mode 100644 index 0000000000..e714b295a6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/ignores_unrelated_global_vp.md @@ -0,0 +1,8 @@ +# ignores_unrelated_global_vp + +## `node assert-create.mjs pnpm@12.11.2 unrelated` + +``` +Created a project with pnpm and an unrelated vp on PATH. +Dependencies installed, code formatted, and Git hooks configured. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/pnpm_without_global_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/pnpm_without_global_vp.md new file mode 100644 index 0000000000..4cbd13eb54 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/pnpm_without_global_vp.md @@ -0,0 +1,8 @@ +# pnpm_without_global_vp + +## `node assert-create.mjs pnpm@12.11.2 missing` + +``` +Created a project with pnpm and no vp on PATH. +Dependencies installed, code formatted, and Git hooks configured. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/yarn_without_global_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/yarn_without_global_vp.md new file mode 100644 index 0000000000..4024e48258 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/snapshots/yarn_without_global_vp.md @@ -0,0 +1,8 @@ +# yarn_without_global_vp + +## `node assert-create.mjs yarn@4.18.1 missing` + +``` +Created a project with yarn and no vp on PATH. +Dependencies installed, code formatted, and Git hooks configured. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/tarballs/create-1.0.0.tgz b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/tarballs/create-1.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b73b8ed8cd21806e348f9caa5995bdc798178622 GIT binary patch literal 436 zcmV;l0ZaZLiwFP!000006YZ8wYlA=#Ky&t24EEaUZlVdK6q+VN4+$x`l~Tm&HrPg2 zb~hoVg+`BbD?zK|T!tGYt}Ew!ysp;8StM#n=u73luXi zb#}&tjCraHs6{MNtVT?RBFHW^jIqo<(M2C4b%HkpHCN?D|h#^uH3=JmW$^$Wy66&jGk8|GU?y z?isMh|B=_}4LmD}j?v?w|E|03f1}x;=zk?#KyUN(Y#1F#;xtZz3A~iY>t)Wufq) ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn() }, })); -vi.mock('../../utils/command.ts', () => ({ +vi.mock(import('../../utils/command.ts'), async (importOriginal) => ({ + ...(await importOriginal()), runCommandSilently: vi.fn(), })); @@ -56,7 +57,10 @@ describe('Prettier migration', () => { ).resolves.toBe('skipped'); expect(mockRunCommandSilently).toHaveBeenCalledWith( - expect.objectContaining({ args: ['fmt', '--migrate=prettier'], cwd: projectPath }), + expect.objectContaining({ + args: [expect.any(String), 'fmt', '--migrate=prettier'], + cwd: projectPath, + }), ); expect(fs.readFileSync(path.join(projectPath, configFile), 'utf8')).toBe(config); expect(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8')).toBe( diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index 7935beb699..b048941707 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -24,7 +24,8 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ }, })); -vi.mock('../../utils/command.ts', () => ({ +vi.mock(import('../../utils/command.ts'), async (importOriginal) => ({ + ...(await importOriginal()), runCommandSilently: vi.fn(), })); vi.mock('../../utils/prompts.ts', () => ({ @@ -96,7 +97,16 @@ describe('tsup migration', () => { expect(mockRunCommandSilently).toHaveBeenCalledWith( expect.objectContaining({ - args: ['dlx', 'tsdown-migrate@0.99.0', '--yes', '--package-manager', 'npm', '--no-install'], + command: process.execPath, + args: [ + expect.any(String), + 'dlx', + 'tsdown-migrate@0.99.0', + '--yes', + '--package-manager', + 'npm', + '--no-install', + ], }), ); }); diff --git a/packages/cli/src/migration/migrator/eslint.ts b/packages/cli/src/migration/migrator/eslint.ts index 992a6e7e5f..bf9231d0e1 100644 --- a/packages/cli/src/migration/migrator/eslint.ts +++ b/packages/cli/src/migration/migrator/eslint.ts @@ -9,7 +9,7 @@ import { compareReversed, isLessThanOrEqual, normalize } from 'verkit'; import { ResolverFactory, rewriteEslint } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; -import { runCommandSilently } from '../../utils/command.ts'; +import { getVpCommand, runCommandSilently } from '../../utils/command.ts'; import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; import { fetchNpmResource, getNpmRegistry } from '../../utils/npm-config.ts'; import { displayRelative } from '../../utils/path.ts'; @@ -180,7 +180,6 @@ export async function resolveOxlintMigrateVersion(oxlintVersion: string): Promis * Returns true on success, false on failure (spawn error or non-zero exit). */ async function runOxlintMigrateStep( - vpBin: string, cwd: string, migratePackage: string, args: string[], @@ -190,8 +189,7 @@ async function runOxlintMigrateStep( ): Promise<{ ok: boolean; skippedRules?: string }> { try { const result = await runCommandSilently({ - command: vpBin, - args: ['dlx', migratePackage, ...args], + ...getVpCommand(['dlx', migratePackage, ...args]), cwd, envs: process.env, }); @@ -219,7 +217,6 @@ export async function migrateEslintToOxlint( packages?: WorkspacePackage[], options?: { silent?: boolean; report?: MigrationReport }, ): Promise { - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root @@ -240,7 +237,6 @@ export async function migrateEslintToOxlint( // Step 1: Generate .oxlintrc.json from ESLint config spinner.start('Migrating ESLint config to Oxlint...'); const migrateResult = await runOxlintMigrateStep( - vpBin, projectPath, migratePackage, migrateArgs, @@ -259,7 +255,6 @@ export async function migrateEslintToOxlint( // Step 2: Replace eslint-disable comments with oxlint-disable spinner.start('Replacing ESLint comments with Oxlint equivalents...'); const replaceResult = await runOxlintMigrateStep( - vpBin, projectPath, migratePackage, ['--replace-eslint-comments'], diff --git a/packages/cli/src/migration/migrator/git-hooks.ts b/packages/cli/src/migration/migrator/git-hooks.ts index 9c71499a46..987e249d1d 100644 --- a/packages/cli/src/migration/migrator/git-hooks.ts +++ b/packages/cli/src/migration/migrator/git-hooks.ts @@ -17,6 +17,7 @@ import { SUPPORTED_GIT_HOOK_NAMES, } from '../../config/hooks.ts'; import type { PackageManager, WorkspacePackage } from '../../types/index.ts'; +import { getVpCommand } from '../../utils/command.ts'; import { documentationUrl } from '../../utils/documentation.ts'; import { findGitRoot } from '../../utils/git.ts'; import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; @@ -474,10 +475,9 @@ export function setupGitHooks( return true; } - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; - // Install git hooks via vp config (--no-agent to skip agent setup, handled by migration) - const configResult = spawn.sync(vpBin, ['config', '--no-agent'], { + const { command, args } = getVpCommand(['config', '--no-agent']); + const configResult = spawn.sync(command, args, { cwd: projectPath, stdio: 'pipe', }); diff --git a/packages/cli/src/migration/migrator/prettier.ts b/packages/cli/src/migration/migrator/prettier.ts index 15338951b2..f837621375 100644 --- a/packages/cli/src/migration/migrator/prettier.ts +++ b/packages/cli/src/migration/migrator/prettier.ts @@ -6,7 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { rewritePrettier } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; -import { runCommandSilently } from '../../utils/command.ts'; +import { getVpCommand, runCommandSilently } from '../../utils/command.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; @@ -61,7 +61,6 @@ export function detectPrettierProject( * Unsupported config formats are skipped; other errors still fail migration. */ async function runPrettierMigrateStep( - vpBin: string, cwd: string, spinner: ReturnType, failMessage: string, @@ -69,8 +68,7 @@ async function runPrettierMigrateStep( ): Promise<'migrated' | 'skipped' | 'failed'> { try { const result = await runCommandSilently({ - command: vpBin, - args: ['fmt', '--migrate=prettier'], + ...getVpCommand(['fmt', '--migrate=prettier']), cwd, envs: process.env, }); @@ -109,7 +107,6 @@ export async function migratePrettierToOxfmt( packages?: WorkspacePackage[], options?: { silent?: boolean; report?: MigrationReport }, ): Promise<'migrated' | 'skipped' | 'failed'> { - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); // Step 1: Generate .oxfmtrc.json from Prettier config @@ -133,7 +130,6 @@ export async function migratePrettierToOxfmt( try { spinner.start('Migrating Prettier config to Oxfmt...'); const result = await runPrettierMigrateStep( - vpBin, projectPath, spinner, 'Prettier migration failed', diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 7eff1306e4..d9e5846fe9 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -6,7 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import corePkg from 'vite/package.json' with { type: 'json' }; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; -import { runCommandSilently } from '../../utils/command.ts'; +import { getVpCommand, runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; @@ -372,21 +372,19 @@ function extractTsdownMigrationWarnings(output: Buffer): string[] { /** Run `vp dlx tsdown-migrate` in `cwd` with graceful error handling. */ async function runTsdownMigrateStep( - vpBin: string, cwd: string, packageManager: PackageManager, ): Promise<{ ok: boolean; warnings: string[] }> { try { const result = await runCommandSilently({ - command: vpBin, - args: [ + ...getVpCommand([ 'dlx', `tsdown-migrate@${corePkg.bundledVersions.tsdown}`, '--yes', '--package-manager', packageManager, '--no-install', - ], + ]), cwd, envs: process.env, }); @@ -417,7 +415,6 @@ export async function migrateTsupToTsdown( packages?: WorkspacePackage[], options?: { silent?: boolean; report?: MigrationReport }, ): Promise { - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); // A tsup config isn't necessarily workspace-wide the way an ESLint flat @@ -491,7 +488,7 @@ export async function migrateTsupToTsdown( for (const target of tsupTargets) { const targetLabel = target === rootPath ? 'the project root' : displayRelative(target, projectPath); - const migrateResult = await runTsdownMigrateStep(vpBin, target, packageManager); + const migrateResult = await runTsdownMigrateStep(target, packageManager); if (!migrateResult.ok) { spinner.stop(); const restoreFailures = restoreTsupMigrationTargets(snapshots); diff --git a/packages/cli/src/utils/__tests__/run-vite-install.spec.ts b/packages/cli/src/utils/__tests__/run-vite-install.spec.ts index 087ade72c9..65b91fc574 100644 --- a/packages/cli/src/utils/__tests__/run-vite-install.spec.ts +++ b/packages/cli/src/utils/__tests__/run-vite-install.spec.ts @@ -1,8 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PackageManager } from '../../types/index.ts'; -vi.mock('../command.ts', () => ({ +vi.mock(import('../command.ts'), async (importOriginal) => ({ + ...(await importOriginal()), runCommandSilently: vi.fn(), })); @@ -18,7 +19,19 @@ function installResult(exitCode: number, stdout = '', stderr = '') { describe('runViteInstall with detectIgnoredBuilds', () => { beforeEach(() => { mockRun.mockReset(); - delete process.env.VP_SKIP_INSTALL; + vi.stubEnv('VP_SKIP_INSTALL', undefined); + vi.stubEnv('VP_CLI_BIN', undefined); + }); + + afterEach(() => vi.unstubAllEnvs()); + + it('preserves an explicit VP_CLI_BIN override and its arguments', async () => { + vi.stubEnv('VP_CLI_BIN', '/custom/vp'); + mockRun.mockResolvedValue(installResult(0)); + await runViteInstall('/project', false, ['--offline'], { silent: true }); + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ command: '/custom/vp', args: ['install', '--offline'] }), + ); }); it('treats pnpm >= 11 ERR_PNPM_IGNORED_BUILDS exit-1 as a completed install', async () => { @@ -38,7 +51,9 @@ describe('runViteInstall with detectIgnoredBuilds', () => { expect(summary.pendingBuilds).toEqual(['better-sqlite3', 'esbuild']); // Detection mode must NOT add --ignore-scripts (that would hide the builds // from `approve-builds` afterwards). - expect(mockRun).toHaveBeenCalledWith(expect.objectContaining({ args: ['install'] })); + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ command: process.execPath, args: [expect.any(String), 'install'] }), + ); }); it('still reports a genuine non-zero exit (no ignored-builds error) as failed', async () => { @@ -74,7 +89,7 @@ describe('runViteInstall with detectIgnoredBuilds', () => { expect(summary.status).toBe('installed'); expect(summary.pendingBuilds).toBeUndefined(); expect(mockRun).toHaveBeenCalledWith( - expect.objectContaining({ args: ['install', '--ignore-scripts'] }), + expect.objectContaining({ args: [expect.any(String), 'install', '--ignore-scripts'] }), ); }); }); diff --git a/packages/cli/src/utils/approve-builds.ts b/packages/cli/src/utils/approve-builds.ts index 351c1a52b9..6d9b18c37d 100644 --- a/packages/cli/src/utils/approve-builds.ts +++ b/packages/cli/src/utils/approve-builds.ts @@ -4,7 +4,7 @@ import { stripVTControlCharacters } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { PackageManager } from '../types/index.ts'; -import { runCommandSilently } from './command.ts'; +import { getVpCommand, runCommandSilently } from './command.ts'; import { readJsonFile, writeJsonFile } from './json.ts'; import { getSilentSpinner, getSpinner } from './spinner.ts'; import { accent } from './terminal.ts'; @@ -360,8 +360,7 @@ export async function detectGatedBuilds( } if (packageManager === PackageManager.bun) { const { exitCode, stdout, stderr } = await runCommandSilently({ - command: process.env.VP_CLI_BIN ?? 'vp', - args: ['exec', 'bun', 'pm', 'untrusted'], + ...getVpCommand(['exec', 'bun', 'pm', 'untrusted']), cwd: installCwd, envs: process.env, }); @@ -425,8 +424,7 @@ async function runBuildAndReport( const spinner = silent ? getSilentSpinner() : getSpinner(interactive); spinner.start(`Building ${packages.join(', ')}...`); const { exitCode, stdout, stderr } = await runCommandSilently({ - command: process.env.VP_CLI_BIN ?? 'vp', - args, + ...getVpCommand(args), cwd, envs: extraEnv ? { ...process.env, ...extraEnv } : process.env, }); @@ -462,8 +460,7 @@ async function approveNpmScripts( ): Promise { const approveCommand = `vp pm approve-builds ${packages.join(' ')}`; const { exitCode, stdout, stderr } = await runCommandSilently({ - command: process.env.VP_CLI_BIN ?? 'vp', - args: ['pm', 'approve-builds', ...packages], + ...getVpCommand(['pm', 'approve-builds', ...packages]), cwd: installCwd, envs: process.env, }); diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index 43d290d999..7b1d90552a 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -1,4 +1,5 @@ import { constants } from 'node:os'; +import { fileURLToPath } from 'node:url'; import spawn from 'cross-spawn'; @@ -23,6 +24,16 @@ export interface RunCommandResult extends ExecutionResult { stderr: Buffer; } +/** Run this CLI version even when a package-manager launcher does not expose `vp` on PATH. */ +export function getVpCommand(args: string[], command = process.env.VP_CLI_BIN) { + return command !== undefined + ? { command, args } + : { + command: process.execPath, + args: [fileURLToPath(import.meta.resolve('vite-plus/bin')), ...args], + }; +} + export function exitCodeFromClose(code: number | null, signal: NodeJS.Signals | null) { if (code !== null) { return code; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 2350798a36..95a9f5900d 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -4,7 +4,7 @@ import { coerce, isGreaterThanOrEqual } from 'verkit'; import { downloadPackageManager as downloadPackageManagerBinding } from '../../binding/index.js'; import { PackageManager } from '../types/index.ts'; import { isPnpmIgnoredBuildsError, parseInstallGatedBuilds } from './approve-builds.ts'; -import { runCommandSilently } from './command.ts'; +import { getVpCommand, runCommandSilently } from './command.ts'; import { reconcilePreviewBridgeRegistry } from './preview-registry.ts'; import { getSilentSpinner, getSpinner } from './spinner.ts'; import { accent } from './terminal.ts'; @@ -145,8 +145,7 @@ export async function runViteInstall( const startTime = Date.now(); spinner.start(`Installing dependencies...`); const { exitCode, stderr, stdout } = await runCommandSilently({ - command: process.env.VP_CLI_BIN ?? 'vp', - args: ['install', ...installArgs], + ...getVpCommand(['install', ...installArgs]), cwd, envs: process.env, }); @@ -191,8 +190,7 @@ export async function runViteFmt( spinner.start(`Formatting code...`); const { exitCode, stderr, stdout } = await runCommandSilently({ - command: options?.command ?? process.env.VP_CLI_BIN ?? 'vp', - args: [...(options?.commandArgs ?? []), 'fmt', ...(paths ?? [])], + ...getVpCommand([...(options?.commandArgs ?? []), 'fmt', ...(paths ?? [])], options?.command), cwd, envs: process.env, }); diff --git a/packages/cli/src/utils/tsconfig.ts b/packages/cli/src/utils/tsconfig.ts index d952120a3d..6da34c4b73 100644 --- a/packages/cli/src/utils/tsconfig.ts +++ b/packages/cli/src/utils/tsconfig.ts @@ -6,7 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { applyEdits, modify, parse as parseJsonc } from 'jsonc-parser'; import vitestV5EntryPoints from '../vitest-v5-entry-points.json' with { type: 'json' }; -import { runCommandSilently } from './command.ts'; +import { getVpCommand, runCommandSilently } from './command.ts'; import { BASEURL_TSCONFIG_FIX_PACKAGE, createBaseUrlTsconfigFixArgs } from './constants.ts'; import { cancelAndExit } from './prompts.ts'; @@ -103,8 +103,7 @@ export async function fixBaseUrlInTsconfig( prompts.log.info(`Running vp dlx ${BASEURL_TSCONFIG_FIX_PACKAGE} ${fixArgs.join(' ')}`); } const result = await runCommandSilently({ - command: process.env.VP_CLI_BIN ?? 'vp', - args: ['dlx', BASEURL_TSCONFIG_FIX_PACKAGE, ...fixArgs], + ...getVpCommand(['dlx', BASEURL_TSCONFIG_FIX_PACKAGE, ...fixArgs]), cwd: projectPath, envs: process.env, }); From 34171f84ac397ababa1e7f4dce264bfc1c6100c9 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 22 Oct 2026 01:42:51 +0800 Subject: [PATCH 2/3] refactor(create): clarify command resolution and regression fixture --- .../assert-create.mjs | 43 +++++++++++++------ packages/cli/src/utils/command.ts | 18 +++++--- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs index bd1cfeacce..ce324ed058 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_without_global_vp/assert-create.mjs @@ -8,12 +8,11 @@ const [packageManager, mode] = process.argv.slice(2); const env = { ...process.env }; const pathKey = Object.keys(env).find((key) => key.toUpperCase() === 'PATH'); // Keep system tools such as Git, but remove every directory that exposes vp. -env[pathKey] = [ - path.dirname(process.execPath), - ...env[pathKey] - .split(path.delimiter) - .filter((dir) => !['vp', 'vp.exe', 'vp.cmd', 'vp.bat'].some((bin) => existsSync(path.join(dir, bin)))), -].join(path.delimiter); +const vpExecutables = ['vp', 'vp.exe', 'vp.cmd', 'vp.bat']; +const pathsWithoutVp = env[pathKey] + .split(path.delimiter) + .filter((dir) => !vpExecutables.some((bin) => existsSync(path.join(dir, bin)))); +env[pathKey] = [path.dirname(process.execPath), ...pathsWithoutVp].join(path.delimiter); delete env.VP_CLI_BIN; assert.equal(spawnSync('vp', ['--version'], { env }).error?.code, 'ENOENT'); @@ -27,20 +26,36 @@ if (mode === 'unrelated') { env.npm_config_user_agent = packageManager.replace('@', '/'); const cli = fileURLToPath(import.meta.resolve('vite-plus/bin')); +const projectDir = 'projects with spaces/app'; // Reuse the local bundled-template fixture, without an external template download. -const result = spawnSync(process.execPath, [ - cli, 'create', '@your-org:demo', '--directory', 'projects with spaces/app', - '--no-interactive', '--no-agent', '--no-editor', '--git', '--hooks', '--verbose', -], { env, encoding: 'utf8', timeout: 210000 }); +const result = spawnSync( + process.execPath, + [ + cli, + 'create', + '@your-org:demo', + '--directory', + projectDir, + '--no-interactive', + '--no-agent', + '--no-editor', + '--git', + '--hooks', + '--verbose', + ], + { env, encoding: 'utf8', timeout: 210000 }, +); const output = `${result.stdout}\n${result.stderr}`; assert.equal(result.status, 0, output || result.error?.message); assert.match(output, /Dependencies installed/); assert.match(output, /Code formatted/); assert.match(output, /Git hooks configured/); assert.doesNotMatch(output, /unrelated-vp|Failed to install git hooks|Install failed|Format failed/); -assert.ok(existsSync('projects with spaces/app/node_modules/vite-plus/package.json')); -assert.match(readFileSync('projects with spaces/app/.git/config', 'utf8'), /hooksPath/); +assert.ok(existsSync(path.join(projectDir, 'node_modules/vite-plus/package.json'))); +assert.match(readFileSync(path.join(projectDir, '.git/config'), 'utf8'), /hooksPath/); const lockfile = packageManager.startsWith('pnpm@') ? 'pnpm-lock.yaml' : 'yarn.lock'; -assert.ok(existsSync(path.join('projects with spaces/app', lockfile))); -console.log(`Created a project with ${packageManager.split('@')[0]} and ${mode === 'missing' ? 'no' : 'an unrelated'} vp on PATH.`); +assert.ok(existsSync(path.join(projectDir, lockfile))); +console.log( + `Created a project with ${packageManager.split('@')[0]} and ${mode === 'missing' ? 'no' : 'an unrelated'} vp on PATH.`, +); console.log('Dependencies installed, code formatted, and Git hooks configured.'); diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index 7b1d90552a..1a5543f0f5 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -25,13 +25,17 @@ export interface RunCommandResult extends ExecutionResult { } /** Run this CLI version even when a package-manager launcher does not expose `vp` on PATH. */ -export function getVpCommand(args: string[], command = process.env.VP_CLI_BIN) { - return command !== undefined - ? { command, args } - : { - command: process.execPath, - args: [fileURLToPath(import.meta.resolve('vite-plus/bin')), ...args], - }; +export function getVpCommand( + args: string[], + command = process.env.VP_CLI_BIN, +): Pick { + if (command !== undefined) { + return { command, args }; + } + return { + command: process.execPath, + args: [fileURLToPath(import.meta.resolve('vite-plus/bin')), ...args], + }; } export function exitCodeFromClose(code: number | null, signal: NodeJS.Signals | null) { From 669905be9ce123e5d9950be78330e7927a99e7c1 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 23 Oct 2026 01:07:35 +0900 Subject: [PATCH 3/3] test(migrate): isolate inherited CLI command overrides --- packages/cli/src/migration/__tests__/prettier.spec.ts | 2 ++ packages/cli/src/migration/__tests__/tsup.spec.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/cli/src/migration/__tests__/prettier.spec.ts b/packages/cli/src/migration/__tests__/prettier.spec.ts index 57965cf352..408d49b55e 100644 --- a/packages/cli/src/migration/__tests__/prettier.spec.ts +++ b/packages/cli/src/migration/__tests__/prettier.spec.ts @@ -28,11 +28,13 @@ describe('Prettier migration', () => { }; beforeEach(() => { + vi.stubEnv('VP_CLI_BIN', undefined); projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-prettier-')); fs.writeFileSync(path.join(projectPath, 'package.json'), JSON.stringify(packageJson)); }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(projectPath, { recursive: true, force: true }); mockRunCommandSilently.mockReset(); }); diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index b048941707..b824b0f7d1 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -63,6 +63,7 @@ describe('tsup migration', () => { let projectPath: string; beforeEach(() => { + vi.stubEnv('VP_CLI_BIN', undefined); projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsup-')); fs.writeFileSync( path.join(projectPath, 'package.json'), @@ -81,6 +82,7 @@ describe('tsup migration', () => { }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(projectPath, { recursive: true, force: true }); mockRunCommandSilently.mockReset(); mockConfirm.mockReset();