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
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ jobs:
pattern: vp-release-archive-*
merge-multiple: true

- name: Create release archive checksums
working-directory: binary-release
run: sha256sum vp-* > vp-checksums.txt

- name: Prepare and publish native addons
run: node ./packages/cli/publish-native-addons.ts --mode npm

Expand Down Expand Up @@ -207,6 +211,7 @@ jobs:
installer-release/vp-setup-*.exe
binary-release/vp-*.tar.gz
binary-release/vp-*.zip
binary-release/vp-checksums.txt

- name: Publish GitHub Release
env:
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/reusable-release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,13 @@ jobs:
shell: bash
working-directory: ./target/${{ matrix.settings.target }}/release
run: |
mkdir -p sync-versions
cp ../../../packages/cli/dist/sync-versions/bin.mjs sync-versions/bin.mjs
cp ../../../packages/cli/dist/toolchain.json toolchain.json
if [ -f vp.exe ]; then
7z a "vp-${{ matrix.settings.target }}.zip" vp.exe vp-shim.exe
7z a "vp-${{ matrix.settings.target }}.zip" vp.exe vp-shim.exe sync-versions/bin.mjs toolchain.json
else
tar -czf "vp-${{ matrix.settings.target }}.tar.gz" vp
tar -czf "vp-${{ matrix.settings.target }}.tar.gz" vp sync-versions/bin.mjs toolchain.json
fi

- name: Upload release archive
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[[case]]
name = "command_sync_versions_rejects_tty"
vp = ["local", "global"]
comment = "The machine protocol rejects an interactive terminal instead of waiting forever for EOF."
steps = [
{ argv = ["vp", "sync-versions", "--json"], continue-on-failure = true },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# command_sync_versions_rejects_tty

The machine protocol rejects an interactive terminal instead of waiting forever for EOF.

## `vp sync-versions --json`

**Exit code:** 1

```
vite-plus sync-versions: Expected a JSON request on stdin. Pipe the request to this command; it is intended for external automation.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# command_sync_versions_rejects_tty

The machine protocol rejects an interactive terminal instead of waiting forever for EOF.

## `vp sync-versions --json`

**Exit code:** 1

```
vite-plus sync-versions: Expected a JSON request on stdin. Pipe the request to this command; it is intended for external automation.
```
28 changes: 26 additions & 2 deletions crates/vp_global_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ pub enum Commands {
args: Vec<String>,
},

/// Produce a dependency-alignment plan for external automation
#[command(name = "sync-versions", hide = true, disable_help_flag = true)]
SyncVersions {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// In-repo configuration (hooks, agent integration)
#[command(disable_help_flag = true)]
Config {
Expand Down Expand Up @@ -273,6 +280,7 @@ impl Commands {
match self {
Self::PackageManager(pm) => pm.is_quiet_or_machine_readable(),
Self::Toolchain { json, .. } => *json,
Self::SyncVersions { .. } => true,
Self::Upgrade { silent, .. } => *silent,
Self::Env(args) => {
args.command.as_ref().is_some_and(|sub| sub.is_quiet_or_machine_readable())
Expand Down Expand Up @@ -1092,6 +1100,10 @@ pub async fn run_command_with_options(

Commands::Migrate { args } => commands::migrate::execute(cwd, &args).await,

Commands::SyncVersions { args } => {
commands::sync_versions::execute(cwd, &args, raw_subcommand).await
}

Commands::Config { args } => commands::config::execute(cwd, &args, raw_subcommand).await,

Commands::Hooks { args } => commands::hooks::execute(cwd, &args, raw_subcommand).await,
Expand Down Expand Up @@ -1280,8 +1292,9 @@ pub fn try_parse_args_from_with_options(
#[cfg(test)]
mod tests {
use super::{
display_node_version, has_flag_before_terminator, is_same_node_version, raw_subcommand,
should_force_global_delegate, should_suppress_header_for_subcommand,
Commands, display_node_version, has_flag_before_terminator, is_same_node_version,
raw_subcommand, should_force_global_delegate, should_suppress_header_for_subcommand,
try_parse_args_from,
};

fn argv(args: &[&str]) -> Vec<String> {
Expand All @@ -1305,6 +1318,17 @@ mod tests {
assert_eq!(raw_subcommand(&argv(&["vp", "--version"])), None);
}

#[test]
fn parses_sync_versions_as_machine_readable_global_command() {
let parsed = try_parse_args_from(argv(&["vp", "sync-versions", "--json"]))
.expect("sync-versions should parse");
let Some(Commands::SyncVersions { args }) = parsed.command else {
panic!("expected sync-versions command");
};
assert_eq!(args, vec!["--json"]);
assert!(Commands::SyncVersions { args }.is_quiet_or_machine_readable());
}

#[test]
fn detects_global_update_node_version_mismatch() {
assert!(is_same_node_version("21.0.0", "v21.0.0"));
Expand Down
1 change: 1 addition & 0 deletions crates/vp_global_cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ pub mod create;
pub mod hooks;
pub mod migrate;
pub mod staged;
pub mod sync_versions;
pub mod toolchain;
pub mod version;

Expand Down
60 changes: 60 additions & 0 deletions crates/vp_global_cli/src/commands/sync_versions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Side-effect-free dependency version reconciliation for external automation.

use std::{path::PathBuf, process::ExitStatus};

use tokio::process::Command;
use vp_shared::env_vars;
use vt_path::AbsolutePathBuf;

use crate::error::Error;

fn packaged_sidecar(executable: &std::path::Path) -> Option<PathBuf> {
let path = executable.parent()?.join("sync-versions").join("bin.mjs");
path.is_file().then_some(path)
}

/// Execute the planner bundled next to the standalone `vp` binary.
///
/// Normal Vite+ installations keep JavaScript under `node_modules`, so they
/// fall back to the global package entrypoint. Official standalone archives
/// include this one self-contained bundle and need no npm installation.
pub async fn execute(
cwd: AbsolutePathBuf,
args: &[String],
raw_subcommand: Option<&str>,
) -> Result<ExitStatus, Error> {
let executable = std::env::current_exe()?;
let executable = std::fs::canonicalize(executable)?;
let Some(sidecar) = packaged_sidecar(&executable) else {
return super::delegate::execute_global(cwd, "sync-versions", args, raw_subcommand).await;
};

let mut command = Command::new("node");
command.arg(sidecar).args(args).current_dir(cwd.as_path()).env(env_vars::VP_BYPASS, "1");
vp_command::sync_child_pwd(&mut command, &cwd);
Ok(command.status().await?)
}

#[cfg(test)]
mod tests {
use std::fs;

use tempfile::tempdir;

use super::packaged_sidecar;

#[test]
fn finds_only_the_packaged_sync_versions_entrypoint() {
let temp = tempdir().expect("temp directory");
let executable = temp.path().join("vp");
fs::write(&executable, []).expect("placeholder executable");

assert_eq!(packaged_sidecar(&executable), None);

let sidecar = temp.path().join("sync-versions/bin.mjs");
fs::create_dir_all(sidecar.parent().expect("sidecar parent")).expect("sidecar directory");
fs::write(&sidecar, []).expect("sidecar file");

assert_eq!(packaged_sidecar(&executable), Some(sidecar));
}
}
4 changes: 3 additions & 1 deletion packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Unified entry point for both the local CLI (via bin/vp) and the global CLI (via Rust vp binary).
*
* Global commands (create, migrate, config, hooks, staged, --version) are handled by tsdown-bundled modules.
* Global commands (create, migrate, sync-versions, config, hooks, staged, --version) are handled by tsdown-bundled modules.
* All other commands are delegated to the Rust core through NAPI bindings, which
* uses JavaScript tool resolver functions to locate tool binaries.
*
Expand Down Expand Up @@ -117,6 +117,8 @@ if (maybePrintCommandHelp(args)) {
await import('./create/bin.js');
} else if (command === 'migrate') {
await import('./migration/bin.js');
} else if (command === 'sync-versions') {
await import('./sync-versions/bin.js');
} else if (command === 'config') {
await import('./config/bin.js');
} else if (command === 'hooks') {
Expand Down
72 changes: 3 additions & 69 deletions packages/cli/src/migration/migrator/vitest-ecosystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
VITE_PLUS_OVERRIDE_PACKAGES,
} from '../../utils/constants.ts';
import { readJsonFile } from '../../utils/json.ts';
import { extractOverrideTargetName } from '../../utils/package-overrides.ts';
import { detectPackageMetadata } from '../../utils/package.ts';
import { isAlignableVitestEcosystemPackage } from '../../utils/vitest-ecosystem.ts';
import {
bootstrapProjectPaths,
getCatalogDependencySpec,
Expand All @@ -31,23 +33,6 @@ import {
type PackageJsonDependencyField,
} from './shared.ts';

// Official `@vitest/*` packages are versioned in lockstep with vitest and carry
// an EXACT `vitest` peer (verified against the registry: `@vitest/coverage-v8`,
// `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, the browser
// family, and the runtime internals all pin `vitest: <version>`), so any the
// project lists must match the bundled vitest or Vitest runs mixed copies (the
// `define-config.ts` coverage guard fail-fasts on exactly this skew).
// `@vitest/eslint-plugin` versions on its own line, and deprecated
// `@vitest/coverage-c8` never published on the Vitest 4 line, so neither may be
// pinned to the bundled Vitest version.
const VITEST_ALIGN_EXCLUDED = new Set([
'@vitest/eslint-plugin',
// Deprecated at 0.33.0 and replaced by @vitest/coverage-v8. It does not
// publish versions on Vitest's current release line, so pinning it to the
// bundled Vitest version creates a dependency spec that does not exist.
'@vitest/coverage-c8',
]);

// Official packages that do not declare a required `vitest` peer. Keep them
// aligned when a project lists them directly, but do not add a direct vitest
// merely because they are present.
Expand All @@ -63,58 +48,7 @@ export const VITEST_DIRECT_USAGE_EXCLUDED = new Set([
'@vitest/ws-client',
]);

export function isAlignableVitestEcosystemPackage(name: string): boolean {
return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name);
}

// Extract the package name an override/resolution key *targets* — i.e. the
// package whose version would be forced. This mirrors the grammar of the real
// package-manager parsers (verified against `@yarnpkg/parsers` parseResolution):
// - bare (`pkg`, `@scope/pkg`)
// - versioned (`pkg@1`, `@scope/pkg@1`)
// - pnpm parent selectors (`parent>pkg`, chained `a@1>b>@scope/pkg`)
// - yarn `from/target` selectors (`parent/pkg`, `parent/@scope/pkg`,
// `parent@1/pkg`, glob `**/pkg`)
// For a yarn `from/target` selector the forced package is the TRAILING
// descriptor, not the parent: `@scope/pkg@4/child` targets `child`, and an
// npm-alias key like `@scope/pkg@npm:@other/fork@1` is parsed by yarn as
// `from=@scope/pkg@npm:@other`, `descriptor=fork@1` — so the target is `fork`,
// NOT `@scope/pkg`. Taking the trailing descriptor is exactly that. (Yarn
// *rejects* keys whose range embeds a slash, e.g. `pkg@patch:…/…` or git/URL
// ranges, so those never reach us as valid keys and need no special handling.)
// Scoped names keep their leading `@` and internal `/`.
function extractOverrideTargetName(key: string): string {
// pnpm parent selector `parent>child` (incl. chains `a>b>child`): the forced
// package is the deepest child. pnpm splits at a `>` whose preceding char is
// NOT space, `|`, or `@` — this is pnpm's own delimiter rule (DELIMITER_REGEX
// = /[^ |@]>/ in @pnpm/parse-overrides) — so a semver comparator range such as
// `pkg@>=4`, `pkg@>4`, or `>1 || >2` is NOT mistaken for a parent selector.
// Peel parent levels until none remain, keeping the trailing child.
let target = key.trim();
for (let delim = target.search(/[^ |@]>/); delim !== -1; delim = target.search(/[^ |@]>/)) {
target = target.slice(delim + 2).trim();
}
if (!target) {
return target;
}
// yarn `from/target` selector: drop leading parent/glob segments, keeping the
// trailing package descriptor (and a scoped name's own `/`).
if (target.includes('/')) {
const segments = target.split('/');
const last = segments[segments.length - 1];
const scope = segments[segments.length - 2];
target = scope?.startsWith('@') ? `${scope}/${last}` : last;
}
// Strip a trailing version/range suffix. The version `@` follows the name
// (after the `/` for a scoped name); the leading scope `@` is never a version
// separator.
const nameStart = target.startsWith('@') ? target.indexOf('/') + 1 : 0;
const versionAt = target.indexOf('@', nameStart);
if (versionAt > 0) {
target = target.slice(0, versionAt);
}
return target;
}
export { isAlignableVitestEcosystemPackage } from '../../utils/vitest-ecosystem.ts';

// True iff a pnpm.overrides key's target (after stripping selector and
// version suffixes) is a provider whose stale pin must be dropped (see
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/sync-versions/__tests__/input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Readable } from 'node:stream';

import { describe, expect, it } from 'vitest';

import { readBoundedUtf8 } from '../input.ts';

async function* invalidInput(): AsyncGenerator<unknown> {
yield 42;
}

describe('readBoundedUtf8', () => {
it('reads chunked UTF-8 input without changing it', async () => {
const input = Readable.from(['{"schema', 'Version":1}\n']);

await expect(readBoundedUtf8(input, 64)).resolves.toBe('{"schemaVersion":1}\n');
});

it('rejects input larger than the byte limit', async () => {
const input = Readable.from(['1234', '5678']);

await expect(readBoundedUtf8(input, 7)).rejects.toThrow('exceeds the 7 byte limit');
});

it('measures bytes rather than JavaScript string length', async () => {
const input = Readable.from(['é']);

await expect(readBoundedUtf8(input, 1)).rejects.toThrow('exceeds the 1 byte limit');
});

it('rejects non-byte input chunks', async () => {
await expect(readBoundedUtf8(invalidInput())).rejects.toThrow('Expected UTF-8 input');
});
});
37 changes: 37 additions & 0 deletions packages/cli/src/sync-versions/__tests__/npm-bin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

import { describe, expect, it } from 'vitest';

const cliBinPath = fileURLToPath(new URL('../../../dist/bin.js', import.meta.url));

describe('npm CLI sync-versions command', () => {
it('runs the bundled planner instead of tree-shaking the dynamic import', () => {
const before = '{"devDependencies":{"vite-plus":"0.0.0"}}\n';
const request = JSON.stringify({
schemaVersion: 1,
workspace: '.',
manifests: [{ path: 'package.json', kind: 'packageJson', contents: before }],
});

const stdout = execFileSync(process.execPath, [cliBinPath, 'sync-versions', '--json'], {
input: request,
encoding: 'utf8',
});
const plan = JSON.parse(stdout) as {
tool: { name: string; version: string };
replacements: Array<{ before: string; after: string }>;
};

expect(plan.tool.name).toBe('vite-plus');
expect(plan.tool.version).toMatch(/^\d+\.\d+\.\d+/u);
expect(plan.replacements).toEqual([
{
path: 'package.json',
kind: 'packageJson',
before,
after: `{"devDependencies":{"vite-plus":"${plan.tool.version}"}}\n`,
},
]);
});
});
Loading
Loading