diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c85238148f..8a2d5f6714 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml index b1eb3caf52..8a9a75448f 100644 --- a/.github/workflows/reusable-release-build.yml +++ b/.github/workflows/reusable-release-build.yml @@ -132,12 +132,54 @@ 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: Verify packaged sync-versions planner + if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} + shell: bash + working-directory: ./target/${{ matrix.settings.target }}/release + run: | + set -euo pipefail + archive_dir="$(mktemp -d)" + cleanup() { + rm -rf "${archive_dir}" + } + trap cleanup EXIT + tar -xzf "vp-${{ matrix.settings.target }}.tar.gz" -C "${archive_dir}" + + node --input-type=module -e ' + const before = JSON.stringify({ devDependencies: { "vite-plus": "0.0.0" } }) + "\n"; + process.stdout.write(JSON.stringify({ + schemaVersion: 1, + workspace: ".", + manifests: [{ path: "package.json", kind: "packageJson", contents: before }], + })); + ' | "${archive_dir}/vp" sync-versions --json > "${archive_dir}/plan.json" + + node --input-type=module -e ' + import fs from "node:fs"; + const [planPath, expectedVersion] = process.argv.slice(1); + const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); + const before = JSON.stringify({ devDependencies: { "vite-plus": "0.0.0" } }) + "\n"; + const after = JSON.stringify({ devDependencies: { "vite-plus": expectedVersion } }) + "\n"; + if ( + plan.tool?.name !== "vite-plus" || + plan.tool.version !== expectedVersion || + plan.replacements?.length !== 1 || + plan.replacements[0].before !== before || + plan.replacements[0].after !== after + ) { + throw new Error("Unexpected packaged sync plan: " + JSON.stringify(plan)); + } + ' "${archive_dir}/plan.json" "${VERSION}" + - name: Upload release archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots.toml new file mode 100644 index 0000000000..ba2dbcd03a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots.toml @@ -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 }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots/command_sync_versions_rejects_tty.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots/command_sync_versions_rejects_tty.global.md new file mode 100644 index 0000000000..502dc582f9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots/command_sync_versions_rejects_tty.global.md @@ -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. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots/command_sync_versions_rejects_tty.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots/command_sync_versions_rejects_tty.local.md new file mode 100644 index 0000000000..502dc582f9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_sync_versions_tty/snapshots/command_sync_versions_rejects_tty.local.md @@ -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. +``` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index cf61829a7e..da845da82c 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -90,6 +90,13 @@ pub enum Commands { args: Vec, }, + /// 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, + }, + /// In-repo configuration (hooks, agent integration) #[command(disable_help_flag = true)] Config { @@ -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()) @@ -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, @@ -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 { @@ -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")); diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 007d351332..b5781bcd73 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -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; diff --git a/crates/vp_global_cli/src/commands/sync_versions.rs b/crates/vp_global_cli/src/commands/sync_versions.rs new file mode 100644 index 0000000000..939f6207b4 --- /dev/null +++ b/crates/vp_global_cli/src/commands/sync_versions.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 8d86e0586e..5e7e2075a1 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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. * @@ -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') { diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts index 233a130f19..88467d5c9a 100644 --- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts +++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts @@ -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, @@ -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: `), 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. @@ -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 diff --git a/packages/cli/src/sync-versions/__tests__/input.spec.ts b/packages/cli/src/sync-versions/__tests__/input.spec.ts new file mode 100644 index 0000000000..e136a33c6d --- /dev/null +++ b/packages/cli/src/sync-versions/__tests__/input.spec.ts @@ -0,0 +1,33 @@ +import { Readable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { readBoundedUtf8 } from '../input.ts'; + +async function* invalidInput(): AsyncGenerator { + 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'); + }); +}); diff --git a/packages/cli/src/sync-versions/__tests__/npm-bin.spec.ts b/packages/cli/src/sync-versions/__tests__/npm-bin.spec.ts new file mode 100644 index 0000000000..4cca573d38 --- /dev/null +++ b/packages/cli/src/sync-versions/__tests__/npm-bin.spec.ts @@ -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`, + }, + ]); + }); +}); diff --git a/packages/cli/src/sync-versions/__tests__/plan.spec.ts b/packages/cli/src/sync-versions/__tests__/plan.spec.ts new file mode 100644 index 0000000000..dcb9c29956 --- /dev/null +++ b/packages/cli/src/sync-versions/__tests__/plan.spec.ts @@ -0,0 +1,433 @@ +import { describe, expect, it } from 'vitest'; +import { parse as parseYaml } from 'yaml'; + +import { + parseSyncVersionsRequest, + planSyncVersions, + type SyncVersionsRequestV1, + type SyncVersionsToolchain, +} from '../plan.ts'; + +const toolchain: SyncVersionsToolchain = { + vitePlus: '0.4.0', + vitest: '5.0.0', +}; + +function packageJsonRequest(contents: string): SyncVersionsRequestV1 { + return parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'package.json', kind: 'packageJson', contents }], + }); +} + +describe('planSyncVersions', () => { + it('aligns existing Vite+ and Vitest install dependencies', () => { + const before = `${JSON.stringify( + { + devDependencies: { + '@vitest/browser-playwright': '4.1.11', + '@vitest/coverage-v8': '^4.1.11', + '@vitest/eslint-plugin': '1.6.0', + '@vitest/coverage-c8': '0.33.0', + '@voidzero-dev/vite-plus-core': '0.3.0', + 'vite-plus': '0.3.0', + vitest: '~4.1.11', + }, + }, + null, + 2, + )}\n`; + + const plan = planSyncVersions(packageJsonRequest(before), toolchain); + + expect(plan.tool).toEqual({ name: 'vite-plus', version: '0.4.0' }); + expect(plan.replacements).toHaveLength(1); + expect(plan.replacements[0].before).toBe(before); + expect(JSON.parse(plan.replacements[0].after)).toEqual({ + devDependencies: { + '@vitest/browser-playwright': '5.0.0', + '@vitest/coverage-v8': '5.0.0', + '@vitest/eslint-plugin': '1.6.0', + '@vitest/coverage-c8': '0.33.0', + '@voidzero-dev/vite-plus-core': '0.4.0', + 'vite-plus': '0.4.0', + vitest: '5.0.0', + }, + }); + }); + + it('aligns managed npm aliases and preserves custom aliases', () => { + const before = `${JSON.stringify( + { + devDependencies: { + '@vitest/ui': 'npm:@vitest/ui@4.1.11', + 'vite-plus': 'npm:@scope/vite-plus-fork@0.3.0', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.3.0', + vitest: 'npm:vitest@~4.1.11', + }, + }, + null, + 2, + )}\n`; + + const plan = planSyncVersions(packageJsonRequest(before), toolchain); + + expect(JSON.parse(plan.replacements[0].after)).toEqual({ + devDependencies: { + '@vitest/ui': 'npm:@vitest/ui@5.0.0', + 'vite-plus': 'npm:@scope/vite-plus-fork@0.3.0', + vite: 'npm:@voidzero-dev/vite-plus-core@0.4.0', + vitest: 'npm:vitest@5.0.0', + }, + }); + }); + + it('updates referenced pnpm catalogs without replacing catalog protocols', () => { + const packageJson = `${JSON.stringify( + { + devDependencies: { + '@vitest/coverage-v8': 'catalog:', + 'vite-plus': 'catalog:toolchain', + }, + }, + null, + 2, + )}\n`; + const pnpmWorkspace = `packages:\n - packages/*\ncatalog:\n '@vitest/coverage-v8': 4.1.11\ncatalogs:\n toolchain:\n vite: npm:@voidzero-dev/vite-plus-core@0.3.0\n vite-plus: 0.3.0\n`; + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [ + { path: 'package.json', kind: 'packageJson', contents: packageJson }, + { path: 'pnpm-workspace.yaml', kind: 'pnpmWorkspace', contents: pnpmWorkspace }, + ], + }); + + const plan = planSyncVersions(request, toolchain); + + expect(plan.replacements).toHaveLength(1); + expect(plan.replacements[0].path).toBe('pnpm-workspace.yaml'); + expect(parseYaml(plan.replacements[0].after)).toEqual({ + packages: ['packages/*'], + catalog: { '@vitest/coverage-v8': '5.0.0' }, + catalogs: { + toolchain: { + vite: 'npm:@voidzero-dev/vite-plus-core@0.4.0', + 'vite-plus': '0.4.0', + }, + }, + }); + }); + + it('updates Yarn catalogs without touching pnpm-only overrides or surrounding bytes', () => { + const before = + 'nodeLinker: node-modules\r\ncatalog:\r\n vitest: \'^4.1.11\' # keep this comment\r\ncatalogs:\r\n toolchain:\r\n vite: "npm:@voidzero-dev/vite-plus-core@0.3.0"\r\n vite-plus: 0.3.0\r\noverrides:\r\n vitest: 4.1.11\r\n'; + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: '.yarnrc.yml', kind: 'yarnRc', contents: before }], + }); + + const plan = planSyncVersions(request, toolchain); + + expect(plan.replacements).toHaveLength(1); + expect(plan.replacements[0].kind).toBe('yarnRc'); + expect(plan.replacements[0].after).toBe( + before + .replace("vitest: '^4.1.11'", "vitest: '5.0.0'") + .replace( + 'vite: "npm:@voidzero-dev/vite-plus-core@0.3.0"', + 'vite: "npm:@voidzero-dev/vite-plus-core@0.4.0"', + ) + .replace('vite-plus: 0.3.0', 'vite-plus: 0.4.0'), + ); + }); + + it('returns a byte-identical Yarn no-op and ignores its overrides', () => { + const before = 'catalog: { vitest: "5.0.0" } # keep spacing\noverrides:\n vitest: 4.1.11\n'; + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'config/.yarnrc.yml', kind: 'yarnRc', contents: before }], + }); + + expect(planSyncVersions(request, toolchain).replacements).toEqual([]); + }); + + it('changes only YAML scalar tokens and preserves surrounding formatting', () => { + const before = + "packages:\r\n- packages/*\r\ncatalog:\r\n vitest: '^4.1.11' # keep this comment\r\ndescription: this-is-a-very-long-plain-scalar-that-must-not-be-folded-even-when-it-crosses-the-default-yaml-printer-width"; + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'pnpm-workspace.yaml', kind: 'pnpmWorkspace', contents: before }], + }); + + const plan = planSyncVersions(request, toolchain); + + expect(plan.replacements).toHaveLength(1); + expect(plan.replacements[0].after).toBe(before.replace("vitest: '^4.1.11'", "vitest: '5.0.0'")); + }); + + it('preserves double-quoted YAML scalar style', () => { + const before = 'catalog: { vitest: "4.1.11", unrelated: 42 }\n'; + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'pnpm-workspace.yaml', kind: 'pnpmWorkspace', contents: before }], + }); + + const plan = planSyncVersions(request, toolchain); + + expect(plan.replacements[0].after).toBe('catalog: { vitest: "5.0.0", unrelated: 42 }\n'); + }); + + it.each([ + ['version: &version 4.1.11\ncatalog:\n vitest: *version\n', 'Expected a string scalar'], + ['catalog:\n vitest: |\n 4.1.11\n', 'Unsupported YAML scalar style'], + ['catalog: [', 'Invalid pnpm-workspace.yaml manifest'], + ['- package\n', 'Invalid pnpm-workspace.yaml manifest'], + ])('rejects an unsafe or invalid YAML manifest', (before, message) => { + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'pnpm-workspace.yaml', kind: 'pnpmWorkspace', contents: before }], + }); + + expect(() => planSyncVersions(request, toolchain)).toThrow(message); + }); + + it.each([ + "packages:\r\n- packages/*\r\ncatalog:\r\n vitest: '5.0.0'", + 'packages:\n - packages/*\ncatalog:\n vitest: 5.0.0\n', + 'catalog: { vitest: "5.0.0" } # keep spacing\n', + ])('returns a byte-identical YAML no-op', (before) => { + const request = parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'pnpm-workspace.yaml', kind: 'pnpmWorkspace', contents: before }], + }); + + expect(planSyncVersions(request, toolchain).replacements).toEqual([]); + }); + + it('preserves custom npm aliases and non-registry protocols in a byte-identical no-op', () => { + const before = `${JSON.stringify( + { + dependencies: { + '@vitest/browser-playwright': 'file:../provider.tgz', + vite: 'npm:@scope/vite-fork@6.0.0', + 'vite-plus': 'workspace:*', + vitest: 'npm:@scope/vitest-fork@4.1.11', + }, + }, + null, + '\t', + )}\r\n`; + + const plan = planSyncVersions(packageJsonRequest(before), toolchain); + + expect(plan.replacements).toEqual([]); + }); + + it('aligns versioned and parent-scoped override selectors', () => { + const before = `${JSON.stringify( + { + pnpm: { + overrides: { + 'app>vite-plus@0.3': '0.3.0', + 'app>@vitest/coverage-v8@4': '4.1.11', + }, + }, + resolutions: { + '**/@vitest/browser-playwright@4': '4.1.11', + }, + }, + null, + 2, + )}\n`; + + const plan = planSyncVersions(packageJsonRequest(before), toolchain); + + expect(JSON.parse(plan.replacements[0].after)).toEqual({ + pnpm: { + overrides: { + 'app>vite-plus@0.3': '0.4.0', + 'app>@vitest/coverage-v8@4': '5.0.0', + }, + }, + resolutions: { + '**/@vitest/browser-playwright@4': '5.0.0', + }, + }); + }); + + it('aligns nested npm overrides and Bun workspace catalogs', () => { + const before = `${JSON.stringify( + { + overrides: { + app: { + '.': '1.0.0', + vitest: '^4.1.11', + wrapper: { '@vitest/coverage-v8': '~4.1.11' }, + }, + }, + workspaces: { + packages: ['packages/*'], + catalog: { 'vite-plus': '^0.3.0' }, + catalogs: { + test: { '@vitest/browser-playwright': '4.1.11' }, + }, + }, + }, + null, + 2, + )}\n`; + + const plan = planSyncVersions(packageJsonRequest(before), toolchain); + + expect(JSON.parse(plan.replacements[0].after)).toMatchObject({ + overrides: { + app: { + '.': '1.0.0', + vitest: '5.0.0', + wrapper: { '@vitest/coverage-v8': '5.0.0' }, + }, + }, + workspaces: { + catalog: { 'vite-plus': '0.4.0' }, + catalogs: { + test: { '@vitest/browser-playwright': '5.0.0' }, + }, + }, + }); + }); + + it('aligns package catalogs and ignores non-string dependency entries', () => { + const before = `${JSON.stringify({ + dependencies: { unrelated: false }, + optionalDependencies: { vitest: '^4.1.11' }, + catalog: { 'vite-plus': '^0.3.0' }, + catalogs: { + test: { '@vitest/coverage-v8': '4.1.11' }, + ignored: false, + }, + })}\n`; + + const plan = planSyncVersions(packageJsonRequest(before), toolchain); + + expect(JSON.parse(plan.replacements[0].after)).toEqual({ + dependencies: { unrelated: false }, + optionalDependencies: { vitest: '5.0.0' }, + catalog: { 'vite-plus': '0.4.0' }, + catalogs: { + test: { '@vitest/coverage-v8': '5.0.0' }, + ignored: false, + }, + }); + }); + + it('preserves npm references and non-semver shorthand specs', () => { + const before = `${JSON.stringify({ + overrides: { vitest: '$vitest' }, + devDependencies: { + '@vitest/coverage-v8': 'owner/provider#main', + vitest: 'latest', + }, + })}\n`; + + expect(planSyncVersions(packageJsonRequest(before), toolchain).replacements).toEqual([]); + }); + + it('is idempotent when planned replacements are used as the next input', () => { + const before = '{\n "devDependencies": {\n "vite-plus": "0.3.0"\n }\n}\n'; + const first = planSyncVersions(packageJsonRequest(before), toolchain); + const second = planSyncVersions(packageJsonRequest(first.replacements[0].after), toolchain); + + expect(second.replacements).toEqual([]); + }); + + it.each(['{', 'null'])('rejects an invalid package.json manifest', (before) => { + expect(() => planSyncVersions(packageJsonRequest(before), toolchain)).toThrow( + 'Invalid package.json manifest', + ); + }); +}); + +describe('parseSyncVersionsRequest', () => { + it.each([ + { + schemaVersion: 2, + workspace: '.', + manifests: [], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [{ path: '../package.json', kind: 'packageJson', contents: '{}' }], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [{ path: '/package.json', kind: 'packageJson', contents: '{}' }], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'dir\\package.json', kind: 'packageJson', contents: '{}' }], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'dir//package.json', kind: 'packageJson', contents: '{}' }], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [ + { path: 'package.json', kind: 'packageJson', contents: '{}' }, + { path: 'package.json', kind: 'packageJson', contents: '{}' }, + ], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'pnpm-workspace.yaml', kind: 'packageJson', contents: '{}' }], + }, + { + schemaVersion: 1, + workspace: '.', + manifests: [{ path: 'yarnrc.yml', kind: 'yarnRc', contents: '{}' }], + }, + ])('rejects an invalid or ambiguous request', (request) => { + expect(() => parseSyncVersionsRequest(request)).toThrow(); + }); + + it('enforces manifest count and size limits', () => { + expect(() => + parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: Array.from({ length: 257 }, (_, index) => ({ + path: `${index}/package.json`, + kind: 'packageJson', + contents: '{}', + })), + }), + ).toThrow(); + expect(() => + parseSyncVersionsRequest({ + schemaVersion: 1, + workspace: '.', + manifests: [ + { + path: 'package.json', + kind: 'packageJson', + contents: 'a'.repeat(1024 * 1024 + 1), + }, + ], + }), + ).toThrow(); + }); +}); diff --git a/packages/cli/src/sync-versions/__tests__/protocol.spec.ts b/packages/cli/src/sync-versions/__tests__/protocol.spec.ts new file mode 100644 index 0000000000..a7120c3c11 --- /dev/null +++ b/packages/cli/src/sync-versions/__tests__/protocol.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { runSyncVersionsProtocol, toolchainFromManifest } from '../protocol.ts'; + +const manifest = { + schemaVersion: 1, + nodes: [ + { id: 'vite-plus', version: '0.4.0' }, + { id: 'vitest', version: '5.0.0' }, + ], +}; + +describe('runSyncVersionsProtocol', () => { + it('returns only the versioned JSON plan', () => { + const output = runSyncVersionsProtocol( + JSON.stringify({ + schemaVersion: 1, + workspace: '.', + manifests: [ + { + path: 'package.json', + kind: 'packageJson', + contents: '{"devDependencies":{"vite-plus":"0.3.0"}}\n', + }, + ], + }), + manifest, + ); + + expect(JSON.parse(output)).toEqual({ + schemaVersion: 1, + tool: { name: 'vite-plus', version: '0.4.0' }, + workspace: '.', + replacements: [ + { + path: 'package.json', + kind: 'packageJson', + before: '{"devDependencies":{"vite-plus":"0.3.0"}}\n', + after: '{"devDependencies":{"vite-plus":"0.4.0"}}\n', + }, + ], + }); + expect(output.endsWith('\n')).toBe(true); + }); + + it('rejects invalid JSON before planning', () => { + expect(() => runSyncVersionsProtocol('{', manifest)).toThrow('Invalid sync request JSON'); + }); +}); + +describe('toolchainFromManifest', () => { + it('reads the exact Vite+ and Vitest versions', () => { + expect(toolchainFromManifest(manifest)).toEqual({ + vitePlus: '0.4.0', + vitest: '5.0.0', + }); + }); + + it.each([ + {}, + { schemaVersion: 2, nodes: [] }, + { schemaVersion: 1, nodes: [{ id: 'vite-plus', version: '0.4.0' }] }, + { + schemaVersion: 1, + nodes: [ + { id: 'vite-plus', version: '0.4.0' }, + { id: 'vitest', version: '' }, + ], + }, + ])('rejects an incomplete toolchain manifest', (input) => { + expect(() => toolchainFromManifest(input)).toThrow(); + }); +}); diff --git a/packages/cli/src/sync-versions/bin.ts b/packages/cli/src/sync-versions/bin.ts new file mode 100644 index 0000000000..68be6514dc --- /dev/null +++ b/packages/cli/src/sync-versions/bin.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs/promises'; + +import { readBoundedUtf8 } from './input.ts'; +import { runSyncVersionsProtocol } from './protocol.ts'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function syncVersionsArgs(argv: readonly string[]): string[] { + const args = argv.slice(2); + return args[0] === 'sync-versions' ? args.slice(1) : args; +} + +async function main(): Promise { + const args = syncVersionsArgs(process.argv); + if (args.length !== 1 || args[0] !== '--json') { + throw new Error('Usage: vp sync-versions --json'); + } + + if (process.stdin.isTTY) { + throw new Error( + 'Expected a JSON request on stdin. Pipe the request to this command; it is intended for external automation.', + ); + } + + const [requestJson, manifestJson] = await Promise.all([ + readBoundedUtf8(process.stdin), + fs.readFile(new URL('../toolchain.json', import.meta.url), 'utf8'), + ]); + + let manifest: unknown; + try { + manifest = JSON.parse(manifestJson); + } catch { + throw new Error('Invalid bundled toolchain manifest'); + } + + process.stdout.write(runSyncVersionsProtocol(requestJson, manifest)); +} + +try { + await main(); +} catch (error) { + process.stderr.write(`vite-plus sync-versions: ${errorMessage(error)}\n`); + process.exitCode = 1; +} diff --git a/packages/cli/src/sync-versions/input.ts b/packages/cli/src/sync-versions/input.ts new file mode 100644 index 0000000000..d980a4bfac --- /dev/null +++ b/packages/cli/src/sync-versions/input.ts @@ -0,0 +1,25 @@ +import { Buffer } from 'node:buffer'; + +export const MAX_SYNC_VERSIONS_REQUEST_BYTES = 16 * 1024 * 1024; + +export async function readBoundedUtf8( + input: AsyncIterable, + maxBytes = MAX_SYNC_VERSIONS_REQUEST_BYTES, +): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + + for await (const chunk of input) { + if (typeof chunk !== 'string' && !ArrayBuffer.isView(chunk)) { + throw new TypeError('Expected UTF-8 input'); + } + const buffer = Buffer.from(chunk as string | Uint8Array); + totalBytes += buffer.byteLength; + if (totalBytes > maxBytes) { + throw new Error(`Sync request exceeds the ${maxBytes} byte limit`); + } + chunks.push(buffer); + } + + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} diff --git a/packages/cli/src/sync-versions/plan.ts b/packages/cli/src/sync-versions/plan.ts new file mode 100644 index 0000000000..d76dc4b150 --- /dev/null +++ b/packages/cli/src/sync-versions/plan.ts @@ -0,0 +1,474 @@ +import path from 'node:path'; + +import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'; +import semver from 'semver'; +import { isScalar, parseDocument, Scalar } from 'yaml'; +import { z } from 'zod'; + +import { detectFormattingOptions } from '../utils/json.ts'; +import { extractOverrideTargetName } from '../utils/package-overrides.ts'; +import { isAlignableVitestEcosystemPackage } from '../utils/vitest-ecosystem.ts'; + +const MAX_MANIFESTS = 256; +const MAX_MANIFEST_BYTES = 1024 * 1024; + +const manifestSnapshotSchema = z.discriminatedUnion('kind', [ + z + .object({ + path: z.string().min(1), + kind: z.literal('packageJson'), + contents: z.string().max(MAX_MANIFEST_BYTES), + }) + .strict(), + z + .object({ + path: z.string().min(1), + kind: z.literal('pnpmWorkspace'), + contents: z.string().max(MAX_MANIFEST_BYTES), + }) + .strict(), + z + .object({ + path: z.string().min(1), + kind: z.literal('yarnRc'), + contents: z.string().max(MAX_MANIFEST_BYTES), + }) + .strict(), +]); + +const syncVersionsRequestSchema = z + .object({ + schemaVersion: z.literal(1), + workspace: z.literal('.'), + manifests: z.array(manifestSnapshotSchema).max(MAX_MANIFESTS), + }) + .strict() + .superRefine((request, context) => { + const paths = new Set(); + for (const [index, manifest] of request.manifests.entries()) { + const segments = manifest.path.split('/'); + const invalidPath = + path.posix.isAbsolute(manifest.path) || + manifest.path.includes('\\') || + segments.some((segment) => segment === '' || segment === '.' || segment === '..'); + if (invalidPath) { + context.addIssue({ + code: 'custom', + path: ['manifests', index, 'path'], + message: 'Manifest paths must be normalized workspace-relative POSIX paths', + }); + } + + const basename = path.posix.basename(manifest.path); + const kindMatchesPath = + (manifest.kind === 'packageJson' && basename === 'package.json') || + (manifest.kind === 'pnpmWorkspace' && basename === 'pnpm-workspace.yaml') || + (manifest.kind === 'yarnRc' && basename === '.yarnrc.yml'); + if (!kindMatchesPath) { + context.addIssue({ + code: 'custom', + path: ['manifests', index, 'kind'], + message: 'Manifest kind does not match its file name', + }); + } + + if (paths.has(manifest.path)) { + context.addIssue({ + code: 'custom', + path: ['manifests', index, 'path'], + message: 'Manifest paths must be unique', + }); + } + paths.add(manifest.path); + } + }); + +export type SyncVersionsRequestV1 = z.infer; +export type SyncVersionsManifestSnapshot = SyncVersionsRequestV1['manifests'][number]; + +export interface SyncVersionsToolchain { + vitePlus: string; + vitest: string; +} + +export interface SyncVersionsReplacementV1 { + path: string; + kind: SyncVersionsManifestSnapshot['kind']; + before: string; + after: string; +} + +export interface SyncVersionsPlanV1 { + schemaVersion: 1; + tool: { + name: 'vite-plus'; + version: string; + }; + workspace: '.'; + replacements: SyncVersionsReplacementV1[]; +} + +interface TextEdit { + start: number; + end: number; + value: string; +} + +const INSTALL_DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', +] as const; + +export function parseSyncVersionsRequest(input: unknown): SyncVersionsRequestV1 { + return syncVersionsRequestSchema.parse(input); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parsePackageJson(contents: string): Record { + const errors: ParseError[] = []; + const parsed: unknown = parseJsonc(contents, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0 || !isRecord(parsed)) { + throw new Error('Invalid package.json manifest'); + } + return parsed; +} + +function targetVersion(name: string, toolchain: SyncVersionsToolchain): string | undefined { + if (name === 'vite-plus' || name === '@voidzero-dev/vite-plus-core') { + return toolchain.vitePlus; + } + if (name === 'vitest' || isAlignableVitestEcosystemPackage(name)) { + return toolchain.vitest; + } + return undefined; +} + +function alignedNpmAlias(current: string, toolchain: SyncVersionsToolchain): string { + const prefix = 'npm:'; + const versionAt = current.lastIndexOf('@'); + if (!current.startsWith(prefix) || versionAt <= prefix.length) { + return current; + } + + const packageName = current.slice(prefix.length, versionAt); + const currentVersion = current.slice(versionAt + 1); + const target = targetVersion(packageName, toolchain); + if ( + target === undefined || + currentVersion === target || + semver.validRange(currentVersion) === null + ) { + return current; + } + return `${current.slice(0, versionAt + 1)}${target}`; +} + +function alignedSpec(name: string, current: string, toolchain: SyncVersionsToolchain): string { + if (current.startsWith('npm:')) { + return alignedNpmAlias(current, toolchain); + } + const target = targetVersion(name, toolchain); + if ( + target === undefined || + current === target || + current.startsWith('$') || + /^[a-z][a-z+.-]*:/i.test(current) || + semver.validRange(current) === null + ) { + return current; + } + return target; +} + +function replaceJsonValue(contents: string, location: readonly string[], value: string): string { + const edits = modify(contents, [...location], value, { + formattingOptions: detectFormattingOptions(contents), + }); + return applyEdits(contents, edits); +} + +function alignJsonStringMap( + contents: string, + source: Record, + location: readonly string[], + toolchain: SyncVersionsToolchain, + packageNameForKey: (key: string) => string = (key) => key, +): string { + let output = contents; + for (const [name, value] of Object.entries(source)) { + if (typeof value !== 'string') { + continue; + } + const aligned = alignedSpec(packageNameForKey(name), value, toolchain); + if (aligned !== value) { + output = replaceJsonValue(output, [...location, name], aligned); + } + } + return output; +} + +function alignJsonOverrideMap( + contents: string, + source: Record, + location: readonly string[], + toolchain: SyncVersionsToolchain, + parentPackageName?: string, +): string { + let output = contents; + for (const [selector, value] of Object.entries(source)) { + const packageName = selector === '.' ? parentPackageName : extractOverrideTargetName(selector); + if (typeof value === 'string' && packageName) { + const aligned = alignedSpec(packageName, value, toolchain); + if (aligned !== value) { + output = replaceJsonValue(output, [...location, selector], aligned); + } + } else if (isRecord(value)) { + output = alignJsonOverrideMap(output, value, [...location, selector], toolchain, packageName); + } + } + return output; +} + +function alignJsonCatalogs( + contents: string, + source: Record, + location: readonly string[], + toolchain: SyncVersionsToolchain, +): string { + let output = contents; + for (const [catalogName, entries] of Object.entries(source)) { + if (isRecord(entries)) { + output = alignJsonStringMap(output, entries, [...location, catalogName], toolchain); + } + } + return output; +} + +function planPackageJson(contents: string, toolchain: SyncVersionsToolchain): string { + const pkg = parsePackageJson(contents); + let output = contents; + + for (const field of INSTALL_DEPENDENCY_FIELDS) { + const dependencies = pkg[field]; + if (isRecord(dependencies)) { + output = alignJsonStringMap(output, dependencies, [field], toolchain); + } + } + + const overrides = pkg.overrides; + if (isRecord(overrides)) { + output = alignJsonOverrideMap(output, overrides, ['overrides'], toolchain); + } + + const resolutions = pkg.resolutions; + if (isRecord(resolutions)) { + output = alignJsonStringMap( + output, + resolutions, + ['resolutions'], + toolchain, + extractOverrideTargetName, + ); + } + + const pnpm = pkg.pnpm; + if (isRecord(pnpm) && isRecord(pnpm.overrides)) { + output = alignJsonStringMap( + output, + pnpm.overrides, + ['pnpm', 'overrides'], + toolchain, + extractOverrideTargetName, + ); + } + + const catalog = pkg.catalog; + if (isRecord(catalog)) { + output = alignJsonStringMap(output, catalog, ['catalog'], toolchain); + } + + const catalogs = pkg.catalogs; + if (isRecord(catalogs)) { + output = alignJsonCatalogs(output, catalogs, ['catalogs'], toolchain); + } + + const workspaces = pkg.workspaces; + if (isRecord(workspaces)) { + if (isRecord(workspaces.catalog)) { + output = alignJsonStringMap(output, workspaces.catalog, ['workspaces', 'catalog'], toolchain); + } + if (isRecord(workspaces.catalogs)) { + output = alignJsonCatalogs( + output, + workspaces.catalogs, + ['workspaces', 'catalogs'], + toolchain, + ); + } + } + + return output; +} + +function alignYamlStringMap( + document: ReturnType, + source: unknown, + location: readonly string[], + toolchain: SyncVersionsToolchain, + edits: TextEdit[], + packageNameForKey: (key: string) => string = (key) => key, +): void { + if (!isRecord(source)) { + return; + } + for (const [name, value] of Object.entries(source)) { + if (typeof value !== 'string') { + continue; + } + const aligned = alignedSpec(packageNameForKey(name), value, toolchain); + if (aligned === value) { + continue; + } + const node = document.getIn([...location, name], true); + if (!isScalar(node) || typeof node.value !== 'string') { + throw new Error(`Expected a string scalar at ${[...location, name].join('.')}`); + } + const range = node.range; + if (range === null || range === undefined) { + throw new Error(`Expected a source range at ${[...location, name].join('.')}`); + } + let replacement: string; + switch (node.type) { + case Scalar.PLAIN: + replacement = aligned; + break; + case Scalar.QUOTE_SINGLE: + replacement = `'${aligned.replaceAll("'", "''")}'`; + break; + case Scalar.QUOTE_DOUBLE: + replacement = JSON.stringify(aligned); + break; + default: + throw new Error(`Unsupported YAML scalar style at ${[...location, name].join('.')}`); + } + edits.push({ start: range[0], end: range[1], value: replacement }); + } +} + +function applyTextEdits(contents: string, edits: TextEdit[]): string { + let output = contents; + let previousStart = contents.length; + for (const edit of edits.toSorted((left, right) => right.start - left.start)) { + if (edit.start < 0 || edit.end < edit.start || edit.end > previousStart) { + throw new Error('Overlapping or invalid YAML manifest edits'); + } + output = `${output.slice(0, edit.start)}${edit.value}${output.slice(edit.end)}`; + previousStart = edit.start; + } + return output; +} + +function parseYamlManifest( + contents: string, + fileName: 'pnpm-workspace.yaml' | '.yarnrc.yml', +): { + document: ReturnType; + manifest: Record; +} { + const document = parseDocument(contents); + if (document.errors.length > 0) { + throw new Error(`Invalid ${fileName} manifest`); + } + const manifest: unknown = document.toJS(); + if (!isRecord(manifest)) { + throw new Error(`Invalid ${fileName} manifest`); + } + return { document, manifest }; +} + +function alignYamlCatalogs( + document: ReturnType, + manifest: Record, + toolchain: SyncVersionsToolchain, + edits: TextEdit[], +): void { + alignYamlStringMap(document, manifest.catalog, ['catalog'], toolchain, edits); + if (isRecord(manifest.catalogs)) { + for (const [catalogName, entries] of Object.entries(manifest.catalogs)) { + alignYamlStringMap(document, entries, ['catalogs', catalogName], toolchain, edits); + } + } +} + +function planPnpmWorkspace(contents: string, toolchain: SyncVersionsToolchain): string { + const { document, manifest } = parseYamlManifest(contents, 'pnpm-workspace.yaml'); + const edits: TextEdit[] = []; + alignYamlCatalogs(document, manifest, toolchain, edits); + alignYamlStringMap( + document, + manifest.overrides, + ['overrides'], + toolchain, + edits, + extractOverrideTargetName, + ); + + return applyTextEdits(contents, edits); +} + +function planYarnRc(contents: string, toolchain: SyncVersionsToolchain): string { + const { document, manifest } = parseYamlManifest(contents, '.yarnrc.yml'); + const edits: TextEdit[] = []; + alignYamlCatalogs(document, manifest, toolchain, edits); + return applyTextEdits(contents, edits); +} + +function planManifest( + manifest: SyncVersionsManifestSnapshot, + toolchain: SyncVersionsToolchain, +): string { + switch (manifest.kind) { + case 'packageJson': + return planPackageJson(manifest.contents, toolchain); + case 'pnpmWorkspace': + return planPnpmWorkspace(manifest.contents, toolchain); + case 'yarnRc': + return planYarnRc(manifest.contents, toolchain); + default: { + const exhaustive: never = manifest; + return exhaustive; + } + } +} + +export function planSyncVersions( + request: SyncVersionsRequestV1, + toolchain: SyncVersionsToolchain, +): SyncVersionsPlanV1 { + const replacements: SyncVersionsReplacementV1[] = []; + for (const manifest of request.manifests) { + const after = planManifest(manifest, toolchain); + if (after !== manifest.contents) { + replacements.push({ + path: manifest.path, + kind: manifest.kind, + before: manifest.contents, + after, + }); + } + } + + return { + schemaVersion: 1, + tool: { name: 'vite-plus', version: toolchain.vitePlus }, + workspace: '.', + replacements, + }; +} diff --git a/packages/cli/src/sync-versions/protocol.ts b/packages/cli/src/sync-versions/protocol.ts new file mode 100644 index 0000000000..f6cf1e78f8 --- /dev/null +++ b/packages/cli/src/sync-versions/protocol.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +import { parseSyncVersionsRequest, planSyncVersions, type SyncVersionsToolchain } from './plan.ts'; + +const exactVersionSchema = z + .string() + .regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/); + +const toolchainManifestSchema = z + .object({ + schemaVersion: z.literal(1), + nodes: z.array( + z + .object({ + id: z.string().min(1), + version: exactVersionSchema.optional(), + }) + .passthrough(), + ), + }) + .passthrough(); + +function requiredToolVersion( + nodes: z.infer['nodes'], + id: string, +): string { + const matches = nodes.filter((node) => node.id === id); + if (matches.length !== 1 || matches[0].version === undefined) { + throw new Error(`Toolchain manifest must contain one exact ${id} version`); + } + return matches[0].version; +} + +export function toolchainFromManifest(input: unknown): SyncVersionsToolchain { + const manifest = toolchainManifestSchema.parse(input); + return { + vitePlus: requiredToolVersion(manifest.nodes, 'vite-plus'), + vitest: requiredToolVersion(manifest.nodes, 'vitest'), + }; +} + +export function runSyncVersionsProtocol(requestJson: string, manifest: unknown): string { + let input: unknown; + try { + input = JSON.parse(requestJson); + } catch { + throw new Error('Invalid sync request JSON'); + } + const request = parseSyncVersionsRequest(input); + const toolchain = toolchainFromManifest(manifest); + const plan = planSyncVersions(request, toolchain); + return `${JSON.stringify(plan)}\n`; +} diff --git a/packages/cli/src/utils/package-overrides.spec.ts b/packages/cli/src/utils/package-overrides.spec.ts new file mode 100644 index 0000000000..accece7581 --- /dev/null +++ b/packages/cli/src/utils/package-overrides.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { extractOverrideTargetName } from './package-overrides.ts'; + +describe('extractOverrideTargetName', () => { + it.each([ + ['vite-plus', 'vite-plus'], + ['vite-plus@^0.3.0', 'vite-plus'], + ['@vitest/coverage-v8@4', '@vitest/coverage-v8'], + ['app>vite-plus', 'vite-plus'], + ['app@1>test>@vitest/browser-playwright@4', '@vitest/browser-playwright'], + ['app/@vitest/coverage-v8@4', '@vitest/coverage-v8'], + ['**/vitest', 'vitest'], + ['pkg@>4', 'pkg'], + ['pkg@>=4', 'pkg'], + ])('extracts %s', (selector, expected) => { + expect(extractOverrideTargetName(selector)).toBe(expected); + }); +}); diff --git a/packages/cli/src/utils/package-overrides.ts b/packages/cli/src/utils/package-overrides.ts new file mode 100644 index 0000000000..9ca5e98579 --- /dev/null +++ b/packages/cli/src/utils/package-overrides.ts @@ -0,0 +1,32 @@ +// Extract the package name an override/resolution key targets. This mirrors the +// package-manager grammars for bare names, versioned descriptors, pnpm parent +// selectors, and Yarn from/target selectors. +export function extractOverrideTargetName(key: string): string { + let target = key.trim(); + + // pnpm uses `>` between parent and child selectors, except when it is part of + // a comparator such as `pkg@>4` or `pkg@>=4`. + for ( + let delimiter = target.search(/[^ |@]>/); + delimiter !== -1; + delimiter = target.search(/[^ |@]>/) + ) { + target = target.slice(delimiter + 2).trim(); + } + if (!target) { + return target; + } + + // Yarn uses `from/target`; retain a trailing scoped target as one name. + 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; + } + + // The leading `@` of a scope is not a version delimiter. + const nameStart = target.startsWith('@') ? target.indexOf('/') + 1 : 0; + const versionAt = target.indexOf('@', nameStart); + return versionAt > 0 ? target.slice(0, versionAt) : target; +} diff --git a/packages/cli/src/utils/vitest-ecosystem.ts b/packages/cli/src/utils/vitest-ecosystem.ts new file mode 100644 index 0000000000..f950cf3ee8 --- /dev/null +++ b/packages/cli/src/utils/vitest-ecosystem.ts @@ -0,0 +1,10 @@ +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. + '@vitest/coverage-c8', +]); + +export function isAlignableVitestEcosystemPackage(name: string): boolean { + return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); +} diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 94c7d182b2..152c230a9e 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -58,6 +58,7 @@ export default defineConfig([ 'create/bin': './src/create/bin.ts', 'migration/bin': './src/migration/bin.ts', 'migration/compat/worker': './src/migration/compat/worker.ts', + 'sync-versions/bin': './src/sync-versions/bin.ts', version: './src/version.ts', 'config/bin': './src/config/bin.ts', 'hooks/bin': './src/hooks/bin.ts', @@ -82,6 +83,29 @@ export default defineConfig([ plugins: [fixVersionsPathPlugin, inlineLintStagedVersionPlugin], }, + // Standalone machine protocol shipped with the prebuilt `vp` archive. + // Keep this as one self-contained file so Containerbase can extract the + // verified release asset without installing npm dependencies. + { + name: 'sync-versions', + entry: { + 'sync-versions/bin': './src/sync-versions/bin.ts', + }, + outDir: 'dist', + format: 'esm', + fixedExtension: true, + dts: false, + clean: false, + outputOptions: { + codeSplitting: false, + }, + inputOptions: { + resolve: { + mainFields: ['module', 'main'], + }, + }, + }, + // CJS — dual-format entries { name: 'cli-cjs',