Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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