Skip to content

Commit 44e817f

Browse files
authored
Merge branch 'main' into chore/docs-trusted-stack-stats
2 parents 6028bcd + 183f8e7 commit 44e817f

9 files changed

Lines changed: 224 additions & 22 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
npx lint-staged
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "migration-husky-catalog-version",
3+
"scripts": {
4+
"prepare": "husky"
5+
},
6+
"devDependencies": {
7+
"husky": "catalog:",
8+
"lint-staged": "catalog:",
9+
"vite": "catalog:"
10+
},
11+
"lint-staged": {
12+
"*.js": "oxlint --fix"
13+
}
14+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
packages:
2+
- .
3+
4+
catalog:
5+
husky: ^9.1.7
6+
lint-staged: ^16.2.6
7+
vite: ^7.0.0
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
> git init
2+
> vp migrate --no-interactive # should resolve husky version from catalog and configure hooks without warning
3+
4+
✔ Created vite.config.ts in vite.config.ts
5+
6+
✔ Merged staged config into vite.config.ts
7+
◇ Migrated . to Vite+
8+
• Node <semver> pnpm <semver>
9+
• Git hooks configured
10+
11+
> cat package.json # husky and lint-staged should be removed, prepare rewritten to vp config
12+
{
13+
"name": "migration-husky-catalog-version",
14+
"scripts": {
15+
"prepare": "vp config"
16+
},
17+
"devDependencies": {
18+
"vite": "catalog:",
19+
"vite-plus": "catalog:"
20+
},
21+
"packageManager": "pnpm@<semver>"
22+
}
23+
24+
> cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog
25+
packages:
26+
- .
27+
28+
catalog:
29+
husky: ^9.1.7
30+
lint-staged: ^16.2.6
31+
vite: npm:@voidzero-dev/vite-plus-core@latest
32+
vitest: npm:@voidzero-dev/vite-plus-test@latest
33+
vite-plus: latest
34+
overrides:
35+
vite: 'catalog:'
36+
vitest: 'catalog:'
37+
peerDependencyRules:
38+
allowAny:
39+
- vite
40+
- vitest
41+
allowedVersions:
42+
vite: '*'
43+
vitest: '*'
44+
45+
> cat vite.config.ts # check staged config migrated to vite.config.ts
46+
import { defineConfig } from 'vite-plus';
47+
48+
export default defineConfig({
49+
fmt: {},
50+
lint: {"jsPlugins":[{"name":"vite-plus","specifier":"vite-plus/oxlint-plugin"}],"rules":{"vite-plus/prefer-vite-plus-imports":"error"},"options":{"typeAware":true,"typeCheck":true}},
51+
staged: {
52+
"*.js": "vp lint --fix"
53+
},
54+
});
55+
56+
> cat .vite-hooks/pre-commit # check pre-commit hook rewritten
57+
vp staged
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"commands": [
3+
{
4+
"command": "git init",
5+
"ignoreOutput": true
6+
},
7+
"vp migrate --no-interactive # should resolve husky version from catalog and configure hooks without warning",
8+
"cat package.json # husky and lint-staged should be removed, prepare rewritten to vp config",
9+
"cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog",
10+
"cat vite.config.ts # check staged config migrated to vite.config.ts",
11+
"cat .vite-hooks/pre-commit # check pre-commit hook rewritten"
12+
]
13+
}

packages/cli/src/create/bin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -983,7 +983,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
983983
injectCreateDefaultTemplate(fullPath, bundled.scope, compactOutput);
984984
}
985985
if (shouldSetupHooks) {
986-
installGitHooks(fullPath, compactOutput);
986+
installGitHooks(fullPath, compactOutput, undefined, workspaceInfo.packageManager);
987987
}
988988
updateCreateProgress('Installing dependencies');
989989
const installSummary = await runViteInstall(fullPath, options.interactive, installArgs, {
@@ -1247,7 +1247,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
12471247
await initGitRepository(fullPath);
12481248
}
12491249
if (shouldSetupHooks) {
1250-
installGitHooks(fullPath, compactOutput);
1250+
installGitHooks(fullPath, compactOutput, undefined, workspaceInfo.packageManager);
12511251
}
12521252
updateCreateProgress('Installing dependencies');
12531253
installSummary = await runViteInstall(fullPath, options.interactive, installArgs, {

packages/cli/src/migration/__tests__/migrator.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const {
2929
injectCreateDefaultTemplate,
3030
rewriteEslintPackageJson,
3131
detectIncompatibleEslintIntegration,
32+
preflightGitHooksSetup,
3233
} = await import('../migrator.js');
3334

3435
describe('rewritePackageJson', () => {
@@ -1831,3 +1832,83 @@ export default defineConfig({
18311832
expect(fs.existsSync(path.join(tmpDir, '.oxfmtrc.jsonc'))).toBe(false);
18321833
});
18331834
});
1835+
1836+
describe('preflightGitHooksSetup husky catalog resolution', () => {
1837+
let tmpDir: string;
1838+
1839+
beforeEach(() => {
1840+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-husky-catalog-'));
1841+
// A `.git` dir at the project root so the subdirectory check passes.
1842+
fs.mkdirSync(path.join(tmpDir, '.git'));
1843+
});
1844+
1845+
afterEach(() => {
1846+
fs.rmSync(tmpDir, { recursive: true, force: true });
1847+
});
1848+
1849+
it('resolves a `catalog:` husky version from the pnpm catalog and allows hooks', () => {
1850+
fs.writeFileSync(
1851+
path.join(tmpDir, 'package.json'),
1852+
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
1853+
);
1854+
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^9.1.7\n');
1855+
1856+
expect(preflightGitHooksSetup(tmpDir, PackageManager.pnpm)).toBeNull();
1857+
});
1858+
1859+
it('resolves the explicit `catalog:default` alias from the top-level catalog', () => {
1860+
// pnpm reserves `default` for the top-level `catalog:` map, so `catalog:default`
1861+
// must resolve there rather than a named `catalogs.default` entry.
1862+
fs.writeFileSync(
1863+
path.join(tmpDir, 'package.json'),
1864+
JSON.stringify({
1865+
scripts: { prepare: 'husky' },
1866+
devDependencies: { husky: 'catalog:default' },
1867+
}),
1868+
);
1869+
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^9.1.7\n');
1870+
1871+
expect(preflightGitHooksSetup(tmpDir, PackageManager.pnpm)).toBeNull();
1872+
});
1873+
1874+
it('flags a `catalog:` husky version that resolves to <9 in the pnpm catalog', () => {
1875+
fs.writeFileSync(
1876+
path.join(tmpDir, 'package.json'),
1877+
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
1878+
);
1879+
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^8.0.0\n');
1880+
1881+
expect(preflightGitHooksSetup(tmpDir, PackageManager.pnpm)).toContain('husky <9.0.0');
1882+
});
1883+
1884+
it('does not read a foreign catalog: a yarn project ignores a leftover pnpm-workspace.yaml', () => {
1885+
// A `catalog:` spec is only meaningful to the active package manager, so a
1886+
// stray pnpm-workspace.yaml in a yarn repo must not satisfy husky's version.
1887+
fs.writeFileSync(
1888+
path.join(tmpDir, 'package.json'),
1889+
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
1890+
);
1891+
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^9.1.7\n');
1892+
1893+
// Yarn's catalog source (.yarnrc.yml) is absent, so husky stays unresolved
1894+
// and the preflight warns instead of trusting the pnpm catalog.
1895+
expect(preflightGitHooksSetup(tmpDir, PackageManager.yarn)).toContain(
1896+
'Could not determine husky version from "catalog:"',
1897+
);
1898+
});
1899+
1900+
it('uses the active package manager catalog over a foreign one', () => {
1901+
// Discriminating case: yarn's own catalog pins a compatible husky while a
1902+
// leftover pnpm-workspace.yaml pins an incompatible one. Reading yarn's
1903+
// catalog returns null (allowed); wrongly reading pnpm's would warn about
1904+
// husky <9, and broken resolution would warn "Could not determine".
1905+
fs.writeFileSync(
1906+
path.join(tmpDir, 'package.json'),
1907+
JSON.stringify({ scripts: { prepare: 'husky' }, devDependencies: { husky: 'catalog:' } }),
1908+
);
1909+
fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'catalog:\n husky: ^9.1.7\n');
1910+
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'catalog:\n husky: ^8.0.0\n');
1911+
1912+
expect(preflightGitHooksSetup(tmpDir, PackageManager.yarn)).toBeNull();
1913+
});
1914+
});

packages/cli/src/migration/bin.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ async function collectMigrationPlan(
354354
// 2. Git hooks (including preflight check)
355355
let shouldSetupHooks = await promptGitHooks(options);
356356
if (shouldSetupHooks) {
357-
const reason = preflightGitHooksSetup(rootDir);
357+
const reason = preflightGitHooksSetup(rootDir, packageManager);
358358
if (reason) {
359359
prompts.log.warn(`⚠ ${reason}`);
360360
shouldSetupHooks = false;
@@ -817,7 +817,7 @@ async function executeMigrationPlan(
817817
// 8. Install git hooks
818818
if (plan.shouldSetupHooks) {
819819
updateMigrationProgress('Configuring git hooks');
820-
installGitHooks(workspaceInfo.rootDir, true, report);
820+
installGitHooks(workspaceInfo.rootDir, true, report, plan.packageManager);
821821
}
822822

823823
// 9. Write agent instructions (using pre-resolved decisions)
@@ -1032,7 +1032,15 @@ async function main() {
10321032
if (shouldSetupHooks) {
10331033
updateMigrationProgress('Configuring git hooks');
10341034
}
1035-
if (shouldSetupHooks && installGitHooks(workspaceInfoOptional.rootDir, true, report)) {
1035+
if (
1036+
shouldSetupHooks &&
1037+
installGitHooks(
1038+
workspaceInfoOptional.rootDir,
1039+
true,
1040+
report,
1041+
workspaceInfoOptional.packageManager,
1042+
)
1043+
) {
10361044
didMigrate = true;
10371045
}
10381046
}

packages/cli/src/migration/migrator.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,16 +1589,13 @@ function createCatalogDependencyResolver(
15891589
};
15901590
const workspacesObj =
15911591
pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined;
1592-
return (catalogSpec, dependencyName) => {
1593-
const catalogName = catalogSpec.slice('catalog:'.length);
1594-
if (catalogName) {
1595-
return (
1596-
workspacesObj?.catalogs?.[catalogName]?.[dependencyName] ??
1597-
pkg.catalogs?.[catalogName]?.[dependencyName]
1598-
);
1599-
}
1600-
return workspacesObj?.catalog?.[dependencyName] ?? pkg.catalog?.[dependencyName];
1601-
};
1592+
const fromWorkspaces = createCatalogDependencyResolverFromCatalogs(
1593+
workspacesObj?.catalog,
1594+
workspacesObj?.catalogs,
1595+
);
1596+
const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs);
1597+
return (catalogSpec, dependencyName) =>
1598+
fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName);
16021599
}
16031600
return undefined;
16041601
}
@@ -1609,7 +1606,9 @@ function createCatalogDependencyResolverFromCatalogs(
16091606
): CatalogDependencyResolver {
16101607
return (catalogSpec, dependencyName) => {
16111608
const catalogName = catalogSpec.slice('catalog:'.length);
1612-
if (catalogName) {
1609+
// pnpm/bun reserve `default` as the name of the top-level `catalog:` map,
1610+
// so `catalog:default` resolves there, not a named `catalogs` entry.
1611+
if (catalogName && catalogName !== 'default') {
16131612
return catalogs?.[catalogName]?.[dependencyName];
16141613
}
16151614
return catalog?.[dependencyName];
@@ -2795,21 +2794,35 @@ function rewriteAllImports(projectPath: string, silent = false, report?: Migrati
27952794
/**
27962795
* Check if the project has an unsupported husky version (<9.0.0).
27972796
* Uses `semver.coerce` to handle ranges like `^8.0.0` → `8.0.0`.
2798-
* When the specifier is not coercible (e.g. `"latest"`), falls back to
2799-
* the installed version in node_modules via `detectPackageMetadata`.
2797+
* When the specifier is a catalog reference (e.g. `"catalog:"`), resolves
2798+
* it from the active package manager's catalog first — a `catalog:` spec is
2799+
* only meaningful to the manager that owns the workspace, so we never read a
2800+
* leftover/foreign catalog file. When it is still not coercible (e.g.
2801+
* `"latest"`), falls back to the installed version in node_modules via
2802+
* `detectPackageMetadata`.
28002803
* Returns a reason string if hooks migration should be skipped, or null
28012804
* if husky is absent or compatible.
28022805
*/
28032806
function checkUnsupportedHuskyVersion(
28042807
projectPath: string,
28052808
deps: Record<string, string> | undefined,
28062809
prodDeps: Record<string, string> | undefined,
2810+
packageManager: PackageManager | undefined,
28072811
): string | null {
28082812
const huskyVersion = deps?.husky ?? prodDeps?.husky;
28092813
if (!huskyVersion) {
28102814
return null;
28112815
}
28122816
let coerced = semver.coerce(huskyVersion);
2817+
if (coerced == null && packageManager != null && huskyVersion.startsWith('catalog:')) {
2818+
const resolved = createCatalogDependencyResolver(projectPath, packageManager)?.(
2819+
huskyVersion,
2820+
'husky',
2821+
);
2822+
if (resolved) {
2823+
coerced = semver.coerce(resolved);
2824+
}
2825+
}
28132826
if (coerced == null) {
28142827
const installed = detectPackageMetadata(projectPath, 'husky');
28152828
if (installed) {
@@ -2881,9 +2894,10 @@ export function installGitHooks(
28812894
projectPath: string,
28822895
silent = false,
28832896
report?: MigrationReport,
2897+
packageManager?: PackageManager,
28842898
): boolean {
28852899
const oldHooksDir = getOldHooksDir(projectPath);
2886-
if (setupGitHooks(projectPath, oldHooksDir, silent, report)) {
2900+
if (setupGitHooks(projectPath, oldHooksDir, silent, report, packageManager)) {
28872901
rewritePrepareScript(projectPath);
28882902
return true;
28892903
}
@@ -2918,8 +2932,14 @@ export function getOldHooksDir(rootDir: string): string | undefined {
29182932
*
29192933
* These checks are deterministic and read-only — they do not modify
29202934
* the project in any way, making them safe to call before migration.
2935+
*
2936+
* `packageManager` is the project's detected manager; it scopes `catalog:`
2937+
* resolution to that manager's catalog so a foreign catalog file is ignored.
29212938
*/
2922-
export function preflightGitHooksSetup(projectPath: string): string | null {
2939+
export function preflightGitHooksSetup(
2940+
projectPath: string,
2941+
packageManager?: PackageManager,
2942+
): string | null {
29232943
const gitRoot = findGitRoot(projectPath);
29242944
if (gitRoot && path.resolve(projectPath) !== path.resolve(gitRoot)) {
29252945
return 'Subdirectory project detected — skipping git hooks setup. Configure hooks at the repository root.';
@@ -2936,7 +2956,7 @@ export function preflightGitHooksSetup(projectPath: string): string | null {
29362956
return `Detected ${tool} — skipping git hooks setup. Please configure git hooks manually.`;
29372957
}
29382958
}
2939-
const huskyReason = checkUnsupportedHuskyVersion(projectPath, deps, prodDeps);
2959+
const huskyReason = checkUnsupportedHuskyVersion(projectPath, deps, prodDeps, packageManager);
29402960
if (huskyReason) {
29412961
return huskyReason;
29422962
}
@@ -2956,8 +2976,9 @@ export function setupGitHooks(
29562976
oldHooksDir?: string,
29572977
silent = false,
29582978
report?: MigrationReport,
2979+
packageManager?: PackageManager,
29592980
): boolean {
2960-
const reason = preflightGitHooksSetup(projectPath);
2981+
const reason = preflightGitHooksSetup(projectPath, packageManager);
29612982
if (reason) {
29622983
warnMigration(reason, report);
29632984
return false;

0 commit comments

Comments
 (0)