Skip to content
Merged
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
@@ -0,0 +1 @@
npx lint-staged
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "migration-husky-catalog-version",
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "catalog:",
"lint-staged": "catalog:",
"vite": "catalog:"
},
"lint-staged": {
"*.js": "oxlint --fix"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
packages:
- .

catalog:
husky: ^9.1.7
lint-staged: ^16.2.6
vite: ^7.0.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
> git init
> vp migrate --no-interactive # should resolve husky version from catalog and configure hooks without warning

✔ Created vite.config.ts in vite.config.ts

✔ Merged staged config into vite.config.ts
◇ Migrated . to Vite+
• Node <semver> pnpm <semver>
• Git hooks configured

> cat package.json # husky and lint-staged should be removed, prepare rewritten to vp config
{
"name": "migration-husky-catalog-version",
"scripts": {
"prepare": "vp config"
},
"devDependencies": {
"vite": "catalog:",
"vite-plus": "catalog:"
},
"packageManager": "pnpm@<semver>"
}

> cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog
packages:
- .

catalog:
husky: ^9.1.7
lint-staged: ^16.2.6
vite: npm:@voidzero-dev/vite-plus-core@latest
vitest: npm:@voidzero-dev/vite-plus-test@latest
vite-plus: latest
overrides:
vite: 'catalog:'
vitest: 'catalog:'
peerDependencyRules:
allowAny:
- vite
- vitest
allowedVersions:
vite: '*'
vitest: '*'

> cat vite.config.ts # check staged config migrated to vite.config.ts
import { defineConfig } from 'vite-plus';

export default defineConfig({
fmt: {},
lint: {"jsPlugins":[{"name":"vite-plus","specifier":"vite-plus/oxlint-plugin"}],"rules":{"vite-plus/prefer-vite-plus-imports":"error"},"options":{"typeAware":true,"typeCheck":true}},
staged: {
"*.js": "vp lint --fix"
},
});

> cat .vite-hooks/pre-commit # check pre-commit hook rewritten
vp staged
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"commands": [
{
"command": "git init",
"ignoreOutput": true
},
"vp migrate --no-interactive # should resolve husky version from catalog and configure hooks without warning",
"cat package.json # husky and lint-staged should be removed, prepare rewritten to vp config",
"cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog",
"cat vite.config.ts # check staged config migrated to vite.config.ts",
"cat .vite-hooks/pre-commit # check pre-commit hook rewritten"
]
}
4 changes: 2 additions & 2 deletions packages/cli/src/create/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,7 +983,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
injectCreateDefaultTemplate(fullPath, bundled.scope, compactOutput);
}
if (shouldSetupHooks) {
installGitHooks(fullPath, compactOutput);
installGitHooks(fullPath, compactOutput, undefined, workspaceInfo.packageManager);
}
updateCreateProgress('Installing dependencies');
const installSummary = await runViteInstall(fullPath, options.interactive, installArgs, {
Expand Down Expand Up @@ -1247,7 +1247,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
await initGitRepository(fullPath);
}
if (shouldSetupHooks) {
installGitHooks(fullPath, compactOutput);
installGitHooks(fullPath, compactOutput, undefined, workspaceInfo.packageManager);
}
updateCreateProgress('Installing dependencies');
installSummary = await runViteInstall(fullPath, options.interactive, installArgs, {
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/migration/__tests__/migrator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const {
injectCreateDefaultTemplate,
rewriteEslintPackageJson,
detectIncompatibleEslintIntegration,
preflightGitHooksSetup,
} = await import('../migrator.js');

describe('rewritePackageJson', () => {
Expand Down Expand Up @@ -1831,3 +1832,83 @@ export default defineConfig({
expect(fs.existsSync(path.join(tmpDir, '.oxfmtrc.jsonc'))).toBe(false);
});
});

describe('preflightGitHooksSetup husky catalog resolution', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-husky-catalog-'));
// A `.git` dir at the project root so the subdirectory check passes.
fs.mkdirSync(path.join(tmpDir, '.git'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('resolves a `catalog:` husky version from the pnpm catalog and allows hooks', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
);
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^9.1.7\n');

expect(preflightGitHooksSetup(tmpDir, PackageManager.pnpm)).toBeNull();
});

it('resolves the explicit `catalog:default` alias from the top-level catalog', () => {
// pnpm reserves `default` for the top-level `catalog:` map, so `catalog:default`
// must resolve there rather than a named `catalogs.default` entry.
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({
scripts: { prepare: 'husky' },
devDependencies: { husky: 'catalog:default' },
}),
);
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^9.1.7\n');

expect(preflightGitHooksSetup(tmpDir, PackageManager.pnpm)).toBeNull();
});

it('flags a `catalog:` husky version that resolves to <9 in the pnpm catalog', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
);
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^8.0.0\n');

expect(preflightGitHooksSetup(tmpDir, PackageManager.pnpm)).toContain('husky <9.0.0');
});

it('does not read a foreign catalog: a yarn project ignores a leftover pnpm-workspace.yaml', () => {
// A `catalog:` spec is only meaningful to the active package manager, so a
// stray pnpm-workspace.yaml in a yarn repo must not satisfy husky's version.
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
);
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^9.1.7\n');

// Yarn's catalog source (.yarnrc.yml) is absent, so husky stays unresolved
// and the preflight warns instead of trusting the pnpm catalog.
expect(preflightGitHooksSetup(tmpDir, PackageManager.yarn)).toContain(
'Could not determine husky version from "catalog:"',
);
});

it('uses the active package manager catalog over a foreign one', () => {
// Discriminating case: yarn's own catalog pins a compatible husky while a
// leftover pnpm-workspace.yaml pins an incompatible one. Reading yarn's
// catalog returns null (allowed); wrongly reading pnpm's would warn about
// husky <9, and broken resolution would warn "Could not determine".
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
);
fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'catalog:\n husky: ^9.1.7\n');
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^8.0.0\n');

expect(preflightGitHooksSetup(tmpDir, PackageManager.yarn)).toBeNull();
});
});
14 changes: 11 additions & 3 deletions packages/cli/src/migration/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ async function collectMigrationPlan(
// 2. Git hooks (including preflight check)
let shouldSetupHooks = await promptGitHooks(options);
if (shouldSetupHooks) {
const reason = preflightGitHooksSetup(rootDir);
const reason = preflightGitHooksSetup(rootDir, packageManager);
if (reason) {
prompts.log.warn(`⚠ ${reason}`);
shouldSetupHooks = false;
Expand Down Expand Up @@ -817,7 +817,7 @@ async function executeMigrationPlan(
// 8. Install git hooks
if (plan.shouldSetupHooks) {
updateMigrationProgress('Configuring git hooks');
installGitHooks(workspaceInfo.rootDir, true, report);
installGitHooks(workspaceInfo.rootDir, true, report, plan.packageManager);
}

// 9. Write agent instructions (using pre-resolved decisions)
Expand Down Expand Up @@ -1032,7 +1032,15 @@ async function main() {
if (shouldSetupHooks) {
updateMigrationProgress('Configuring git hooks');
}
if (shouldSetupHooks && installGitHooks(workspaceInfoOptional.rootDir, true, report)) {
if (
shouldSetupHooks &&
installGitHooks(
workspaceInfoOptional.rootDir,
true,
report,
workspaceInfoOptional.packageManager,
)
) {
didMigrate = true;
}
}
Expand Down
55 changes: 38 additions & 17 deletions packages/cli/src/migration/migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1589,16 +1589,13 @@ function createCatalogDependencyResolver(
};
const workspacesObj =
pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined;
return (catalogSpec, dependencyName) => {
const catalogName = catalogSpec.slice('catalog:'.length);
if (catalogName) {
return (
workspacesObj?.catalogs?.[catalogName]?.[dependencyName] ??
pkg.catalogs?.[catalogName]?.[dependencyName]
);
}
return workspacesObj?.catalog?.[dependencyName] ?? pkg.catalog?.[dependencyName];
};
const fromWorkspaces = createCatalogDependencyResolverFromCatalogs(
workspacesObj?.catalog,
workspacesObj?.catalogs,
);
const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs);
return (catalogSpec, dependencyName) =>
fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName);
}
return undefined;
}
Expand All @@ -1609,7 +1606,9 @@ function createCatalogDependencyResolverFromCatalogs(
): CatalogDependencyResolver {
return (catalogSpec, dependencyName) => {
const catalogName = catalogSpec.slice('catalog:'.length);
if (catalogName) {
// pnpm/bun reserve `default` as the name of the top-level `catalog:` map,
// so `catalog:default` resolves there, not a named `catalogs` entry.
if (catalogName && catalogName !== 'default') {
return catalogs?.[catalogName]?.[dependencyName];
}
return catalog?.[dependencyName];
Expand Down Expand Up @@ -2795,21 +2794,35 @@ function rewriteAllImports(projectPath: string, silent = false, report?: Migrati
/**
* Check if the project has an unsupported husky version (<9.0.0).
* Uses `semver.coerce` to handle ranges like `^8.0.0` → `8.0.0`.
* When the specifier is not coercible (e.g. `"latest"`), falls back to
* the installed version in node_modules via `detectPackageMetadata`.
* When the specifier is a catalog reference (e.g. `"catalog:"`), resolves
* it from the active package manager's catalog first — a `catalog:` spec is
* only meaningful to the manager that owns the workspace, so we never read a
* leftover/foreign catalog file. When it is still not coercible (e.g.
* `"latest"`), falls back to the installed version in node_modules via
* `detectPackageMetadata`.
* Returns a reason string if hooks migration should be skipped, or null
* if husky is absent or compatible.
*/
function checkUnsupportedHuskyVersion(
projectPath: string,
deps: Record<string, string> | undefined,
prodDeps: Record<string, string> | undefined,
packageManager: PackageManager | undefined,
): string | null {
const huskyVersion = deps?.husky ?? prodDeps?.husky;
if (!huskyVersion) {
return null;
}
let coerced = semver.coerce(huskyVersion);
if (coerced == null && packageManager != null && huskyVersion.startsWith('catalog:')) {
const resolved = createCatalogDependencyResolver(projectPath, packageManager)?.(
huskyVersion,
'husky',
);
if (resolved) {
coerced = semver.coerce(resolved);
}
}
if (coerced == null) {
const installed = detectPackageMetadata(projectPath, 'husky');
if (installed) {
Expand Down Expand Up @@ -2881,9 +2894,10 @@ export function installGitHooks(
projectPath: string,
silent = false,
report?: MigrationReport,
packageManager?: PackageManager,
): boolean {
const oldHooksDir = getOldHooksDir(projectPath);
if (setupGitHooks(projectPath, oldHooksDir, silent, report)) {
if (setupGitHooks(projectPath, oldHooksDir, silent, report, packageManager)) {
rewritePrepareScript(projectPath);
return true;
}
Expand Down Expand Up @@ -2918,8 +2932,14 @@ export function getOldHooksDir(rootDir: string): string | undefined {
*
* These checks are deterministic and read-only — they do not modify
* the project in any way, making them safe to call before migration.
*
* `packageManager` is the project's detected manager; it scopes `catalog:`
* resolution to that manager's catalog so a foreign catalog file is ignored.
*/
export function preflightGitHooksSetup(projectPath: string): string | null {
export function preflightGitHooksSetup(
projectPath: string,
packageManager?: PackageManager,
): string | null {
const gitRoot = findGitRoot(projectPath);
if (gitRoot && path.resolve(projectPath) !== path.resolve(gitRoot)) {
return 'Subdirectory project detected — skipping git hooks setup. Configure hooks at the repository root.';
Expand All @@ -2936,7 +2956,7 @@ export function preflightGitHooksSetup(projectPath: string): string | null {
return `Detected ${tool} — skipping git hooks setup. Please configure git hooks manually.`;
}
}
const huskyReason = checkUnsupportedHuskyVersion(projectPath, deps, prodDeps);
const huskyReason = checkUnsupportedHuskyVersion(projectPath, deps, prodDeps, packageManager);
if (huskyReason) {
return huskyReason;
}
Expand All @@ -2956,8 +2976,9 @@ export function setupGitHooks(
oldHooksDir?: string,
silent = false,
report?: MigrationReport,
packageManager?: PackageManager,
): boolean {
const reason = preflightGitHooksSetup(projectPath);
const reason = preflightGitHooksSetup(projectPath, packageManager);
if (reason) {
warnMigration(reason, report);
return false;
Expand Down
Loading