Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
42 changes: 15 additions & 27 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,32 +109,19 @@ jobs:
- name: Pack packages into tgz
run: |
mkdir -p tmp/tgz
# Every tgz consumer below references fixed `*-0.0.0.tgz` filenames.
# A release commit can leave `packages/{core,cli}` at a published
# version (e.g. 0.1.22), which would make `pnpm pack` emit
# `*-0.1.22.tgz` instead. Pin both to 0.0.0 so the names stay stable.
# patch-project.ts serves these tgz through a local npm registry
# (packages/tools/src/local-npm-registry.ts), so `vp migrate` pins
# and installs them like a real release, with every package manager
# (including bun) resolving the standard
# `vite -> npm:@voidzero-dev/vite-plus-core@<version>` alias. Pin
# both packages to 0.0.0 so a correctly installed local build is
# always distinguishable from any published version
# (verify-install.ts asserts it), even on a release commit that
# leaves packages/{core,cli} at a published version.
(cd packages/core && npm pkg set version=0.0.0)
(cd packages/cli && npm pkg set version=0.0.0)
cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
# Bun is uniquely strict about peer-dep resolution:
# 1. It checks the *resolved target's* package name and version
# against the peer range (vitest 4.1.9 declares peer
# `vite ^6 || ^7 || ^8`).
# 2. A file: override pointing at the vite-plus-core tgz fails
# both the name check (target is `@voidzero-dev/vite-plus-core`,
# not `vite`) and the version check (0.0.0 is outside `^6|^7|^8`).
# pnpm/npm/yarn don't enforce either, and using the same core tgz as
# the file: target for both `vite` and `@voidzero-dev/vite-plus-core`
# is the only configuration they install cleanly. See
# https://github.com/oven-sh/bun/issues/8406.
#
# Generate a sibling vite-7.99.0.tgz: a copy of the core tgz with
# `package.json#name` rewritten to "vite" and `version` to 7.99.0.
# ecosystem-ci/patch-project.ts points its vite override at this
# tgz only for bun-based projects (e.g. bun-vite-template); pnpm-,
# npm- and yarn-based ecosystem projects use the real core tgz.
pnpm exec tool repack-vite-tgz tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz tmp/tgz/vite-7.99.0.tgz vite 7.99.0
# Copy vp binary for e2e-test job (findVpBinary expects it in target/)
cp target/${{ matrix.target }}/release/vp tmp/tgz/vp 2>/dev/null || cp target/${{ matrix.target }}/release/vp.exe tmp/tgz/vp.exe 2>/dev/null || true
cp target/${{ matrix.target }}/release/vp-shim.exe tmp/tgz/vp-shim.exe 2>/dev/null || true
Expand Down Expand Up @@ -438,13 +425,14 @@ jobs:
- name: Migrate in ${{ matrix.project.name }}
working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }}
shell: bash
# patch-project.ts runs `vp migrate` and the follow-up `vp install`,
# handling pnpm's minimumReleaseAge gate per project (disabled so
# freshly published deps install, kept off for dify's time-based
# resolution policy). See its comments for the details.
# patch-project.ts starts a local npm registry serving the packed
# local build (it stays up for the later steps; the registry env is
# exported via GITHUB_ENV), then runs `vp migrate` and the follow-up
# `vp install` through it, with pnpm's minimumReleaseAge gate disabled
# so freshly published upstream deps install. See its comments.
run: node $GITHUB_WORKSPACE/ecosystem-ci/patch-project.ts ${{ matrix.project.name }}

- name: Verify local tgz packages installed
- name: Verify local packages installed
working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }}
shell: bash
run: node $GITHUB_WORKSPACE/ecosystem-ci/verify-install.ts
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ vite-plus/
- **Generated project agent guidance**: `packages/cli/AGENTS.md` and `packages/cli/src/utils/agent.ts`; do not edit these when the task is only to improve root repo guidance.
- **Product/repo docs**: root contributor docs live at the repo root and the VitePress site under `docs/` (`docs/guide/`, `docs/config/`); generated agent guidance is separate.
- **CLI output behavior**: inspect the relevant code plus `packages/cli/snap-tests/` or `packages/cli/snap-tests-global/`.
- **Install-testing against the local build**: `packages/tools/src/local-npm-registry.ts` serves the packed checkout behind a real registry interface; used by install snap fixtures (`localVitePlusPackages`), ecosystem e2e (`ecosystem-ci/patch-project.ts`), and local `vp migrate`/`vp create` iteration (see `CONTRIBUTING.md`).

## Command and Config Model

Expand Down
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ Verify the link with `ls -l node_modules/vite-plus` (it should be a symlink into
- `pnpm link` may also add a `packageManager` field to the test project's `package.json`; revert it if unwanted.
- Undo with `pnpm unlink vite-plus`, or remove the override and run `pnpm install`.

### Test `vp migrate` / `vp create` through a local npm registry

`pnpm link` swaps the code inside an existing project, but `vp migrate` and `vp create` pin the exact CLI version and then _install_ it, so the checkout's `vite-plus` / `@voidzero-dev/vite-plus-core` must be resolvable from a registry. `packages/tools/src/local-npm-registry.ts` provides that: it packs the checkout, serves the tarballs behind a real registry HTTP interface, and proxies every other package upstream. This replaces the old pkg.pr.new publish + registry-bridge round-trip for local iteration; you can verify migrate/create logic immediately after a build.

```bash
pnpm build # the served packages are built artifacts; rebuild after JS changes

# One-shot: wrap any command (from the project you want to migrate)
cd /path/to/test-project
node /path/to/vite-plus/packages/tools/src/local-npm-registry.ts --pack -- vp migrate --no-interactive
node /path/to/vite-plus/packages/tools/src/local-npm-registry.ts --pack -- vp create vite:application --no-interactive

# Or keep a server running for repeated commands (from the vite-plus checkout)
pnpm local-registry --pack --serve
# copy the printed `export ...` lines into the shell where you run vp
```

Notes:

- The served versions carry an old publish time, so `minimumReleaseAge` gates never quarantine them, and wrapped runs get throwaway Yarn Berry / bun caches (both cache registry state in ways that would otherwise leak stale local builds between runs).
- The same server backs the install snap fixtures (`localVitePlusPackages` in `steps.json`) and ecosystem e2e (`ecosystem-ci/patch-project.ts`), so a flow that works here works there too.
- `pnpm local-registry:ps` lists any registry processes still running (e.g. a `--serve` you forgot, or a wrapper that was killed mid-run); `pnpm local-registry:kill` stops them all and removes their leftover temp caches.

### Global CLI (Rust) changes

`pnpm link` only swaps the JS side; the `vp` binary on `PATH` (and the Rust-backed commands it handles directly, such as package-manager commands) is still whatever is installed in `~/.vite-plus`. For changes to the Rust global CLI (`crates/`), install it from source, and combine with `pnpm link` when the change spans both layers:
Expand Down
199 changes: 111 additions & 88 deletions ecosystem-ci/patch-project.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { execSync } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { execSync, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { appendFile, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import { VITEST_VERSION } from '../packages/cli/src/utils/constants.ts';
import { ecosystemCiDir, tgzDir } from './paths.ts';
import { ecosystemCiDir, tgzDir, vitePlusTgzVersion } from './paths.ts';
import repos from './repo.json' with { type: 'json' };

const projects = Object.keys(repos);
Expand All @@ -19,12 +20,68 @@ const repoRoot = join(ecosystemCiDir, project);
const repoConfig = repos[project as keyof typeof repos];
const directory = 'directory' in repoConfig ? repoConfig.directory : undefined;
const cwd = directory ? join(repoRoot, directory) : repoRoot;
// The e2e build job pins packages/cli to 0.0.0 before `pnpm pack`, so the
// artifact is always vite-plus-0.0.0.tgz regardless of the committed version.
const vitePlusTgz = `file:${tgzDir}/vite-plus-0.0.0.tgz`;
// run vp migrate
const cli = process.env.VP_CLI_BIN ?? 'vp';

// The packed local build in tmp/tgz is served through a local npm registry
// (local-npm-registry.ts), so vp migrate pins and installs the checkout's
// own version through the standard registry code paths, with no `file:` specs.
const vitePlusVersion = vitePlusTgzVersion();

const registryScript = join(
import.meta.dirname,
'..',
'packages',
'tools',
'src',
'local-npm-registry.ts',
);
// Detach the server so it can outlive this script on CI: the lockfiles
// written below reference its tarball URLs, and later workflow steps (the
// project's own vp commands) inherit the registry env via GITHUB_ENV.
// stderr must not inherit this process's streams: the detached server would
// hold the step's output pipe open after this script exits.
const registryServer = spawn(
process.execPath,
[registryScript, '--serve', '--packages-dir', tgzDir],
{
stdio: ['ignore', 'pipe', 'ignore'],
detached: true,
},
);
const registryInfo = await new Promise<{ registry: string; env: Record<string, string> }>(
(resolve, reject) => {
let buffered = '';
registryServer.stdout.on('data', (chunk: Buffer) => {
buffered += chunk.toString();
const newline = buffered.indexOf('\n');
if (newline !== -1) {
resolve(JSON.parse(buffered.slice(0, newline)));
}
});
registryServer.on('error', reject);
registryServer.on('exit', (code) => reject(new Error(`registry exited early (${code})`)));
},
);
console.log(
`Serving local Vite+ packages at ${registryInfo.registry} (vite-plus@${vitePlusVersion})`,
);
// The server prints nothing after the handshake; release the pipe and the
// process handle so they don't keep this script's event loop alive after the
// installs below finish.
registryServer.stdout.destroy();
registryServer.unref();

if (process.env.GITHUB_ENV) {
// Keep the registry reachable for the workflow's later steps.
const lines = Object.entries(registryInfo.env)
.map(([key, value]) => `${key}=${value}\n`)
.join('');
await appendFile(process.env.GITHUB_ENV, lines);
} else {
process.on('exit', () => registryServer.kill());
}

if (project === 'rollipop') {
const oxfmtrc = await readFile(join(repoRoot, '.oxfmtrc.json'), 'utf-8');
await writeFile(
Expand Down Expand Up @@ -61,11 +118,10 @@ if (project === 'vinext') {
}

if (project === 'dify') {
// dify sets `minimumReleaseAge` (0) with `resolutionMode: time-based`, and
// pnpm 11.5.2 crashes with ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED
// once the policy machinery is active and the local `file:` tgz overrides
// produce violations (file deps have no publish timestamp). Remove the key
// so the policy stays inactive for the ecosystem run.
// dify sets `minimumReleaseAge` with `resolutionMode: time-based`. Keep the
// policy inactive for the ecosystem run so a same-day upstream publish does
// not fail resolution (the local Vite+ packages themselves carry an old
// `time` from the registry, so they always pass age gates).
const workspacePath = join(repoRoot, 'pnpm-workspace.yaml');
const workspace = await readFile(workspacePath, 'utf-8');
const patched = workspace.replace(/^minimumReleaseAge:.*\n/m, '');
Expand All @@ -79,16 +135,6 @@ if (project === 'dify') {
// vp migrate runs full dependency rewriting instead of skipping.
const forceFreshMigration = 'forceFreshMigration' in repoConfig && repoConfig.forceFreshMigration;

// Bun is uniquely strict about vitest's `peer vite ^6 || ^7 || ^8` resolution
// (https://github.com/oven-sh/bun/issues/8406): it checks both the override
// target's package name and version. Point bun-based projects at the
// vite-7.99.0 alias tgz (a copy of core renamed to "vite" with a satisfying
// version); pnpm/npm/yarn must keep pointing at the real core tgz, otherwise
// they trip a registry lookup for "vite@<version>" when a workspace
// sub-package and the override both reference the same vite-named alias.
const isBunProject = project === 'bun-vite-template';
const viteOverrideTgz = isBunProject ? `vite-7.99.0.tgz` : `voidzero-dev-vite-plus-core-0.0.0.tgz`;

// Mirror VITE_PLUS_OVERRIDE_PACKAGES: pin `vitest` only. The `@vitest/*` family
// are exact deps of `vitest`, so a single `vitest` override cascades them.
//
Expand All @@ -106,78 +152,55 @@ const vitestOverrides = {
'@vitest/coverage-istanbul': VITEST_VERSION,
};

// E2E intentionally installs just-published toolchain packages (e.g.
// @oxlint/migrate during `vp migrate`, freshly bumped @oxc-project/runtime
// during `vp install`). Disable pnpm's minimumReleaseAge gate so a same-day
// publish does not fail with ERR_PNPM_NO_MATURE_MATCHING_VERSION. pnpm >= 10.6
// only reads the PNPM_CONFIG_* spelling; older pnpm reads the lowercase form.
//
// Projects with `resolutionMode: time-based` (currently dify) are the
// exception: defining a minimumReleaseAge (even 0, via any env spelling)
// activates pnpm's resolution-policy engine there, which vp's bundled pnpm
// cannot handle (ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, no
// handleResolutionPolicyViolations callback wired). Their `minimumReleaseAge:`
// key is stripped by the per-project patches above, so with no gate env the
// policy stays inactive and installs work.
const workspaceYamlPath = join(repoRoot, 'pnpm-workspace.yaml');
const timeBasedResolution =
existsSync(workspaceYamlPath) &&
/^resolutionMode:\s*time-based/m.test(readFileSync(workspaceYamlPath, 'utf-8'));
const releaseAgeEnv = timeBasedResolution
? {}
: {
pnpm_config_minimum_release_age: '0',
PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0',
};

const migrateEnv: NodeJS.ProcessEnv = {
...process.env,
...registryInfo.env,
...(forceFreshMigration ? { VP_FORCE_MIGRATE: '1' } : {}),
VP_OVERRIDE_PACKAGES: JSON.stringify({
vite: `file:${tgzDir}/${viteOverrideTgz}`,
'@voidzero-dev/vite-plus-core': `file:${tgzDir}/voidzero-dev-vite-plus-core-0.0.0.tgz`,
vite: `npm:@voidzero-dev/vite-plus-core@${vitePlusVersion}`,
...vitestOverrides,
}),
VP_VERSION: vitePlusTgz,
// E2E intentionally installs just-published toolchain packages (e.g.
// @oxlint/migrate during `vp migrate`). Disable pnpm's minimumReleaseAge gate
// so a same-day publish does not fail with ERR_PNPM_NO_MATURE_MATCHING_VERSION.
// This is scoped to the migrate subprocess; the workflow's follow-up
// `vp install` runs without it (see the dify note below).
pnpm_config_minimum_release_age: '0',
};

const isDify = project === 'dify';

try {
execSync(`${cli} migrate --no-agent --no-interactive`, {
cwd,
stdio: 'inherit',
env: migrateEnv,
});
} catch (err) {
// dify sets `resolutionMode: time-based`, so the gate var above re-activates
// pnpm's resolution policy during migrate's auto-install. vp's bundled pnpm
// has no handleResolutionPolicyViolations callback, so the local `file:` tgz
// overrides (no publish timestamp) crash it with
// ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED. Tolerate it: migrate still
// generated the config and import rewrites, and the workflow's follow-up
// `vp install` (run without the gate var, so the policy stays inactive) does
// the real install. Other projects must still fail hard.
if (!isDify) {
throw err;
}
console.warn(
'dify: `vp migrate` auto-install failed (expected with the age gate active); ' +
'continuing, `vp install` will resync node_modules.',
);
}

const packageJsonPath = join(cwd, 'package.json');
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
// The vp binary was built before the pack step pinned the package versions,
// so align the version migrate pins with the tgz the registry serves.
VP_VERSION: vitePlusVersion,
...releaseAgeEnv,
};

if (packageJson.dependencies?.['vite-plus']) {
packageJson.dependencies['vite-plus'] = vitePlusTgz;
} else {
packageJson.devDependencies ??= {};
packageJson.devDependencies['vite-plus'] = vitePlusTgz;
}

await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf-8');

// Install with the local tgz overrides now wired into package.json. Disable
// pnpm's minimumReleaseAge gate so the freshly published bumped deps (e.g.
// @oxc-project/runtime, @oxfmt/binding-*) pass `vp install`'s lockfile
// supply-chain check instead of failing with
// ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION.
//
// dify is the exception: it sets `resolutionMode: time-based`, so the gate var
// re-activates pnpm's resolution policy and vp's bundled pnpm (no
// handleResolutionPolicyViolations callback) crashes on the local file: tgz
// overrides with ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED. Its
// `minimumReleaseAge:` key was already removed above, so the policy stays
// inactive when the var is unset.
const installEnv: NodeJS.ProcessEnv = { ...process.env };
if (!isDify) {
installEnv.pnpm_config_minimum_release_age = '0';
}
execSync(`${cli} install --no-frozen-lockfile`, { cwd, stdio: 'inherit', env: installEnv });
execSync(`${cli} migrate --no-agent --no-interactive`, {
cwd,
stdio: 'inherit',
env: migrateEnv,
});

// Install through the local registry. `vp migrate` already pinned
// `vite-plus@<version>` in package.json exactly like a real migration, so no
// manual package.json rewrite is needed.
execSync(`${cli} install --no-frozen-lockfile`, {
cwd,
stdio: 'inherit',
env: { ...process.env, ...registryInfo.env, ...releaseAgeEnv },
});
17 changes: 17 additions & 0 deletions ecosystem-ci/paths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -11,3 +12,19 @@ export const ecosystemCiDir = join(tempBase, 'vite-plus-ecosystem-ci');

// tgz path: always use local tmp/tgz
export const tgzDir = join(projectDir, '..', 'tmp', 'tgz');

/**
* The version of the packed vite-plus tgz that the local registry serves and
* that `vp migrate` pins: 0.0.0 on CI (the e2e pack step pins it so a local
* build is always distinguishable from a published version), the checkout
* version on a local run.
*/
export function vitePlusTgzVersion(): string {
const version = readdirSync(tgzDir)
.map((entry) => /^vite-plus-(\d.*)\.tgz$/.exec(entry)?.[1])
.find(Boolean);
if (!version) {
throw new Error(`No vite-plus-<version>.tgz found in ${tgzDir}`);
}
return version;
}
Loading
Loading