Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ check Bun configuration is unchanged
```
[install]
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@zerobyte/*"]
minimumReleaseAgeExcludes = ["@zerobyte/*", "vite-plus", "@voidzero-dev/vite-plus-core", "@voidzero-dev/vite-plus-darwin-arm64", "@voidzero-dev/vite-plus-darwin-x64", "@voidzero-dev/vite-plus-linux-arm64-gnu", "@voidzero-dev/vite-plus-linux-arm64-musl", "@voidzero-dev/vite-plus-linux-x64-gnu", "@voidzero-dev/vite-plus-linux-x64-musl", "@voidzero-dev/vite-plus-win32-arm64-msvc", "@voidzero-dev/vite-plus-win32-x64-msvc", "vitest", "@vitest/browser", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/expect", "@vitest/mocker", "@vitest/pretty-format", "@vitest/runner", "@vitest/snapshot", "@vitest/spy", "@vitest/ui", "@vitest/utils", "@vitest/web-worker", "@vitest/ws-client"]
```
59 changes: 59 additions & 0 deletions packages/cli/src/utils/__tests__/preview-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,65 @@ describe('reconcilePreviewBridgeRegistry', () => {
expect(fs.readFileSync(path.join(dir, '.npmrc'), 'utf8')).toContain(`registry=${BRIDGE}`);
});

it('exempts managed packages from an npm minimum release age gate', () => {
fs.writeFileSync(
path.join(dir, '.npmrc'),
'min-release-age=3\nmin-release-age-exclude[]=user-package\n',
);
reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.npm, '11.17.0');
reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.npm, '11.17.0');
const npmrc = fs.readFileSync(path.join(dir, '.npmrc'), 'utf8');
expect(npmrc).toContain('min-release-age-exclude[]=user-package');
for (const name of [
'vite-plus',
'@voidzero-dev/vite-plus-core',
'@voidzero-dev/vite-plus-darwin-x64',
'vitest',
'@vitest/browser',
]) {
expect(
npmrc.split(/\r?\n/).filter((line) => line === `min-release-age-exclude[]=${name}`),
).toHaveLength(1);
}
});

it('exempts managed packages from a Bun minimum release age gate', () => {
fs.writeFileSync(
path.join(dir, 'bunfig.toml'),
'[install]\nminimumReleaseAge = 259200\nminimumReleaseAgeExcludes = ["@demo/*"]\n\n[run]\nsilent = true\n',
);
reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.bun);
reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.bun);
const bunfig = fs.readFileSync(path.join(dir, 'bunfig.toml'), 'utf8');
expect(bunfig).toContain('minimumReleaseAgeExcludes = ["@demo/*", "vite-plus"');
expect(bunfig).toContain('"@voidzero-dev/vite-plus-core"');
expect(bunfig).toContain('"@voidzero-dev/vite-plus-darwin-x64"');
expect(bunfig).toContain('"vitest"');
expect(bunfig).toContain('"@vitest/browser"');
expect(bunfig.match(/"@vitest\/browser"/g)).toHaveLength(1);
expect(bunfig).toContain('[run]\nsilent = true');
});

it('adds exact preview trust exclusions for pnpm and removes them for a real release', () => {
fs.writeFileSync(
path.join(dir, 'pnpm-workspace.yaml'),
"packages: []\ntrustPolicy: no-downgrade\ntrustPolicyExclude:\n - 'webpack@5.0.0'\n",
);
reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.pnpm);
reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.pnpm);
let workspace = fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8');
expect(workspace).toContain(`vite-plus@${PREVIEW}`);
expect(workspace).toContain(`@voidzero-dev/vite-plus-core@${PREVIEW}`);
expect(workspace).toContain(`@voidzero-dev/vite-plus-darwin-x64@${PREVIEW}`);
expect(workspace.match(new RegExp(`vite-plus@${PREVIEW}`, 'g'))).toHaveLength(1);
expect(workspace).toContain('webpack@5.0.0');

reconcilePreviewBridgeRegistry(dir, '0.2.1', PackageManager.pnpm);
workspace = fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8');
expect(workspace).not.toContain('0.0.0-commit');
expect(workspace).toContain('webpack@5.0.0');
});

it('appends to an existing .npmrc without clobbering it', () => {
fs.writeFileSync(path.join(dir, '.npmrc'), '@scope:registry=https://npm.example.com/\n');
reconcilePreviewBridgeRegistry(dir, PREVIEW);
Expand Down
32 changes: 31 additions & 1 deletion packages/cli/src/utils/__tests__/run-vite-install.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

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

import { PackageManager } from '../../types/index.ts';
Expand All @@ -15,10 +19,11 @@ function installResult(exitCode: number, stdout = '', stderr = '') {
return { exitCode, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) };
}

describe('runViteInstall with detectIgnoredBuilds', () => {
describe('runViteInstall', () => {
beforeEach(() => {
mockRun.mockReset();
delete process.env.VP_SKIP_INSTALL;
delete process.env.VP_VERSION;
});

it('treats pnpm >= 11 ERR_PNPM_IGNORED_BUILDS exit-1 as a completed install', async () => {
Expand Down Expand Up @@ -77,4 +82,29 @@ describe('runViteInstall with detectIgnoredBuilds', () => {
expect.objectContaining({ args: ['install', '--ignore-scripts'] }),
);
});

it('temporarily relaxes an npm age gate when the pinned npm lacks exclusions', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-install-policy-'));
try {
fs.writeFileSync(path.join(dir, '.npmrc'), 'min-release-age=3\n');
mockRun.mockResolvedValue(installResult(0));

await runViteInstall(dir, false, undefined, {
silent: true,
packageManager: PackageManager.npm,
packageManagerVersion: '11.12.1',
});

expect(mockRun).toHaveBeenCalledWith(
expect.objectContaining({
envs: expect.objectContaining({ NPM_CONFIG_MIN_RELEASE_AGE: '0' }),
}),
);
expect(fs.readFileSync(path.join(dir, '.npmrc'), 'utf8')).not.toContain(
'min-release-age-exclude',
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
232 changes: 229 additions & 3 deletions packages/cli/src/utils/preview-registry.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';

import { Scalar } from 'yaml';
import semver from 'semver';
import { Scalar, YAMLSeq } from 'yaml';

import { PackageManager } from '../types/index.ts';
import { VITE_PLUS_VERSION } from './constants.ts';
import { readJsonFile } from './json.ts';
import { editYamlFile } from './yaml.ts';
import { editYamlFile, scalarString } from './yaml.ts';

const DEFAULT_BRIDGE_REGISTRY = 'https://registry-bridge.viteplus.dev/';
const REGISTRY_MARKER = '# vite-plus preview build registry bridge (auto-added by vp)';
Expand All @@ -15,6 +16,41 @@ const REGISTRY_MARKER = '# vite-plus preview build registry bridge (auto-added b
// pointed at the same host.
const BRIDGE_HOST = 'registry-bridge.viteplus.dev';

const PREVIEW_TRUST_PACKAGES = [
'vite-plus',
'@voidzero-dev/vite-plus-core',
'@voidzero-dev/vite-plus-darwin-arm64',
'@voidzero-dev/vite-plus-darwin-x64',
'@voidzero-dev/vite-plus-linux-arm64-gnu',
'@voidzero-dev/vite-plus-linux-arm64-musl',
'@voidzero-dev/vite-plus-linux-x64-gnu',
'@voidzero-dev/vite-plus-linux-x64-musl',
'@voidzero-dev/vite-plus-win32-arm64-msvc',
'@voidzero-dev/vite-plus-win32-x64-msvc',
] as const;
const MANAGED_VITEST_PACKAGES = [
'vitest',
'@vitest/browser',
'@vitest/browser-playwright',
'@vitest/browser-preview',
'@vitest/browser-webdriverio',
'@vitest/coverage-istanbul',
'@vitest/coverage-v8',
'@vitest/expect',
'@vitest/mocker',
'@vitest/pretty-format',
'@vitest/runner',
'@vitest/snapshot',
'@vitest/spy',
'@vitest/ui',
'@vitest/utils',
'@vitest/web-worker',
'@vitest/ws-client',
] as const;
const MANAGED_AGE_GATE_EXCLUDES = [...PREVIEW_TRUST_PACKAGES, ...MANAGED_VITEST_PACKAGES];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include direct CLI tool deps in age-gate excludes

With npm or Bun age gates, this combined list is the complete set of package-name exclusions written before vp install. It covers Vite+/native packages and Vitest, but omits direct toolchain dependencies that the published vite-plus package installs (oxfmt, oxlint, oxlint-tsgolint, @oxlint/plugins, @oxc-project/types; see packages/cli/package.json:361-377), while the existing pnpm age-gate code already treats the ox family as Vite+-managed (packages/cli/src/migration/migrator/catalog.ts:62-72). If one of those exact bundled versions is still younger than the user's npm/Bun release-age gate, migrate/create installs will still be rejected despite this reconciliation.

Useful? React with 👍 / 👎.

const PREVIEW_TRUST_SELECTOR_RE =
/^(?:vite-plus|@voidzero-dev\/vite-plus(?:-core|-(?:darwin-(?:arm64|x64)|linux-(?:arm64|x64)-(?:gnu|musl)|win32-(?:arm64|x64)-msvc)))@0\.0\.0-/;

/**
* Registry bridge that serves PR preview builds as ordinary `0.0.0-commit.<sha>`
* versions (and proxies everything else to npmjs). Only ever used for preview
Expand Down Expand Up @@ -84,6 +120,167 @@ function ensureNpmrcRegistry(projectRoot: string): void {
fs.appendFileSync(npmrc, `${prefix}${REGISTRY_MARKER}\nregistry=${bridge}\n`);
}

function npmSupportsMinimumReleaseAgeExclude(packageManagerVersion: string | undefined): boolean {
const coerced = packageManagerVersion ? semver.coerce(packageManagerVersion)?.version : undefined;
return coerced !== undefined && semver.gte(coerced, '11.17.0');
}

function hasNpmMinimumReleaseAge(projectRoot: string): boolean {
const npmrc = path.join(projectRoot, '.npmrc');
if (!fs.existsSync(npmrc)) {
return false;
}
return /^\s*min-release-age\s*=/m.test(fs.readFileSync(npmrc, 'utf8'));
}

function ensureNpmMinimumReleaseAgeExcludes(
projectRoot: string,
packageManagerVersion: string | undefined,
): boolean {
if (
!npmSupportsMinimumReleaseAgeExclude(packageManagerVersion) ||
!hasNpmMinimumReleaseAge(projectRoot)
) {
return false;
}
const npmrc = path.join(projectRoot, '.npmrc');
const original = fs.readFileSync(npmrc, 'utf8');
const existing = new Set(
[...original.matchAll(/^\s*min-release-age-exclude(?:\[\])?\s*=\s*(.+?)\s*$/gm)].map((match) =>
match[1].replace(/^(['"])(.*)\1$/, '$2'),
),
);
const missing = MANAGED_AGE_GATE_EXCLUDES.filter((name) => !existing.has(name));
if (missing.length === 0) {
return false;
}
const prefix = original.length > 0 && !original.endsWith('\n') ? '\n' : '';
fs.appendFileSync(
npmrc,
`${prefix}${missing.map((name) => `min-release-age-exclude[]=${name}`).join('\n')}\n`,
);
return true;
}

function ensureBunMinimumReleaseAgeExcludes(projectRoot: string): boolean {
const bunfigPath = path.join(projectRoot, 'bunfig.toml');
if (!fs.existsSync(bunfigPath)) {
return false;
}
const original = fs.readFileSync(bunfigPath, 'utf8');
const installHeader = /^\s*\[install\]\s*(?:#.*)?$/m.exec(original);
if (!installHeader) {
return false;
}
const sectionStart = installHeader.index + installHeader[0].length;
const nextSection = /^\s*\[[^\]]+\]\s*(?:#.*)?$/m.exec(original.slice(sectionStart));
const sectionEnd = nextSection ? sectionStart + nextSection.index : original.length;
const section = original.slice(sectionStart, sectionEnd);
const ageMatch = /^([ \t]*)minimumReleaseAge[ \t]*=.*$/m.exec(section);
if (!ageMatch) {
return false;
}

const excludesRe = /^([ \t]*minimumReleaseAgeExcludes[ \t]*=[ \t]*)\[([\s\S]*?)\]([^\r\n]*)$/m;
const excludesMatch = excludesRe.exec(section);
const existing = new Set<string>();
if (excludesMatch) {
for (const match of excludesMatch[2].matchAll(/(['"])(.*?)\1/g)) {
existing.add(match[2]);
}
}
const missing = MANAGED_AGE_GATE_EXCLUDES.filter((name) => !existing.has(name));
if (missing.length === 0) {
return false;
}

let nextSectionContent: string;
if (excludesMatch) {
const body = excludesMatch[2];
const trailingWhitespace = body.match(/\s*$/)?.[0] ?? '';
const content = body.slice(0, body.length - trailingWhitespace.length);
const separator = content.trim().length === 0 ? '' : content.trimEnd().endsWith(',') ? '' : ',';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve commas before appending to commented TOML arrays

For Bun projects whose existing minimumReleaseAgeExcludes is a multiline array with an inline comment on the last item and no trailing comma, for example "@demo/*" # keep local, this computes a comma but appends it after the comment text. TOML treats that comma as part of the comment, so the next inserted package is not comma-separated and the rewritten bunfig.toml becomes invalid; insert the separator before any inline comment or parse the TOML instead of splicing the string.

Useful? React with 👍 / 👎.

const addition = missing.map((name) => JSON.stringify(name)).join(', ');
const spacing = content.includes('\n') ? `\n${ageMatch[1]}` : content.length > 0 ? ' ' : '';
const replacement = `${excludesMatch[1]}[${content}${separator}${spacing}${addition}${trailingWhitespace}]${excludesMatch[3]}`;
nextSectionContent = section.replace(excludesRe, replacement);
} else {
const insertionAt = (ageMatch.index ?? 0) + ageMatch[0].length;
const insertion = `\n${ageMatch[1]}minimumReleaseAgeExcludes = [${MANAGED_AGE_GATE_EXCLUDES.map((name) => JSON.stringify(name)).join(', ')}]`;
nextSectionContent = section.slice(0, insertionAt) + insertion + section.slice(insertionAt);
}
fs.writeFileSync(
bunfigPath,
original.slice(0, sectionStart) + nextSectionContent + original.slice(sectionEnd),
);
return true;
}

function reconcilePnpmPreviewTrustExcludes(projectRoot: string, version: string): boolean {
const workspacePath = path.join(projectRoot, 'pnpm-workspace.yaml');
if (!fs.existsSync(workspacePath)) {
return false;
}
let changed = false;
editYamlFile(workspacePath, (doc) => {
const current = doc.get('trustPolicyExclude');
let trustPolicyExclude: YAMLSeq<Scalar<string>>;
if (current instanceof YAMLSeq) {
trustPolicyExclude = current as YAMLSeq<Scalar<string>>;
} else {
trustPolicyExclude = new YAMLSeq<Scalar<string>>();
}

if (isPreviewVitePlusVersion(version) && doc.get('trustPolicy') === 'no-downgrade') {
const existing = new Set(trustPolicyExclude.items.map((item) => item.value));
for (const packageName of PREVIEW_TRUST_PACKAGES) {
const selector = `${packageName}@${version}`;
if (!existing.has(selector)) {
trustPolicyExclude.add(scalarString(selector));
changed = true;
}
}
if (changed || !(current instanceof YAMLSeq)) {
doc.set('trustPolicyExclude', trustPolicyExclude);
}
return;
}

const kept = trustPolicyExclude.items.filter(
(item) => !PREVIEW_TRUST_SELECTOR_RE.test(item.value),
);
if (kept.length === trustPolicyExclude.items.length) {
return;
}
changed = true;
if (kept.length === 0) {
doc.delete('trustPolicyExclude');
} else {
trustPolicyExclude.items = kept;
doc.set('trustPolicyExclude', trustPolicyExclude);
}
});
return changed;
}

function reconcileManagedInstallPolicy(
projectRoot: string,
version: string,
packageManager: PackageManager | undefined,
packageManagerVersion: string | undefined,
): boolean {
switch (packageManager) {
case PackageManager.npm:
return ensureNpmMinimumReleaseAgeExcludes(projectRoot, packageManagerVersion);
case PackageManager.bun:
return ensureBunMinimumReleaseAgeExcludes(projectRoot);
case PackageManager.pnpm:
return reconcilePnpmPreviewTrustExcludes(projectRoot, version);
default:
return false;
}
}

// Comment attached to the bridge `npmRegistryServer` value so a later
// real-release run can restore the user's original registry instead of
// deleting it. Comments survive the YAML round-trip.
Expand Down Expand Up @@ -192,7 +389,14 @@ export function reconcilePreviewBridgeRegistry(
projectRoot: string,
version: string = process.env.VP_VERSION || VITE_PLUS_VERSION,
packageManager?: PackageManager,
packageManagerVersion?: string,
): boolean {
const installPolicyChanged = reconcileManagedInstallPolicy(
projectRoot,
version,
packageManager,
packageManagerVersion,
);
if (isPreviewVitePlusVersion(version)) {
// Write the file the ACTIVE package manager reads: Yarn Berry uses
// `.yarnrc.yml`, everything else uses `.npmrc`. Fall back to file-based
Expand All @@ -214,5 +418,27 @@ export function reconcilePreviewBridgeRegistry(
// files in case the project switched package managers since).
const clearedNpmrc = clearNpmrcRegistry(projectRoot);
const clearedYarnrc = clearYarnBerryRegistry(projectRoot);
return clearedNpmrc || clearedYarnrc;
return installPolicyChanged || clearedNpmrc || clearedYarnrc;
}

/**
* npm only gained package-level `min-release-age` exclusions in 11.17. For an
* older npm migration, relax that one policy in the child install
* environment instead of persisting an unsupported `.npmrc` key. The project
* setting remains unchanged for every install outside this explicit migration.
*/
export function getManagedInstallEnv(
projectRoot: string,
packageManager: PackageManager | undefined,
packageManagerVersion: string | undefined,
): NodeJS.ProcessEnv {
const envs = { ...process.env };
if (
packageManager === PackageManager.npm &&
hasNpmMinimumReleaseAge(projectRoot) &&
!npmSupportsMinimumReleaseAgeExclude(packageManagerVersion)
) {
envs.NPM_CONFIG_MIN_RELEASE_AGE = '0';
}
return envs;
}
Loading
Loading