From f260d99e256101475d727671569f3e8e62f615cd Mon Sep 17 00:00:00 2001 From: KTrain <69028025+KTrain5169@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:59:12 +0000 Subject: [PATCH 01/20] start --- packages/cli/src/migration/migrator/tsdown.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/cli/src/migration/migrator/tsdown.ts diff --git a/packages/cli/src/migration/migrator/tsdown.ts b/packages/cli/src/migration/migrator/tsdown.ts new file mode 100644 index 0000000000..103789ba98 --- /dev/null +++ b/packages/cli/src/migration/migrator/tsdown.ts @@ -0,0 +1,4 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { type UserConfig } from 'tsdown'; From c2ed14b7e05837378e270a1f27abe7dc1649c8a3 Mon Sep 17 00:00:00 2001 From: KTrain <69028025+KTrain5169@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:34:37 +0000 Subject: [PATCH 02/20] turns out it was there already --- packages/cli/src/migration/migrator/{tsdown.ts => tsup.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/cli/src/migration/migrator/{tsdown.ts => tsup.ts} (100%) diff --git a/packages/cli/src/migration/migrator/tsdown.ts b/packages/cli/src/migration/migrator/tsup.ts similarity index 100% rename from packages/cli/src/migration/migrator/tsdown.ts rename to packages/cli/src/migration/migrator/tsup.ts From 945e719dbb715a90bab14a69086a7c063d883f9e Mon Sep 17 00:00:00 2001 From: KTrain <69028025+KTrain5169@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:34:50 +0000 Subject: [PATCH 03/20] add WIP tsup migration flow --- packages/cli/rules/vite-tools.yml | 9 + packages/cli/src/create/bin.ts | 7 +- packages/cli/src/migration/bin.ts | 51 +++- packages/cli/src/migration/detector.ts | 31 +++ packages/cli/src/migration/migrator.ts | 1 + packages/cli/src/migration/migrator/README.md | 1 + packages/cli/src/migration/migrator/tsup.ts | 260 +++++++++++++++++- packages/cli/src/migration/report.ts | 2 + packages/cli/src/migration/setup-plan.ts | 25 ++ 9 files changed, 384 insertions(+), 3 deletions(-) diff --git a/packages/cli/rules/vite-tools.yml b/packages/cli/rules/vite-tools.yml index 7e438987fc..2b02711e7a 100644 --- a/packages/cli/rules/vite-tools.yml +++ b/packages/cli/rules/vite-tools.yml @@ -84,3 +84,12 @@ rule: kind: command_name regex: '^tsdown$' fix: vp pack + +# tsup => vp pack +--- +id: replace-tsup +language: bash +rule: + kind: command_name + regex: '^tsup$' +fix: vp pack diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 661a76b463..4895d9ccf6 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -11,11 +11,13 @@ import { detectEslintProject, detectFramework, detectPrettierProject, + detectTsupProject, hasFrameworkShim, injectCreateDefaultTemplate, installGitHooks, promptEslintMigration, promptPrettierMigration, + promptTsupMigration, rewriteMonorepo, rewriteMonorepoProject, rewriteStandaloneProject, @@ -1253,7 +1255,9 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // and relies on `rewrite*Project` to add tarball overrides BEFORE the // first install, so install-first would break CI's local-tarball resolve. const shouldMigrateLintFmtTools = - detectEslintProject(fullPath).hasDependency || detectPrettierProject(fullPath).hasDependency; + detectEslintProject(fullPath).hasDependency || + detectPrettierProject(fullPath).hasDependency || + detectTsupProject(fullPath).hasDependency; let installSummary: CommandRunSummary | undefined; @@ -1297,6 +1301,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h pauseCreateProgress(); await promptEslintMigration(fullPath, /* interactive */ false); await promptPrettierMigration(fullPath, /* interactive */ false); + await promptTsupMigration(fullPath, /* interactive */ false); resumeCreateProgress(); }; diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 75a1b5148c..8c9caa5e5b 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -47,6 +47,7 @@ import { detectNodeVersionManagerFile, detectPendingCoreMigration, detectPrettierProject, + detectTsupProject, detectVitePlusBootstrapPending, detectYarnPnpMode, ensureVitePlusBootstrap, @@ -55,10 +56,12 @@ import { detectLegacyGitHooksMigrationCandidate, injectLintTypeCheckDefaults, installGitHooks, + mergeTsdownConfigFile, mergeViteConfigFiles, migrateEslintToOxlint, migrateNodeVersionManagerFile, migratePrettierToOxfmt, + migrateTsupToTsdown, configureYarnNodeModulesMode, rewriteMonorepo, rewriteStandaloneProject, @@ -467,12 +470,14 @@ function hasExistingVitePlusMigrationCandidates( ): boolean { const eslintProject = detectEslintProject(workspaceInfo.rootDir, workspaceInfo.packages); const prettierProject = detectPrettierProject(workspaceInfo.rootDir, workspaceInfo.packages); + const tsupProject = detectTsupProject(workspaceInfo.rootDir, workspaceInfo.packages); return ( hasExplicitExistingVitePlusSetupRequest(options) || detectLegacyGitHooksMigrationCandidate(workspaceInfo.rootDir) || hasBaseUrlInWorkspace(workspaceInfo) || eslintProject.hasDependency || prettierProject.hasDependency || + tsupProject.hasDependency || detectNodeVersionManagerFile(workspaceInfo.rootDir) !== undefined || getFrameworkShimCandidates(workspaceInfo.rootDir, workspaceInfo.packages).length > 0 ); @@ -691,6 +696,9 @@ function showMigrationSummary(options: { if (report.prettierMigrated) { log(`${styleText('gray', '•')} Prettier migrated to Oxfmt`); } + if (report.tsupMigrated) { + log(`${styleText('gray', '•')} tsup config migrated to tsdown (\`vp pack\`)`); + } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); } @@ -905,6 +913,22 @@ async function executeMigrationPlan( } } + // 6b. tsup → tsdown migration (before main rewrite so tsdown.config.* gets picked up) + if (plan.migrateTsup) { + updateMigrationProgress('Migrating tsup'); + const tsupOk = await migrateTsupToTsdown( + workspaceInfo.rootDir, + interactive, + plan.tsupConfigFile, + workspaceInfo.packages, + { silent: true, report }, + ); + if (!tsupOk) { + failMigrationProgress('Migration failed'); + cancelAndExit('tsup migration failed. Fix the issue and re-run `vp migrate`.', 1); + } + } + // Preserve lint-staged whenever hook setup is disabled/unsafe or existing // project-owned hooks remain authoritative. const skipStagedMigration = shouldSkipStagedMigrationForHooks( @@ -1307,6 +1331,24 @@ async function main() { eslintMigrated = true; } + let tsupMigrated = false; + if (plan.migrateTsup) { + await ensureExistingPackageManager(); + updateMigrationProgress('Migrating tsup'); + const tsupOk = await migrateTsupToTsdown( + workspaceInfoOptional.rootDir, + options.interactive, + plan.tsupConfigFile, + workspaceInfoOptional.packages, + { silent: true, report }, + ); + if (!tsupOk) { + clearMigrationProgress(); + cancelAndExit('tsup migration failed. Fix the issue and re-run `vp migrate`.', 1); + } + tsupMigrated = true; + } + // Detect Prettier unconditionally so the formatting gate below skips Oxfmt on // a project that still uses Prettier, even on a bare (non-`--full`) upgrade // that rewrites imports/scripts. The Prettier MIGRATION itself stays in the @@ -1391,7 +1433,7 @@ async function main() { } // Merge configs and reinstall once if any tool or bootstrap migration happened - if (eslintMigrated || prettierMigrated) { + if (eslintMigrated || prettierMigrated || tsupMigrated) { updateMigrationProgress('Rewriting configs'); mergeViteConfigFiles( workspaceInfoOptional.rootDir, @@ -1399,10 +1441,17 @@ async function main() { report, workspaceInfoOptional.packages, ); + if (tsupMigrated) { + mergeTsdownConfigFile(workspaceInfoOptional.rootDir, true, report); + for (const pkg of workspaceInfoOptional.packages ?? []) { + mergeTsdownConfigFile(path.join(workspaceInfoOptional.rootDir, pkg.path), true, report); + } + } needsInstall = true; didMigrate = true; report.eslintMigrated = eslintMigrated; report.prettierMigrated = prettierMigrated; + report.tsupMigrated = tsupMigrated; } if (plan.shouldSetupHooks) { diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index fd4065cd86..aa8bcf4513 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -7,6 +7,7 @@ export interface ConfigFiles { viteConfig?: string; vitestConfig?: string; tsdownConfig?: string; + tsupConfig?: string; oxlintConfig?: string; oxfmtConfig?: string; eslintConfig?: string; @@ -20,6 +21,21 @@ export interface ConfigFiles { // Sentinel value indicating Prettier config lives inside package.json "prettier" key. export const PRETTIER_PACKAGE_JSON_CONFIG = 'package.json#prettier'; +// Sentinel value indicating tsup config lives inside package.json "tsup" key. +export const TSUP_PACKAGE_JSON_CONFIG = 'package.json#tsup'; + +// All known tsup config file names (standalone files only). +// https://tsup.egoist.dev/#using-a-config-file +export const TSUP_CONFIG_FILES = [ + 'tsup.config.ts', + 'tsup.config.mts', + 'tsup.config.cts', + 'tsup.config.js', + 'tsup.config.mjs', + 'tsup.config.cjs', + 'tsup.config.json', +] as const; + // All known Prettier config file names (standalone files only). // https://prettier.io/docs/configuration export const PRETTIER_CONFIG_FILES = [ @@ -90,6 +106,17 @@ export function detectConfigs(projectPath: string): ConfigFiles { } } + // Check for tsup.config.* (still detected even though tsdown supersedes it — + // `promptTsupMigration` / `detectTsupProject` use this to offer the tsup → + // tsdown migration). + // https://tsup.egoist.dev/#using-a-config-file + for (const config of TSUP_CONFIG_FILES) { + if (fs.existsSync(path.join(projectPath, config))) { + configs.tsupConfig = config; + break; + } + } + // Check for oxlint configs // https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format const oxlintConfigs = ['.oxlintrc.json', '.oxlintrc.jsonc']; @@ -172,6 +199,10 @@ export function detectConfigs(projectPath: string): ConfigFiles { configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG; } + if (!configs.tsupConfig && pkg.tsup) { + configs.tsupConfig = TSUP_PACKAGE_JSON_CONFIG; + } + const voltaNode = pkg.volta?.node; if (typeof voltaNode === 'string') { configs.voltaNode = voltaNode; diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index fa4c23f13d..d5d5f122b7 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1,6 +1,7 @@ export * from './migrator/shared.ts'; export * from './migrator/eslint.ts'; export * from './migrator/prettier.ts'; +export * from './migrator/tsup.ts'; export * from './migrator/tsconfig.ts'; export * from './migrator/framework-shim.ts'; export * from './migrator/vitest-ecosystem.ts'; diff --git a/packages/cli/src/migration/migrator/README.md b/packages/cli/src/migration/migrator/README.md index 9b4cd71638..0c3aad0389 100644 --- a/packages/cli/src/migration/migrator/README.md +++ b/packages/cli/src/migration/migrator/README.md @@ -31,6 +31,7 @@ Pick the file by what a function _does_, not by where it happens to be called. | `vite-config.ts` | `vite.config.ts` merging, default-config injection, staged-config merge, lazy-plugin wrapping, import rewriting (`rewriteAllImports`), migrated-oxlint-config sanitization, lint-staged removal. | | `eslint.ts` | ESLint → Oxlint migration, oxlint JS-plugin namespace handling, ESLint prompts/warnings. | | `prettier.ts` | Prettier → Oxfmt migration and its prompts/warnings. | +| `tsup.ts` | tsup → tsdown (`vp pack`) migration via `tsdown-migrate` and its prompts/warnings. | | `tsconfig.ts` | `tsconfig.json` cleanup and `types` rewriting. | | `framework-shim.ts` | Framework (Vue/Astro) shim detection and injection. | | `git-hooks.ts` | Vite+ hook defaults, project-owned hook preservation, and conservative detection of existing hook tools. | diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 103789ba98..4fb6355ba3 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -1,4 +1,262 @@ import fs from 'node:fs'; import path from 'node:path'; +import { styleText } from 'node:util'; -import { type UserConfig } from 'tsdown'; +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { type WorkspacePackage } from '../../types/index.ts'; +import { runCommandSilently } from '../../utils/command.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; +import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; +import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; +import { type MigrationReport } from '../report.ts'; + +export function detectTsupProject( + projectPath: string, + packages?: WorkspacePackage[], +): { + hasDependency: boolean; + configFile?: string; +} { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return { hasDependency: false }; + } + const pkg = readJsonFile(packageJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + let hasDependency = !!(pkg.devDependencies?.tsup || pkg.dependencies?.tsup); + const configs = detectConfigs(projectPath); + const configFile = configs.tsupConfig; + + // If root doesn't have tsup dependency, check workspace packages + if (!hasDependency && packages) { + for (const wp of packages) { + const pkgJsonPath = path.join(projectPath, wp.path, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + const wpPkg = readJsonFile(pkgJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + if (wpPkg.devDependencies?.tsup || wpPkg.dependencies?.tsup) { + hasDependency = true; + break; + } + } + } + + return { hasDependency, configFile }; +} + +/** + * Run `vp dlx tsdown-migrate` in `cwd` with graceful error handling. + * Returns true on success, false on failure (spawn error or non-zero exit). + */ +async function runTsdownMigrateStep( + vpBin: string, + cwd: string, + spinner: ReturnType, + failMessage: string, + manualHint: string, +): Promise { + try { + const result = await runCommandSilently({ + command: vpBin, + args: ['dlx', 'tsdown-migrate'], + cwd, + envs: process.env, + }); + if (result.exitCode !== 0) { + spinner.stop(failMessage); + const stderr = result.stderr.toString().trim(); + if (stderr) { + prompts.log.warn(`⚠ ${stderr}`); + } + prompts.log.info(manualHint); + return false; + } + return true; + } catch { + spinner.stop(failMessage); + prompts.log.info(manualHint); + return false; + } +} + +export async function migrateTsupToTsdown( + projectPath: string, + interactive: boolean, + tsupConfigFile?: string, + packages?: WorkspacePackage[], + options?: { silent?: boolean; report?: MigrationReport }, +): Promise { + const vpBin = process.env.VP_CLI_BIN ?? 'vp'; + const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); + + // A tsup config isn't necessarily workspace-wide the way an ESLint flat + // config usually is — a monorepo commonly builds each package + // independently with its own `tsup.config.*`. Run `tsdown-migrate` in + // every directory that has one (root and/or workspace packages) so each + // gets its own `tsdown.config.*`, which `mergeTsdownConfigFile` then picks + // up per-project — mirroring how `tsdown.config.*` itself is merged. + const targets = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; + const tsupTargets = targets.filter((target) => + target === projectPath ? !!tsupConfigFile : !!detectConfigs(target).tsupConfig, + ); + + if (tsupTargets.length > 0) { + spinner.start('Migrating tsup config to tsdown...'); + for (const target of tsupTargets) { + const migrateOk = await runTsdownMigrateStep( + vpBin, + target, + spinner, + 'tsup migration failed', + `You can run \`vp dlx tsdown-migrate\` manually later in ${displayRelative(target)}`, + ); + if (!migrateOk) { + return false; + } + } + spinner.stop('tsup config migrated to tsdown.config'); + } + + if (options?.report) { + options.report.tsupMigrated = true; + } + + // Cleanup runs uniformly across the root and every workspace package — + // delete tsup config files and remove the `tsup` dependency from + // package.json. Mirrors the eslint/prettier cleanup pass. + for (const target of targets) { + if (!fs.existsSync(path.join(target, 'package.json'))) { + continue; + } + deleteTsupConfigFiles(target, options?.report, options?.silent); + rewriteTsupPackageJson(path.join(target, 'package.json')); + } + + return true; +} + +function deleteTsupConfigFiles(basePath: string, report?: MigrationReport, silent = false): void { + const configs = detectConfigs(basePath); + if (configs.tsupConfig && configs.tsupConfig !== TSUP_PACKAGE_JSON_CONFIG) { + const configPath = path.join(basePath, configs.tsupConfig); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); + } + } + } + // Also clean up any stale tsup config files that detectConfigs didn't pick + // (tsup only uses one config, but users may have leftover files). + for (const file of TSUP_CONFIG_FILES) { + if (file === configs.tsupConfig) { + continue; // already handled above + } + const configPath = path.join(basePath, file); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); + } + } + } + // Remove "tsup" key from package.json if present — `tsdown-migrate` + // reads it as a config source but never deletes it. + editJsonFile<{ tsup?: unknown }>(path.join(basePath, 'package.json'), (pkg) => { + if (pkg.tsup) { + delete pkg.tsup; + return pkg; + } + return undefined; + }); +} + +function rewriteTsupPackageJson(packageJsonPath: string): void { + if (!fs.existsSync(packageJsonPath)) { + return; + } + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + }>(packageJsonPath, (pkg) => { + let changed = false; + // Remove the tsup dependency itself. Scripts (`"build": "tsup"`) are + // already rewritten to `vp pack` generically by `rewriteScripts` (see + // `replace-tsup` in rules/vite-tools.yml), and `tsdown` is a managed + // vite-plus-bundled dependency (see `REMOVE_PACKAGES`), so neither needs + // handling here. + for (const field of ['devDependencies', 'dependencies'] as const) { + if (pkg[field]?.tsup) { + delete pkg[field].tsup; + changed = true; + } + } + return changed ? pkg : undefined; + }); +} + +export function warnPackageLevelTsup() { + prompts.log.warn( + 'tsup detected in workspace packages but no root config found. Package-level tsup must be migrated manually.', + ); +} + +export async function confirmTsupMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate tsup config to tsdown using tsdown-migrate?\n ' + + styleText( + 'gray', + "tsdown is Vite+'s built-in bundler (exposed via `vp pack`) — a drop-in tsup replacement powered by Rolldown. tsdown-migrate converts your existing config automatically.", + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + prompts.log.info('tsup configuration detected. Auto-migrating to tsdown...'); + return true; +} + +export async function promptTsupMigration( + projectPath: string, + interactive: boolean, + packages?: WorkspacePackage[], +): Promise { + const tsupProject = detectTsupProject(projectPath, packages); + if (!tsupProject.hasDependency) { + return false; + } + if (!tsupProject.configFile) { + // Packages have tsup but no root config → warn and skip + warnPackageLevelTsup(); + return false; + } + const confirmed = await confirmTsupMigration(interactive); + if (!confirmed) { + return false; + } + const ok = await migrateTsupToTsdown(projectPath, interactive, tsupProject.configFile, packages); + if (!ok) { + cancelAndExit('tsup migration failed.', 1); + } + return true; +} diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 78c7ef2461..4de0bc25a2 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -23,6 +23,7 @@ export interface MigrationReport { rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; prettierMigrated: boolean; + tsupMigrated: boolean; nodeVersionFileMigrated: boolean; gitHooksConfigured: boolean; frameworkShimAdded: boolean; @@ -45,6 +46,7 @@ export function createMigrationReport(): MigrationReport { preservedUpstreamVitestImportFileCount: 0, rewrittenImportErrors: [], eslintMigrated: false, + tsupMigrated: false, prettierMigrated: false, nodeVersionFileMigrated: false, gitHooksConfigured: false, diff --git a/packages/cli/src/migration/setup-plan.ts b/packages/cli/src/migration/setup-plan.ts index d6eb7a2645..450a4ef54c 100644 --- a/packages/cli/src/migration/setup-plan.ts +++ b/packages/cli/src/migration/setup-plan.ts @@ -17,12 +17,15 @@ import { import { cancelAndExit, promptGitHooks } from '../utils/prompts.ts'; import { confirmEslintMigration, + confirmTsupMigration, detectEslintProject, detectIncompatibleEslintIntegration, + detectTsupProject, preflightGitHooksSetup, warnIncompatibleEslintIntegration, warnLegacyEslintConfig, warnPackageLevelEslint, + warnPackageLevelTsup, } from './migrator.ts'; import type { MigrationOptions } from './options.ts'; @@ -34,6 +37,8 @@ export interface MigrationSetupPlan { editorConflictDecisions: Map; migrateEslint: boolean; eslintConfigFile?: string; + migrateTsup: boolean; + tsupConfigFile?: string; } async function collectGitHooksDecision( @@ -185,6 +190,22 @@ async function collectEslintMigrationDecision( return { migrateEslint, eslintConfigFile: eslintProject.configFile }; } +async function collectTsupMigrationDecision( + rootDir: string, + options: MigrationOptions, + packages?: WorkspacePackage[], +): Promise<{ migrateTsup: boolean; tsupConfigFile?: string }> { + const tsupProject = detectTsupProject(rootDir, packages); + let migrateTsup = false; + if (tsupProject.hasDependency && tsupProject.configFile) { + migrateTsup = await confirmTsupMigration(options.interactive); + } else if (tsupProject.hasDependency) { + warnPackageLevelTsup(); + } + + return { migrateTsup, tsupConfigFile: tsupProject.configFile }; +} + export async function collectMigrationSetupPlan( rootDir: string, packageManager: PackageManager | undefined, @@ -203,11 +224,15 @@ export async function collectMigrationSetupPlan( const eslintPlan = includeEslint ? await collectEslintMigrationDecision(rootDir, options, packages) : { migrateEslint: false }; + const tsupPlan = includeEslint + ? await collectTsupMigrationDecision(rootDir, options, packages) + : { migrateTsup: false }; return { shouldSetupHooks, ...agentPlan, ...editorPlan, ...eslintPlan, + ...tsupPlan, }; } From d613ff434aba1f994d42ee030d821dd0a1395522 Mon Sep 17 00:00:00 2001 From: KTrain <69028025+KTrain5169@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:41:55 +0000 Subject: [PATCH 04/20] fix: pin tsdown-migrate to RC tag for now + make tsup detection happen before prettier --- packages/cli/src/create/bin.ts | 4 +- packages/cli/src/migration/bin.ts | 67 +++++++++++++++------ packages/cli/src/migration/migrator/tsup.ts | 12 ++-- packages/cli/src/migration/setup-plan.ts | 11 ++-- 4 files changed, 62 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 4895d9ccf6..b631ff03c0 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -1297,11 +1297,11 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h if (installSummary.pendingBuilds && installSummary.pendingBuilds.length > 0) { migratePendingBuilds = installSummary.pendingBuilds; } - updateCreateProgress('Migrating lint and format tools'); + updateCreateProgress('Migrating lint, format & pack tools'); pauseCreateProgress(); await promptEslintMigration(fullPath, /* interactive */ false); await promptPrettierMigration(fullPath, /* interactive */ false); - await promptTsupMigration(fullPath, /* interactive */ false); + await promptTsupMigration(fullPath, /* interactive */ false, packageManager); resumeCreateProgress(); }; diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 8c9caa5e5b..7418f7bfca 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -73,7 +73,11 @@ import { import { prepareNpmViteAliasReinstall } from './npm-reinstall.ts'; import type { MigrationOptions } from './options.ts'; import { addMigrationWarning, createMigrationReport, type MigrationReport } from './report.ts'; -import { collectMigrationSetupPlan, type MigrationSetupPlan } from './setup-plan.ts'; +import { + collectMigrationSetupPlan, + collectTsupMigrationDecision, + type MigrationSetupPlan, +} from './setup-plan.ts'; async function confirmNodeVersionFileMigration( interactive: boolean, @@ -382,6 +386,8 @@ interface MigrationPlan extends MigrationSetupPlan { migratePrettier: boolean; hasPrettierDependency: boolean; prettierConfigFile?: string; + migrateTsup: boolean; + tsupConfigFile?: string; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; @@ -530,6 +536,13 @@ async function collectMigrationPlan( warnPackageLevelPrettier(); } + // 3b. tsup detection + prompt (after Prettier so Prettier -> Oxfmt is checked first) + const { migrateTsup, tsupConfigFile } = await collectTsupMigrationDecision( + rootDir, + options, + packages, + ); + // 9. tsconfig baseUrl prompt const fixBaseUrl = hasBaseUrlInWorkspace({ rootDir, packages }) ? await confirmBaseUrlFix(options.interactive) @@ -555,6 +568,8 @@ async function collectMigrationPlan( migratePrettier, hasPrettierDependency: prettierProject.hasDependency, prettierConfigFile: prettierProject.configFile, + migrateTsup, + tsupConfigFile, fixBaseUrl, migrateNodeVersionFile, nodeVersionDetection, @@ -919,6 +934,7 @@ async function executeMigrationPlan( const tsupOk = await migrateTsupToTsdown( workspaceInfo.rootDir, interactive, + plan.packageManager, plan.tsupConfigFile, workspaceInfo.packages, { silent: true, report }, @@ -1331,24 +1347,6 @@ async function main() { eslintMigrated = true; } - let tsupMigrated = false; - if (plan.migrateTsup) { - await ensureExistingPackageManager(); - updateMigrationProgress('Migrating tsup'); - const tsupOk = await migrateTsupToTsdown( - workspaceInfoOptional.rootDir, - options.interactive, - plan.tsupConfigFile, - workspaceInfoOptional.packages, - { silent: true, report }, - ); - if (!tsupOk) { - clearMigrationProgress(); - cancelAndExit('tsup migration failed. Fix the issue and re-run `vp migrate`.', 1); - } - tsupMigrated = true; - } - // Detect Prettier unconditionally so the formatting gate below skips Oxfmt on // a project that still uses Prettier, even on a bare (non-`--full`) upgrade // that rewrites imports/scripts. The Prettier MIGRATION itself stays in the @@ -1387,6 +1385,37 @@ async function main() { } } + let tsupMigrated = false; + if (fullSetup) { + // Interactive only: stop any active spinner (e.g. "Migrating Prettier") so + // it does not animate beneath the confirm prompt. + if (options.interactive) { + clearMigrationProgress(); + } + const { migrateTsup, tsupConfigFile } = await collectTsupMigrationDecision( + workspaceInfoOptional.rootDir, + setupOptions, + workspaceInfoOptional.packages, + ); + if (migrateTsup) { + await ensureExistingPackageManager(); + updateMigrationProgress('Migrating tsup'); + const tsupOk = await migrateTsupToTsdown( + workspaceInfoOptional.rootDir, + options.interactive, + packageManager!, // is it safe to do this? + tsupConfigFile, + workspaceInfoOptional.packages, + { silent: true, report }, + ); + if (!tsupOk) { + clearMigrationProgress(); + cancelAndExit('tsup migration failed. Fix the issue and re-run `vp migrate`.', 1); + } + tsupMigrated = true; + } + } + // Check if node version manager file migration is needed (full setup only) if (fullSetup) { const nodeVersionDetection = detectNodeVersionManagerFile(workspaceInfoOptional.rootDir); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 4fb6355ba3..221410fcf7 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -4,7 +4,7 @@ import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import { type WorkspacePackage } from '../../types/index.ts'; +import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; @@ -63,11 +63,12 @@ async function runTsdownMigrateStep( spinner: ReturnType, failMessage: string, manualHint: string, + packageManager: PackageManager, ): Promise { try { const result = await runCommandSilently({ command: vpBin, - args: ['dlx', 'tsdown-migrate'], + args: ['dlx', 'tsdown-migrate@rc', '--yes', `--package-manager ${packageManager}`], // remove pin to rc tag once it graduates to main version cwd, envs: process.env, }); @@ -91,6 +92,7 @@ async function runTsdownMigrateStep( export async function migrateTsupToTsdown( projectPath: string, interactive: boolean, + packageManager: PackageManager, tsupConfigFile?: string, packages?: WorkspacePackage[], options?: { silent?: boolean; report?: MigrationReport }, @@ -118,6 +120,7 @@ export async function migrateTsupToTsdown( spinner, 'tsup migration failed', `You can run \`vp dlx tsdown-migrate\` manually later in ${displayRelative(target)}`, + packageManager, ); if (!migrateOk) { return false; @@ -223,7 +226,7 @@ export async function confirmTsupMigration(interactive: boolean): Promise { const tsupProject = detectTsupProject(projectPath, packages); @@ -254,7 +258,7 @@ export async function promptTsupMigration( if (!confirmed) { return false; } - const ok = await migrateTsupToTsdown(projectPath, interactive, tsupProject.configFile, packages); + const ok = await migrateTsupToTsdown(projectPath, interactive, packageManager, tsupProject.configFile, packages); if (!ok) { cancelAndExit('tsup migration failed.', 1); } diff --git a/packages/cli/src/migration/setup-plan.ts b/packages/cli/src/migration/setup-plan.ts index 450a4ef54c..d0b9d7ac1e 100644 --- a/packages/cli/src/migration/setup-plan.ts +++ b/packages/cli/src/migration/setup-plan.ts @@ -37,8 +37,6 @@ export interface MigrationSetupPlan { editorConflictDecisions: Map; migrateEslint: boolean; eslintConfigFile?: string; - migrateTsup: boolean; - tsupConfigFile?: string; } async function collectGitHooksDecision( @@ -190,7 +188,10 @@ async function collectEslintMigrationDecision( return { migrateEslint, eslintConfigFile: eslintProject.configFile }; } -async function collectTsupMigrationDecision( +// Collected separately from collectMigrationSetupPlan so callers can prompt +// for it after the Prettier -> Oxfmt decision (tsup/tsdown is checked last +// among the tool migrations). +export async function collectTsupMigrationDecision( rootDir: string, options: MigrationOptions, packages?: WorkspacePackage[], @@ -224,15 +225,11 @@ export async function collectMigrationSetupPlan( const eslintPlan = includeEslint ? await collectEslintMigrationDecision(rootDir, options, packages) : { migrateEslint: false }; - const tsupPlan = includeEslint - ? await collectTsupMigrationDecision(rootDir, options, packages) - : { migrateTsup: false }; return { shouldSetupHooks, ...agentPlan, ...editorPlan, ...eslintPlan, - ...tsupPlan, }; } From e116122ec9fc39ca6b2eb89e96a439639afb434a Mon Sep 17 00:00:00 2001 From: KTrain <69028025+KTrain5169@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:38:07 +0000 Subject: [PATCH 05/20] chore: fmt --- packages/cli/src/migration/migrator/tsup.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 221410fcf7..c58ce11e09 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -258,7 +258,13 @@ export async function promptTsupMigration( if (!confirmed) { return false; } - const ok = await migrateTsupToTsdown(projectPath, interactive, packageManager, tsupProject.configFile, packages); + const ok = await migrateTsupToTsdown( + projectPath, + interactive, + packageManager, + tsupProject.configFile, + packages, + ); if (!ok) { cancelAndExit('tsup migration failed.', 1); } From 8850ecdd3221b2b6dea7973a64bf06af34242688 Mon Sep 17 00:00:00 2001 From: KTrain <69028025+KTrain5169@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:43:03 +0000 Subject: [PATCH 06/20] docs: mention tsup migratio --- docs/guide/migrate-rules.md | 1 + docs/guide/migrate.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 8c8fb95cf6..063d291119 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -236,6 +236,7 @@ scripts while preserving their arguments: | `lint-staged` | `vp staged` | | `eslint` | `vp lint`, when its optional migration runs | | `prettier` | `vp fmt`, when its optional migration runs | +| `tsup` | `vp pack`, when its optional migration runs | For commands launched through `bunx`, migration preserves `bunx` and its `--bun` flag (keeping the user's chosen runtime) and rewrites only the managed diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index db37ae4735..a4640170c0 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -4,7 +4,7 @@ ## Overview -This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, and Prettier setups into Vite+. +This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, and tsup setups into Vite+. Use it when you want to take an existing project and move it onto the Vite+ defaults instead of wiring each tool by hand. @@ -70,7 +70,7 @@ After running the migration: - Run `vp install` - Run `vp check` - Run `vp test` -- Run `vp build` +- Run `vp build` (or `vp pack` if you are building a library) ## Manual Installation & Migration From 86496be67e58af56ed60601e3a46facfb662d450 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 15:40:28 +0800 Subject: [PATCH 07/20] test(ecosystem): cover tsup migration --- .github/workflows/e2e-test.yml | 5 ++ ecosystem-ci/repo.json | 5 ++ .../cli/src/migration/__tests__/tsup.spec.ts | 59 +++++++++++++++++++ packages/cli/src/migration/migrator/tsup.ts | 4 +- 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/migration/__tests__/tsup.spec.ts diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index d181a98d65..7b472a5900 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -284,6 +284,11 @@ jobs: vp run lint vp run test:run npx tsc --noEmit + - name: decoders + node-version: 24 + command: | + vp pack + vp test - name: vite-vue-vercel node-version: 24 command: | diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index e57d9c306b..8578e8678c 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -54,6 +54,11 @@ "branch": "main", "hash": "439d7df1a5d594458e95071612c5edd93ff736a5" }, + "decoders": { + "repository": "https://github.com/nvie/decoders.git", + "branch": "main", + "hash": "ba339327758fcfda9d2f361e2786513aca901717" + }, "vite-vue-vercel": { "repository": "https://github.com/fengmk2/vite-vue-vercel.git", "branch": "main", diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts new file mode 100644 index 0000000000..b10dcf4368 --- /dev/null +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/command.ts', () => ({ + runCommandSilently: vi.fn(), +})); +vi.mock('../../utils/prompts.ts', () => ({ + cancelAndExit: vi.fn(), +})); + +import { PackageManager } from '../../types/index.ts'; +import { runCommandSilently } from '../../utils/command.ts'; +import { migrateTsupToTsdown } from '../migrator/tsup.ts'; + +const mockRunCommandSilently = vi.mocked(runCommandSilently); + +describe('tsup migration', () => { + let projectPath: string; + + beforeEach(() => { + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsup-')); + fs.writeFileSync( + path.join(projectPath, 'package.json'), + `${JSON.stringify({ name: 'fixture', devDependencies: { tsup: '^8.5.0' } }, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(projectPath, 'tsup.config.ts'), + "import { defineConfig } from 'tsup';\nexport default defineConfig({ dts: true });\n", + ); + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + }); + + afterEach(() => { + fs.rmSync(projectPath, { recursive: true, force: true }); + mockRunCommandSilently.mockReset(); + }); + + it('passes the package manager as a separate CLI argument', async () => { + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(true); + + expect(mockRunCommandSilently).toHaveBeenCalledWith({ + command: 'vp', + args: ['dlx', 'tsdown-migrate@rc', '--yes', '--package-manager', 'npm'], + cwd: projectPath, + envs: process.env, + }); + }); +}); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index c58ce11e09..ad1051057b 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -68,7 +68,9 @@ async function runTsdownMigrateStep( try { const result = await runCommandSilently({ command: vpBin, - args: ['dlx', 'tsdown-migrate@rc', '--yes', `--package-manager ${packageManager}`], // remove pin to rc tag once it graduates to main version + // Stable 0.22.x predates non-TTY support. Remove the RC tag after 0.23.0 + // becomes the latest release. + args: ['dlx', 'tsdown-migrate@rc', '--yes', '--package-manager', packageManager], cwd, envs: process.env, }); From 7fdcbbf25ed0a867fb19f4ddb976210422ebe38c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 16:35:32 +0800 Subject: [PATCH 08/20] fix(migrate): pin tsdown migrator version --- .github/scripts/upgrade-deps.ts | 48 +++++++++++++++++++ .github/workflows/upgrade-deps.yml | 15 ++++-- .../cli/src/migration/__tests__/tsup.spec.ts | 2 +- packages/cli/src/migration/migrator/tsup.ts | 14 ++++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 686202c534..3d5950ae5c 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -363,6 +363,50 @@ async function updateVitestVersionConstant(vitestVersion: string): Promise console.log('Updated packages/cli/src/utils/constants.ts'); } +// ============ Update tsdown-migrate version ============ +// The current RC adds non-TTY support while it still targets tsdown 0.22.14. +// Keep that RC until the stable tsdown version advances. At that point, require +// tsdown-migrate to have the same stable version and replace the RC pin. +async function updateTsdownMigrateVersion( + tsdownVersion: string, + stableMigrateVersion: string, +): Promise { + const tsdownChange = changes.get('tsdown'); + if (!tsdownChange) { + throw new Error('The tsdown catalog update did not record a version'); + } + + const filePath = path.join(ROOT, 'packages/cli/src/migration/migrator/tsup.ts'); + const content = fs.readFileSync(filePath, 'utf8'); + const pattern = /const TSDOWN_MIGRATE_VERSION = '([\d.]+(?:-[\w.]+)?)';/; + let currentMigrateVersion: string | undefined; + const tsdownAdvanced = tsdownChange.old !== tsdownChange.new; + + if (tsdownAdvanced && stableMigrateVersion !== tsdownVersion) { + throw new Error( + `Stable tsdown advanced to ${tsdownVersion}, but stable tsdown-migrate is ` + + `${stableMigrateVersion}. Update their compatibility before the dependency upgrade.`, + ); + } + + const updated = content.replace(pattern, (_match: string, captured: string) => { + currentMigrateVersion = captured; + const nextMigrateVersion = tsdownAdvanced ? stableMigrateVersion : captured; + return `const TSDOWN_MIGRATE_VERSION = '${nextMigrateVersion}';`; + }); + if (currentMigrateVersion === undefined) { + throw new Error( + `Failed to match TSDOWN_MIGRATE_VERSION in ${filePath} — the pattern ${pattern} is stale, ` + + `please update it in .github/scripts/upgrade-deps.ts`, + ); + } + + const nextMigrateVersion = tsdownAdvanced ? stableMigrateVersion : currentMigrateVersion; + fs.writeFileSync(filePath, updated); + recordChange('tsdown-migrate', currentMigrateVersion, nextMigrateVersion); + console.log('Updated packages/cli/src/migration/migrator/tsup.ts'); +} + // ============ Update README.md manual-migration vitest pins ============ // The manual-migration guide pins `vitest` to an exact version in three places — // the npm/Bun `overrides` block, the pnpm-workspace `overrides` block, and the @@ -519,6 +563,7 @@ console.log('Fetching latest versions…'); const [ vitestVersion, tsdownVersion, + stableTsdownMigrateVersion, lightningcssVersion, devtoolsVersion, oxcNodeCliVersion, @@ -534,6 +579,7 @@ const [ ] = await Promise.all([ getLatestNpmVersion('vitest'), getLatestNpmVersion('tsdown'), + getLatestNpmVersion('tsdown-migrate'), // Mirror exactly what the bundled @tsdown/css depends on. getNpmDependencyRange('@tsdown/css', 'lightningcss'), getLatestNpmVersion('@vitejs/devtools'), @@ -551,6 +597,7 @@ const [ console.log(`vitest: ${vitestVersion}`); console.log(`tsdown: ${tsdownVersion}`); +console.log(`tsdown-migrate (stable): ${stableTsdownMigrateVersion}`); console.log(`lightningcss (from @tsdown/css): ${lightningcssVersion}`); console.log(`@vitejs/devtools: ${devtoolsVersion}`); console.log(`@oxc-node/cli: ${oxcNodeCliVersion}`); @@ -580,6 +627,7 @@ await updatePnpmWorkspace({ oxcParser: oxcParserVersion, oxcTransform: oxcTransformVersion, }); +await updateTsdownMigrateVersion(tsdownVersion, stableTsdownMigrateVersion); await updateVitestVersionConstant(vitestVersion); await updateReadmeVitestPins(vitestVersion); await updateCorePackage(devtoolsVersion); diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 5072f0626f..1316f46e3c 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -140,15 +140,20 @@ jobs: help documents. If it is `false`, do not modify help documents. 5. Compare tsdown CLI options with `vp pack` and sync new/removed options per `.claude/skills/sync-tsdown-cli/SKILL.md`. - 6. Follow `.claude/skills/sync-upstream-dependency-docs/SKILL.md` to + 6. If the stable `tsdown` version changed, confirm that + `TSDOWN_MIGRATE_VERSION` in + `packages/cli/src/migration/migrator/tsup.ts` uses the same stable + version. Do not use a prerelease version or an npm dist-tag. The + upgrade script must stop if the two stable versions do not match. + 7. Follow `.claude/skills/sync-upstream-dependency-docs/SKILL.md` to update version-specific documentation for dependencies changed in this run. - 7. Install the global CLI: + 8. Install the global CLI: - `pnpm bootstrap-cli:ci` - `echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH` - 8. If any Rust code or `Cargo.toml` was modified, run `cargo check + 9. If any Rust code or `Cargo.toml` was modified, run `cargo check --all-targets --all-features` and `cargo shear`; fix anything they report. - 9. Run `pnpm run lint` (requires a prior `just build`); fix any errors. - 10. Smoke-test the CLI: `vp -h`, `vp run -h`, `vp lint -h`, `vp test -h`, + 10. Run `pnpm run lint` (requires a prior `just build`); fix any errors. + 11. Smoke-test the CLI: `vp -h`, `vp run -h`, `vp lint -h`, `vp test -h`, `vp build -h`, `vp fmt -h`, `vp pack -h`. ### Generated artifacts and build diffs diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index b10dcf4368..4546a6a838 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -51,7 +51,7 @@ describe('tsup migration', () => { expect(mockRunCommandSilently).toHaveBeenCalledWith({ command: 'vp', - args: ['dlx', 'tsdown-migrate@rc', '--yes', '--package-manager', 'npm'], + args: ['dlx', 'tsdown-migrate@0.23.0-rc.0', '--yes', '--package-manager', 'npm'], cwd: projectPath, envs: process.env, }); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index ad1051057b..17f1f591c6 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -13,6 +13,10 @@ import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; +// Stable 0.22.x predates non-TTY support. The dependency upgrade script replaces +// this RC with the matching stable version when the bundled tsdown version advances. +const TSDOWN_MIGRATE_VERSION = '0.23.0-rc.0'; + export function detectTsupProject( projectPath: string, packages?: WorkspacePackage[], @@ -68,9 +72,13 @@ async function runTsdownMigrateStep( try { const result = await runCommandSilently({ command: vpBin, - // Stable 0.22.x predates non-TTY support. Remove the RC tag after 0.23.0 - // becomes the latest release. - args: ['dlx', 'tsdown-migrate@rc', '--yes', '--package-manager', packageManager], + args: [ + 'dlx', + `tsdown-migrate@${TSDOWN_MIGRATE_VERSION}`, + '--yes', + '--package-manager', + packageManager, + ], cwd, envs: process.env, }); From fd712230b44c49de2737494f23bee964e8aa2384 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 16:40:41 +0800 Subject: [PATCH 09/20] refactor(migrate): centralize migrator version --- .github/scripts/upgrade-deps.ts | 8 ++++---- .github/workflows/upgrade-deps.yml | 6 +++--- packages/cli/src/migration/__tests__/tsup.spec.ts | 9 ++++++++- packages/cli/src/migration/migrator/tsup.ts | 5 +---- packages/cli/src/utils/constants.ts | 4 ++++ 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 3d5950ae5c..0d0f3958d9 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -376,9 +376,9 @@ async function updateTsdownMigrateVersion( throw new Error('The tsdown catalog update did not record a version'); } - const filePath = path.join(ROOT, 'packages/cli/src/migration/migrator/tsup.ts'); + const filePath = path.join(ROOT, 'packages/cli/src/utils/constants.ts'); const content = fs.readFileSync(filePath, 'utf8'); - const pattern = /const TSDOWN_MIGRATE_VERSION = '([\d.]+(?:-[\w.]+)?)';/; + const pattern = /export const TSDOWN_MIGRATE_VERSION = '([\d.]+(?:-[\w.]+)?)';/; let currentMigrateVersion: string | undefined; const tsdownAdvanced = tsdownChange.old !== tsdownChange.new; @@ -392,7 +392,7 @@ async function updateTsdownMigrateVersion( const updated = content.replace(pattern, (_match: string, captured: string) => { currentMigrateVersion = captured; const nextMigrateVersion = tsdownAdvanced ? stableMigrateVersion : captured; - return `const TSDOWN_MIGRATE_VERSION = '${nextMigrateVersion}';`; + return `export const TSDOWN_MIGRATE_VERSION = '${nextMigrateVersion}';`; }); if (currentMigrateVersion === undefined) { throw new Error( @@ -404,7 +404,7 @@ async function updateTsdownMigrateVersion( const nextMigrateVersion = tsdownAdvanced ? stableMigrateVersion : currentMigrateVersion; fs.writeFileSync(filePath, updated); recordChange('tsdown-migrate', currentMigrateVersion, nextMigrateVersion); - console.log('Updated packages/cli/src/migration/migrator/tsup.ts'); + console.log('Updated packages/cli/src/utils/constants.ts'); } // ============ Update README.md manual-migration vitest pins ============ diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 1316f46e3c..4d3f2c7e06 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -142,9 +142,9 @@ jobs: `.claude/skills/sync-tsdown-cli/SKILL.md`. 6. If the stable `tsdown` version changed, confirm that `TSDOWN_MIGRATE_VERSION` in - `packages/cli/src/migration/migrator/tsup.ts` uses the same stable - version. Do not use a prerelease version or an npm dist-tag. The - upgrade script must stop if the two stable versions do not match. + `packages/cli/src/utils/constants.ts` uses the same stable version. + Do not use a prerelease version or an npm dist-tag. The upgrade + script must stop if the two stable versions do not match. 7. Follow `.claude/skills/sync-upstream-dependency-docs/SKILL.md` to update version-specific documentation for dependencies changed in this run. 8. Install the global CLI: diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index 4546a6a838..dd24a73efa 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -13,6 +13,7 @@ vi.mock('../../utils/prompts.ts', () => ({ import { PackageManager } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; +import { TSDOWN_MIGRATE_VERSION } from '../../utils/constants.ts'; import { migrateTsupToTsdown } from '../migrator/tsup.ts'; const mockRunCommandSilently = vi.mocked(runCommandSilently); @@ -51,7 +52,13 @@ describe('tsup migration', () => { expect(mockRunCommandSilently).toHaveBeenCalledWith({ command: 'vp', - args: ['dlx', 'tsdown-migrate@0.23.0-rc.0', '--yes', '--package-manager', 'npm'], + args: [ + 'dlx', + `tsdown-migrate@${TSDOWN_MIGRATE_VERSION}`, + '--yes', + '--package-manager', + 'npm', + ], cwd: projectPath, envs: process.env, }); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 17f1f591c6..bcd5cf4e08 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -6,6 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; +import { TSDOWN_MIGRATE_VERSION } from '../../utils/constants.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; @@ -13,10 +14,6 @@ import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; -// Stable 0.22.x predates non-TTY support. The dependency upgrade script replaces -// this RC with the matching stable version when the bundled tsdown version advances. -const TSDOWN_MIGRATE_VERSION = '0.23.0-rc.0'; - export function detectTsupProject( projectPath: string, packages?: WorkspacePackage[], diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 6450f2ec5c..beeca11d0a 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -17,6 +17,10 @@ export const VITE_CONFIG_FILES = [ export const VITEST_VERSION = '4.1.11'; +// Stable 0.22.x predates non-TTY support. The dependency upgrade script replaces +// this RC with the matching stable version when the bundled tsdown version advances. +export const TSDOWN_MIGRATE_VERSION = '0.23.0-rc.0'; + export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES) : { From 119af5928ecc8891fea144bc152a3443d3cc38fd Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 16:47:41 +0800 Subject: [PATCH 10/20] feat(migrate): link tsdown migration skill --- .../cli/src/migration/__tests__/tsup.spec.ts | 71 +++++++++++++++---- packages/cli/src/migration/migrator/tsup.ts | 13 +++- packages/cli/src/utils/constants.ts | 2 + 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index dd24a73efa..e497f11681 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -4,6 +4,21 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const { mockConfirm, mockInfo } = vi.hoisted(() => ({ + mockConfirm: vi.fn(), + mockInfo: vi.fn(), +})); + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + confirm: mockConfirm, + isCancel: () => false, + log: { + info: mockInfo, + success: vi.fn(), + warn: vi.fn(), + }, +})); + vi.mock('../../utils/command.ts', () => ({ runCommandSilently: vi.fn(), })); @@ -13,8 +28,8 @@ vi.mock('../../utils/prompts.ts', () => ({ import { PackageManager } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; -import { TSDOWN_MIGRATE_VERSION } from '../../utils/constants.ts'; -import { migrateTsupToTsdown } from '../migrator/tsup.ts'; +import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; +import { confirmTsupMigration, migrateTsupToTsdown } from '../migrator/tsup.ts'; const mockRunCommandSilently = vi.mocked(runCommandSilently); @@ -36,11 +51,14 @@ describe('tsup migration', () => { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), }); + mockConfirm.mockResolvedValue(true); }); afterEach(() => { fs.rmSync(projectPath, { recursive: true, force: true }); mockRunCommandSilently.mockReset(); + mockConfirm.mockReset(); + mockInfo.mockReset(); }); it('passes the package manager as a separate CLI argument', async () => { @@ -50,17 +68,44 @@ describe('tsup migration', () => { }), ).resolves.toBe(true); - expect(mockRunCommandSilently).toHaveBeenCalledWith({ - command: 'vp', - args: [ - 'dlx', - `tsdown-migrate@${TSDOWN_MIGRATE_VERSION}`, - '--yes', - '--package-manager', - 'npm', - ], - cwd: projectPath, - envs: process.env, + expect(mockRunCommandSilently).toHaveBeenCalledWith( + expect.objectContaining({ + args: [ + 'dlx', + `tsdown-migrate@${TSDOWN_MIGRATE_VERSION}`, + '--yes', + '--package-manager', + 'npm', + ], + }), + ); + }); + + it('shows the migration skill when automatic migration is declined', async () => { + mockConfirm.mockResolvedValue(false); + + await expect(confirmTsupMigration(true)).resolves.toBe(false); + + expect(mockInfo).toHaveBeenCalledWith( + `You can use the tsdown migration skill to migrate manually: ${TSDOWN_MIGRATION_SKILL_URL}`, + ); + }); + + it('shows the migration skill when automatic migration fails', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), }); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockInfo).toHaveBeenCalledWith( + `You can use the tsdown migration skill to migrate manually: ${TSDOWN_MIGRATION_SKILL_URL}`, + ); }); }); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index bcd5cf4e08..c8f03e11f6 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -6,7 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; -import { TSDOWN_MIGRATE_VERSION } from '../../utils/constants.ts'; +import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; @@ -14,6 +14,12 @@ import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; +function showTsdownMigrationSkill(): void { + prompts.log.info( + `You can use the tsdown migration skill to migrate manually: ${TSDOWN_MIGRATION_SKILL_URL}`, + ); +} + export function detectTsupProject( projectPath: string, packages?: WorkspacePackage[], @@ -86,12 +92,14 @@ async function runTsdownMigrateStep( prompts.log.warn(`⚠ ${stderr}`); } prompts.log.info(manualHint); + showTsdownMigrationSkill(); return false; } return true; } catch { spinner.stop(failMessage); prompts.log.info(manualHint); + showTsdownMigrationSkill(); return false; } } @@ -240,6 +248,9 @@ export async function confirmTsupMigration(interactive: boolean): Promise = process.env.VP_OVERRIDE_PACKAGES ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES) From 8241f102e9410d94ec6d97deede426394e28c4a5 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 18:03:18 +0800 Subject: [PATCH 11/20] test(migrate): cover tsup migration snapshots --- .../fixtures/migration_from_tsup/package.json | 10 +++ .../migration_from_tsup/snapshots.toml | 24 +++++ .../snapshots/migration_from_tsup_failure.md | 62 +++++++++++++ .../snapshots/migration_from_tsup_success.md | 87 +++++++++++++++++++ .../fixtures/migration_from_tsup/src/index.ts | 3 + .../tsdown-migrate-stub.mjs | 27 ++++++ .../migration_from_tsup/tsup.config.ts | 7 ++ .../cli/src/migration/__tests__/tsup.spec.ts | 18 +++- packages/cli/src/migration/bin.ts | 4 +- packages/cli/src/migration/migrator/tsup.ts | 40 +++++---- 10 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/src/index.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json new file mode 100644 index 0000000000..e51484ed01 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-from-tsup", + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml new file mode 100644 index 0000000000..4448699c6c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml @@ -0,0 +1,24 @@ +[[case]] +name = "migration_from_tsup_success" +vp = "local" +env = { VP_CLI_BIN = "./tsdown-migrate-stub.mjs" } +steps = [ + { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub tsdown-migrate so the migration stays offline", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "tsup config should migrate automatically", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "tsup.config.ts", "--assert-not", "file"], comment = "the original tsup config is removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", "tsdown.config.ts"], comment = "the converted config uses vite-plus pack", continue-on-failure = true }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "the converted config is connected to vite.config.ts", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "tsup is removed and its script uses vp pack", continue-on-failure = true }, +] + +[[case]] +name = "migration_from_tsup_failure" +vp = "local" +env = { VP_CLI_BIN = "./tsdown-migrate-stub.mjs", TSDOWN_MIGRATE_STUB_FAIL = "1" } +steps = [ + { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub a tsdown-migrate failure", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a failed automatic migration should show manual options", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "tsup.config.ts", "--assert", "file"], comment = "the original tsup config is preserved", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "tsdown.config.ts", "--assert-not", "file"], comment = "no converted config is left behind", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "the tsup dependency and script are preserved", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md new file mode 100644 index 0000000000..c8d1bbd27a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md @@ -0,0 +1,62 @@ +# migration_from_tsup_failure + +## `vpt chmod +x tsdown-migrate-stub.mjs` + +stub a tsdown-migrate failure + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +a failed automatic migration should show manual options + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +Automatic tsup migration failed. + +Choose one of these manual migration methods: + 1. Run `vp dlx tsdown-migrate` in the project root. + 2. Use the tsdown migration skill: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md + +Complete the tsup migration manually, then re-run `vp migrate`. +``` + +## `vpt stat-file tsup.config.ts --assert file` + +the original tsup config is preserved + +``` +tsup.config.ts: file +``` + +## `vpt stat-file tsdown.config.ts --assert-not file` + +no converted config is left behind + +``` +tsdown.config.ts: missing +``` + +## `vpt print-file package.json` + +the tsup dependency and script are preserved + +``` +{ + "name": "migration-from-tsup", + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md new file mode 100644 index 0000000000..d5e5b84e64 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md @@ -0,0 +1,87 @@ +# migration_from_tsup_success + +## `vpt chmod +x tsdown-migrate-stub.mjs` + +stub tsdown-migrate so the migration stays offline + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +tsup config should migrate automatically + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... +◇ Migrated . to Vite+ +• Node pnpm +• 3 config updates applied, 1 file had imports rewritten +• tsup config migrated to tsdown (`vp pack`) +→ Manual follow-up: + - Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown +``` + +## `vpt stat-file tsup.config.ts --assert-not file` + +the original tsup config is removed + +``` +tsup.config.ts: missing +``` + +## `vpt print-file tsdown.config.ts` + +the converted config uses vite-plus pack + +``` +import { defineConfig } from 'vite-plus/pack'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: ['esm', 'cjs'], + target: false, +}); +``` + +## `vpt print-file vite.config.ts` + +the converted config is connected to vite.config.ts + +``` +import tsdownConfig from './tsdown.config.js'; + +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: tsdownConfig, + fmt: {}, + lint: {"jsPlugins":[{"name":"vite-plus","specifier":"vite-plus/oxlint-plugin"}],"rules":{"vite-plus/prefer-vite-plus-imports":"error"},"options":{"typeAware":true,"typeCheck":true}}, +}); +``` + +## `vpt print-file package.json` + +tsup is removed and its script uses vp pack + +``` +{ + "name": "migration-from-tsup", + "scripts": { + "build": "vp pack" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/src/index.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/src/index.ts new file mode 100644 index 0000000000..85b5d3655c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/src/index.ts @@ -0,0 +1,3 @@ +export function answer(): number { + return 42; +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs new file mode 100644 index 0000000000..c09f0fb231 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import fs from 'node:fs'; + +const args = process.argv.slice(2); +const validArgs = + args[0] === 'dlx' && + args[1]?.startsWith('tsdown-migrate@') && + args[2] === '--yes' && + args[3] === '--package-manager' && + args[4] === 'pnpm'; + +if (!validArgs || process.env.TSDOWN_MIGRATE_STUB_FAIL === '1') { + process.exit(1); +} + +fs.writeFileSync( + 'tsdown.config.ts', + `import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: ['esm', 'cjs'], + target: false, +}); +`, +); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts new file mode 100644 index 0000000000..46e0ee5056 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: ['esm', 'cjs'], +}); diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index e497f11681..a41ad361a6 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -29,10 +29,20 @@ vi.mock('../../utils/prompts.ts', () => ({ import { PackageManager } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; +import { displayRelative } from '../../utils/path.ts'; import { confirmTsupMigration, migrateTsupToTsdown } from '../migrator/tsup.ts'; const mockRunCommandSilently = vi.mocked(runCommandSilently); +function manualMigrationOptions(targetLabel = 'the project root'): string { + return [ + 'Choose one of these manual migration methods:', + ` 1. Run \`vp dlx tsdown-migrate\` in ${targetLabel}.`, + ' 2. Use the tsdown migration skill:', + ` ${TSDOWN_MIGRATION_SKILL_URL}`, + ].join('\n'); +} + describe('tsup migration', () => { let projectPath: string; @@ -86,9 +96,7 @@ describe('tsup migration', () => { await expect(confirmTsupMigration(true)).resolves.toBe(false); - expect(mockInfo).toHaveBeenCalledWith( - `You can use the tsdown migration skill to migrate manually: ${TSDOWN_MIGRATION_SKILL_URL}`, - ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); }); it('shows the migration skill when automatic migration fails', async () => { @@ -105,7 +113,9 @@ describe('tsup migration', () => { ).resolves.toBe(false); expect(mockInfo).toHaveBeenCalledWith( - `You can use the tsdown migration skill to migrate manually: ${TSDOWN_MIGRATION_SKILL_URL}`, + `Automatic tsup migration failed.\n\n${manualMigrationOptions( + displayRelative(projectPath) || 'the project root', + )}\n`, ); }); }); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 7418f7bfca..09aa45c993 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -941,7 +941,7 @@ async function executeMigrationPlan( ); if (!tsupOk) { failMigrationProgress('Migration failed'); - cancelAndExit('tsup migration failed. Fix the issue and re-run `vp migrate`.', 1); + cancelAndExit('Complete the tsup migration manually, then re-run `vp migrate`.', 1); } } @@ -1410,7 +1410,7 @@ async function main() { ); if (!tsupOk) { clearMigrationProgress(); - cancelAndExit('tsup migration failed. Fix the issue and re-run `vp migrate`.', 1); + cancelAndExit('Complete the tsup migration manually, then re-run `vp migrate`.', 1); } tsupMigrated = true; } diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index c8f03e11f6..bf0c5f9f19 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -14,10 +14,21 @@ import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; -function showTsdownMigrationSkill(): void { - prompts.log.info( - `You can use the tsdown migration skill to migrate manually: ${TSDOWN_MIGRATION_SKILL_URL}`, - ); +function showTsdownMigrationOptions( + targetLabel = 'the project root', + automaticMigrationFailed = false, +): void { + const lines = [ + 'Choose one of these manual migration methods:', + ` 1. Run \`vp dlx tsdown-migrate\` in ${targetLabel}.`, + ' 2. Use the tsdown migration skill:', + ` ${TSDOWN_MIGRATION_SKILL_URL}`, + ]; + if (automaticMigrationFailed) { + lines.unshift('Automatic tsup migration failed.', ''); + lines.push(''); + } + prompts.log.info(lines.join('\n')); } export function detectTsupProject( @@ -68,8 +79,7 @@ async function runTsdownMigrateStep( vpBin: string, cwd: string, spinner: ReturnType, - failMessage: string, - manualHint: string, + targetLabel: string, packageManager: PackageManager, ): Promise { try { @@ -86,20 +96,18 @@ async function runTsdownMigrateStep( envs: process.env, }); if (result.exitCode !== 0) { - spinner.stop(failMessage); + spinner.stop(); const stderr = result.stderr.toString().trim(); if (stderr) { prompts.log.warn(`⚠ ${stderr}`); } - prompts.log.info(manualHint); - showTsdownMigrationSkill(); + showTsdownMigrationOptions(targetLabel, true); return false; } return true; } catch { - spinner.stop(failMessage); - prompts.log.info(manualHint); - showTsdownMigrationSkill(); + spinner.stop(); + showTsdownMigrationOptions(targetLabel, true); return false; } } @@ -129,12 +137,12 @@ export async function migrateTsupToTsdown( if (tsupTargets.length > 0) { spinner.start('Migrating tsup config to tsdown...'); for (const target of tsupTargets) { + const targetLabel = displayRelative(target) || 'the project root'; const migrateOk = await runTsdownMigrateStep( vpBin, target, spinner, - 'tsup migration failed', - `You can run \`vp dlx tsdown-migrate\` manually later in ${displayRelative(target)}`, + targetLabel, packageManager, ); if (!migrateOk) { @@ -249,7 +257,7 @@ export async function confirmTsupMigration(interactive: boolean): Promise Date: Fri, 21 Aug 2026 19:57:07 +0800 Subject: [PATCH 12/20] fix(migrate): support workspace tsup migration --- .../tsdown-migrate-stub.mjs | 3 +- .../migration_from_tsup_monorepo/package.json | 8 ++ .../packages/a/package.json | 10 ++ .../packages/a/src/index.ts | 1 + .../packages/a/tsup.config.ts | 6 + .../packages/b/package.json | 10 ++ .../packages/b/src/index.ts | 1 + .../packages/b/tsup.config.ts | 6 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 27 ++++ .../migration_from_tsup_monorepo_failure.md | 95 ++++++++++++++ .../migration_from_tsup_monorepo_success.md | 70 +++++++++++ .../tsdown-migrate-stub.mjs | 41 ++++++ .../cli/src/migration/__tests__/tsup.spec.ts | 80 +++++++++++- packages/cli/src/migration/migrator/tsup.ts | 117 ++++++++++++------ packages/cli/src/migration/setup-plan.ts | 6 +- 16 files changed, 438 insertions(+), 45 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/src/index.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/tsup.config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/src/index.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/tsup.config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs index c09f0fb231..5becedd62c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs @@ -7,7 +7,8 @@ const validArgs = args[1]?.startsWith('tsdown-migrate@') && args[2] === '--yes' && args[3] === '--package-manager' && - args[4] === 'pnpm'; + args[4] === 'pnpm' && + args[5] === '--no-install'; if (!validArgs || process.env.TSDOWN_MIGRATE_STUB_FAIL === '1') { process.exit(1); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/package.json new file mode 100644 index 0000000000..89304c1d0e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/package.json @@ -0,0 +1,8 @@ +{ + "name": "migration-from-tsup-monorepo", + "private": true, + "devDependencies": { + "vite": "^7.0.0" + }, + "packageManager": "pnpm@10.18.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json new file mode 100644 index 0000000000..9bc7f148f8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json @@ -0,0 +1,10 @@ +{ + "name": "a", + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/src/index.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/src/index.ts new file mode 100644 index 0000000000..c4a2d30fbc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/src/index.ts @@ -0,0 +1 @@ +export const a = 'a'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/tsup.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/tsup.config.ts new file mode 100644 index 0000000000..2414010adc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/tsup.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json new file mode 100644 index 0000000000..1bf79296a5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json @@ -0,0 +1,10 @@ +{ + "name": "b", + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/src/index.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/src/index.ts new file mode 100644 index 0000000000..137b8ce642 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/src/index.ts @@ -0,0 +1 @@ +export const b = 'b'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/tsup.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/tsup.config.ts new file mode 100644 index 0000000000..2414010adc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/tsup.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml new file mode 100644 index 0000000000..69737bba16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml @@ -0,0 +1,27 @@ +[[case]] +name = "migration_from_tsup_monorepo_success" +vp = "local" +env = { VP_CLI_BIN = "../../tsdown-migrate-stub.mjs" } +steps = [ + { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub tsdown-migrate so the migration stays offline", continue-on-failure = true }, + { argv = ["vpt", "rm", "-rf", "packages/b"], comment = "keep the success case to one workspace package", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "workspace-only tsup configs should migrate automatically", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "packages/a/tsup.config.ts", "--assert-not", "file"], comment = "package a original config is removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/a/tsdown.config.ts"], comment = "package a gets a converted config", continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/a/package.json"], comment = "package a uses vp pack", continue-on-failure = true }, +] + +[[case]] +name = "migration_from_tsup_monorepo_failure" +vp = "local" +env = { VP_CLI_BIN = "../../tsdown-migrate-stub.mjs", TSDOWN_MIGRATE_STUB_FAIL_IN = "b" } +steps = [ + { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub a failure after package a succeeds", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a later package failure should roll back every package", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "packages/a/tsup.config.ts", "--assert", "file"], comment = "package a original config is restored", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "packages/a/tsdown.config.ts", "--assert-not", "file"], comment = "package a converted config is removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/a/package.json"], comment = "package a manifest is restored", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "packages/b/tsup.config.ts", "--assert", "file"], comment = "package b original config is restored", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "packages/b/tsdown.config.ts", "--assert-not", "file"], comment = "package b partial config is removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/b/package.json"], comment = "package b manifest is restored", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md new file mode 100644 index 0000000000..a80871fc3e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md @@ -0,0 +1,95 @@ +# migration_from_tsup_monorepo_failure + +## `vpt chmod +x tsdown-migrate-stub.mjs` + +stub a failure after package a succeeds + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +a later package failure should roll back every package + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +Automatic tsup migration failed. + +Choose one of these manual migration methods: + 1. Run `vp dlx tsdown-migrate` in packages/b. + 2. Use the tsdown migration skill: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md + +Complete the tsup migration manually, then re-run `vp migrate`. +``` + +## `vpt stat-file packages/a/tsup.config.ts --assert file` + +package a original config is restored + +``` +packages/a/tsup.config.ts: file +``` + +## `vpt stat-file packages/a/tsdown.config.ts --assert-not file` + +package a converted config is removed + +``` +packages/a/tsdown.config.ts: missing +``` + +## `vpt print-file packages/a/package.json` + +package a manifest is restored + +``` +{ + "name": "a", + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} +``` + +## `vpt stat-file packages/b/tsup.config.ts --assert file` + +package b original config is restored + +``` +packages/b/tsup.config.ts: file +``` + +## `vpt stat-file packages/b/tsdown.config.ts --assert-not file` + +package b partial config is removed + +``` +packages/b/tsdown.config.ts: missing +``` + +## `vpt print-file packages/b/package.json` + +package b manifest is restored + +``` +{ + "name": "b", + "scripts": { + "build": "tsup" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md new file mode 100644 index 0000000000..4267203ef4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md @@ -0,0 +1,70 @@ +# migration_from_tsup_monorepo_success + +## `vpt chmod +x tsdown-migrate-stub.mjs` + +stub tsdown-migrate so the migration stays offline + +``` +``` + +## `vpt rm -rf packages/b` + +keep the success case to one workspace package + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +workspace-only tsup configs should migrate automatically + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... +◇ Migrated . to Vite+ +• Node pnpm +• 3 config updates applied, 1 file had imports rewritten +• tsup config migrated to tsdown (`vp pack`) +→ Manual follow-up: + - Please manually merge packages/a/tsdown.config.ts into packages/a/vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown +``` + +## `vpt stat-file packages/a/tsup.config.ts --assert-not file` + +package a original config is removed + +``` +packages/a/tsup.config.ts: missing +``` + +## `vpt print-file packages/a/tsdown.config.ts` + +package a gets a converted config + +``` +import { defineConfig } from 'vite-plus/pack'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: 'cjs', + clean: false, + target: false, +}); +``` + +## `vpt print-file packages/a/package.json` + +package a uses vp pack + +``` +{ + "name": "a", + "scripts": { + "build": "vp pack" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs new file mode 100644 index 0000000000..34325d302e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const validArgs = + args[0] === 'dlx' && + args[1]?.startsWith('tsdown-migrate@') && + args[2] === '--yes' && + args[3] === '--package-manager' && + args[4] === 'pnpm' && + args[5] === '--no-install'; + +if (!validArgs) { + process.exit(1); +} + +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); +packageJson.scripts.build = 'tsdown'; +packageJson.devDependencies.tsdown = '0.22.14'; +delete packageJson.devDependencies.tsup; +fs.writeFileSync('package.json', `${JSON.stringify(packageJson, null, 2)}\n`); + +fs.writeFileSync( + 'tsdown.config.ts', + `import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: 'cjs', + clean: false, + target: false, +}); +`, +); +fs.unlinkSync('tsup.config.ts'); + +if (process.env.TSDOWN_MIGRATE_STUB_FAIL_IN === path.basename(process.cwd())) { + process.exit(1); +} diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index a41ad361a6..bd4db9f26a 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -30,7 +30,7 @@ import { PackageManager } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; import { displayRelative } from '../../utils/path.ts'; -import { confirmTsupMigration, migrateTsupToTsdown } from '../migrator/tsup.ts'; +import { confirmTsupMigration, detectTsupProject, migrateTsupToTsdown } from '../migrator/tsup.ts'; const mockRunCommandSilently = vi.mocked(runCommandSilently); @@ -86,11 +86,89 @@ describe('tsup migration', () => { '--yes', '--package-manager', 'npm', + '--no-install', ], }), ); }); + it('detects a workspace-only tsup config', () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + '{"name":"workspace","private":true}\n', + ); + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + const packagePath = path.join(projectPath, 'packages/a'); + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync( + path.join(packagePath, 'package.json'), + '{"name":"a","devDependencies":{"tsup":"^8.5.0"}}\n', + ); + fs.writeFileSync(path.join(packagePath, 'tsup.config.ts'), 'export default {};\n'); + + expect(detectTsupProject(projectPath, [{ name: 'a', path: 'packages/a' }])).toEqual({ + hasDependency: true, + hasConfig: true, + configFile: undefined, + }); + }); + + it('restores all workspace targets when a later migration fails', async () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + '{"name":"workspace","private":true}\n', + ); + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + const originalFiles = new Map(); + + for (const workspacePackage of packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + const packageJson = `${JSON.stringify( + { + name: workspacePackage.name, + scripts: { build: 'tsup' }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`; + const tsupConfig = `export default { name: '${workspacePackage.name}' };\n`; + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync(path.join(packagePath, 'package.json'), packageJson); + fs.writeFileSync(path.join(packagePath, 'tsup.config.ts'), tsupConfig); + originalFiles.set(path.join(packagePath, 'package.json'), packageJson); + originalFiles.set(path.join(packagePath, 'tsup.config.ts'), tsupConfig); + } + + mockRunCommandSilently.mockImplementation(async ({ cwd }) => { + const packageJsonPath = path.join(cwd, 'package.json'); + fs.writeFileSync(packageJsonPath, '{"name":"partially-migrated"}\n'); + fs.writeFileSync(path.join(cwd, 'tsdown.config.ts'), 'export default {};\n'); + fs.unlinkSync(path.join(cwd, 'tsup.config.ts')); + return { + exitCode: path.basename(cwd) === 'b' ? 1 : 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }; + }); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, undefined, packages, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).toHaveBeenCalledTimes(2); + for (const [filePath, contents] of originalFiles) { + expect(fs.readFileSync(filePath, 'utf8')).toBe(contents); + expect(fs.existsSync(path.join(path.dirname(filePath), 'tsdown.config.ts'))).toBe(false); + } + }); + it('shows the migration skill when automatic migration is declined', async () => { mockConfirm.mockResolvedValue(false); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index bf0c5f9f19..daa3478e0e 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -36,39 +36,74 @@ export function detectTsupProject( packages?: WorkspacePackage[], ): { hasDependency: boolean; + hasConfig: boolean; configFile?: string; } { const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return { hasDependency: false }; + let hasDependency = false; + if (fs.existsSync(packageJsonPath)) { + const pkg = readJsonFile(packageJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + hasDependency = !!(pkg.devDependencies?.tsup || pkg.dependencies?.tsup); } - const pkg = readJsonFile(packageJsonPath) as { - devDependencies?: Record; - dependencies?: Record; - }; - let hasDependency = !!(pkg.devDependencies?.tsup || pkg.dependencies?.tsup); const configs = detectConfigs(projectPath); const configFile = configs.tsupConfig; + let hasConfig = !!configFile; - // If root doesn't have tsup dependency, check workspace packages - if (!hasDependency && packages) { - for (const wp of packages) { - const pkgJsonPath = path.join(projectPath, wp.path, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) { - continue; - } - const wpPkg = readJsonFile(pkgJsonPath) as { - devDependencies?: Record; - dependencies?: Record; - }; - if (wpPkg.devDependencies?.tsup || wpPkg.dependencies?.tsup) { - hasDependency = true; - break; + for (const wp of packages ?? []) { + const workspacePath = path.join(projectPath, wp.path); + hasConfig ||= !!detectConfigs(workspacePath).tsupConfig; + + if (!hasDependency) { + const workspacePackageJsonPath = path.join(workspacePath, 'package.json'); + if (fs.existsSync(workspacePackageJsonPath)) { + const workspacePackageJson = readJsonFile(workspacePackageJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + hasDependency = !!( + workspacePackageJson.devDependencies?.tsup || workspacePackageJson.dependencies?.tsup + ); } } } - return { hasDependency, configFile }; + return { hasDependency, hasConfig, configFile }; +} + +const TSDOWN_MIGRATION_FILES = [ + 'package.json', + ...TSUP_CONFIG_FILES, + ...TSUP_CONFIG_FILES.map((file) => file.replace('tsup', 'tsdown')), +]; + +function snapshotTsupMigrationTargets(targets: string[]): Map { + const snapshots = new Map(); + for (const target of targets) { + for (const file of TSDOWN_MIGRATION_FILES) { + const filePath = path.join(target, file); + snapshots.set(filePath, fs.existsSync(filePath) ? fs.readFileSync(filePath) : undefined); + } + } + return snapshots; +} + +function restoreTsupMigrationTargets(snapshots: Map): string[] { + const failures: string[] = []; + for (const [filePath, contents] of snapshots) { + try { + if (contents === undefined) { + fs.rmSync(filePath, { force: true }); + } else { + fs.writeFileSync(filePath, contents); + } + } catch { + failures.push(displayRelative(filePath)); + } + } + return failures; } /** @@ -78,8 +113,6 @@ export function detectTsupProject( async function runTsdownMigrateStep( vpBin: string, cwd: string, - spinner: ReturnType, - targetLabel: string, packageManager: PackageManager, ): Promise { try { @@ -91,23 +124,20 @@ async function runTsdownMigrateStep( '--yes', '--package-manager', packageManager, + '--no-install', ], cwd, envs: process.env, }); if (result.exitCode !== 0) { - spinner.stop(); const stderr = result.stderr.toString().trim(); if (stderr) { prompts.log.warn(`⚠ ${stderr}`); } - showTsdownMigrationOptions(targetLabel, true); return false; } return true; } catch { - spinner.stop(); - showTsdownMigrationOptions(targetLabel, true); return false; } } @@ -135,17 +165,25 @@ export async function migrateTsupToTsdown( ); if (tsupTargets.length > 0) { + // tsdown-migrate rewrites package.json and renames every tsup config it + // finds. Preserve those files across all targets so a later failure can + // roll the complete workspace back to its pre-migration state. + const snapshots = snapshotTsupMigrationTargets(tsupTargets); spinner.start('Migrating tsup config to tsdown...'); for (const target of tsupTargets) { const targetLabel = displayRelative(target) || 'the project root'; - const migrateOk = await runTsdownMigrateStep( - vpBin, - target, - spinner, - targetLabel, - packageManager, - ); + const migrateOk = await runTsdownMigrateStep(vpBin, target, packageManager); if (!migrateOk) { + spinner.stop(); + const restoreFailures = restoreTsupMigrationTargets(snapshots); + if (restoreFailures.length > 0) { + prompts.log.warn( + `Could not restore these files after the failed migration:\n${restoreFailures + .map((file) => ` ${file}`) + .join('\n')}`, + ); + } + showTsdownMigrationOptions(targetLabel, true); return false; } } @@ -236,9 +274,9 @@ function rewriteTsupPackageJson(packageJsonPath: string): void { }); } -export function warnPackageLevelTsup() { +export function warnMissingTsupConfig() { prompts.log.warn( - 'tsup detected in workspace packages but no root config found. Package-level tsup must be migrated manually.', + 'tsup detected, but no tsup config was found. The tsup setup must be migrated manually.', ); } @@ -275,9 +313,8 @@ export async function promptTsupMigration( if (!tsupProject.hasDependency) { return false; } - if (!tsupProject.configFile) { - // Packages have tsup but no root config → warn and skip - warnPackageLevelTsup(); + if (!tsupProject.hasConfig) { + warnMissingTsupConfig(); return false; } const confirmed = await confirmTsupMigration(interactive); diff --git a/packages/cli/src/migration/setup-plan.ts b/packages/cli/src/migration/setup-plan.ts index d0b9d7ac1e..c7abeeb1eb 100644 --- a/packages/cli/src/migration/setup-plan.ts +++ b/packages/cli/src/migration/setup-plan.ts @@ -24,8 +24,8 @@ import { preflightGitHooksSetup, warnIncompatibleEslintIntegration, warnLegacyEslintConfig, + warnMissingTsupConfig, warnPackageLevelEslint, - warnPackageLevelTsup, } from './migrator.ts'; import type { MigrationOptions } from './options.ts'; @@ -198,10 +198,10 @@ export async function collectTsupMigrationDecision( ): Promise<{ migrateTsup: boolean; tsupConfigFile?: string }> { const tsupProject = detectTsupProject(rootDir, packages); let migrateTsup = false; - if (tsupProject.hasDependency && tsupProject.configFile) { + if (tsupProject.hasDependency && tsupProject.hasConfig) { migrateTsup = await confirmTsupMigration(options.interactive); } else if (tsupProject.hasDependency) { - warnPackageLevelTsup(); + warnMissingTsupConfig(); } return { migrateTsup, tsupConfigFile: tsupProject.configFile }; From 8b7d76d60349265f4d47a64f95f2bd7338d2087c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:04:50 +0800 Subject: [PATCH 13/20] revert(ci): keep upgrade workflow unchanged --- .github/workflows/upgrade-deps.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 4d3f2c7e06..5072f0626f 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -140,20 +140,15 @@ jobs: help documents. If it is `false`, do not modify help documents. 5. Compare tsdown CLI options with `vp pack` and sync new/removed options per `.claude/skills/sync-tsdown-cli/SKILL.md`. - 6. If the stable `tsdown` version changed, confirm that - `TSDOWN_MIGRATE_VERSION` in - `packages/cli/src/utils/constants.ts` uses the same stable version. - Do not use a prerelease version or an npm dist-tag. The upgrade - script must stop if the two stable versions do not match. - 7. Follow `.claude/skills/sync-upstream-dependency-docs/SKILL.md` to + 6. Follow `.claude/skills/sync-upstream-dependency-docs/SKILL.md` to update version-specific documentation for dependencies changed in this run. - 8. Install the global CLI: + 7. Install the global CLI: - `pnpm bootstrap-cli:ci` - `echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH` - 9. If any Rust code or `Cargo.toml` was modified, run `cargo check + 8. If any Rust code or `Cargo.toml` was modified, run `cargo check --all-targets --all-features` and `cargo shear`; fix anything they report. - 10. Run `pnpm run lint` (requires a prior `just build`); fix any errors. - 11. Smoke-test the CLI: `vp -h`, `vp run -h`, `vp lint -h`, `vp test -h`, + 9. Run `pnpm run lint` (requires a prior `just build`); fix any errors. + 10. Smoke-test the CLI: `vp -h`, `vp run -h`, `vp lint -h`, `vp test -h`, `vp build -h`, `vp fmt -h`, `vp pack -h`. ### Generated artifacts and build diffs From e6d2059f567f0c296ee49605d64125ba62295e59 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:34:45 +0800 Subject: [PATCH 14/20] test(migrate): cover multiple tsup workspaces --- .../snapshots.toml | 6 ++- .../migration_from_tsup_monorepo_success.md | 51 ++++++++++++++++--- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml index 69737bba16..6b4d368429 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml @@ -4,11 +4,13 @@ vp = "local" env = { VP_CLI_BIN = "../../tsdown-migrate-stub.mjs" } steps = [ { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub tsdown-migrate so the migration stays offline", continue-on-failure = true }, - { argv = ["vpt", "rm", "-rf", "packages/b"], comment = "keep the success case to one workspace package", snapshot = false, continue-on-failure = true }, - { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "workspace-only tsup configs should migrate automatically", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "all workspace-only tsup configs should migrate automatically", continue-on-failure = true }, { argv = ["vpt", "stat-file", "packages/a/tsup.config.ts", "--assert-not", "file"], comment = "package a original config is removed", continue-on-failure = true }, { argv = ["vpt", "print-file", "packages/a/tsdown.config.ts"], comment = "package a gets a converted config", continue-on-failure = true }, { argv = ["vpt", "print-file", "packages/a/package.json"], comment = "package a uses vp pack", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "packages/b/tsup.config.ts", "--assert-not", "file"], comment = "package b original config is removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/b/tsdown.config.ts"], comment = "package b gets a converted config", continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/b/package.json"], comment = "package b uses vp pack", continue-on-failure = true }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md index 4267203ef4..9f98026863 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md @@ -7,14 +7,9 @@ stub tsdown-migrate so the migration stays offline ``` ``` -## `vpt rm -rf packages/b` - -keep the success case to one workspace package - - ## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` -workspace-only tsup configs should migrate automatically +all workspace-only tsup configs should migrate automatically ``` VITE+ - The Unified Toolchain for the Web @@ -22,9 +17,10 @@ VITE+ - The Unified Toolchain for the Web tsup configuration detected. Auto-migrating to tsdown... ◇ Migrated . to Vite+ • Node pnpm -• 3 config updates applied, 1 file had imports rewritten +• 5 config updates applied, 2 files had imports rewritten • tsup config migrated to tsdown (`vp pack`) → Manual follow-up: + - Please manually merge packages/b/tsdown.config.ts into packages/b/vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown - Please manually merge packages/a/tsdown.config.ts into packages/a/vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown ``` @@ -68,3 +64,44 @@ package a uses vp pack } } ``` + +## `vpt stat-file packages/b/tsup.config.ts --assert-not file` + +package b original config is removed + +``` +packages/b/tsup.config.ts: missing +``` + +## `vpt print-file packages/b/tsdown.config.ts` + +package b gets a converted config + +``` +import { defineConfig } from 'vite-plus/pack'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: 'cjs', + clean: false, + target: false, +}); +``` + +## `vpt print-file packages/b/package.json` + +package b uses vp pack + +``` +{ + "name": "b", + "scripts": { + "build": "vp pack" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` From 9b0f684fb681e44e54299b2f6fd1368ff8758171 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:54:47 +0800 Subject: [PATCH 15/20] fix(migrate): handle tsup migration edge cases --- .../fixtures/migration_from_tsup/package.json | 2 +- .../snapshots/migration_from_tsup_failure.md | 2 +- .../snapshots/migration_from_tsup_success.md | 7 +- .../tsdown-migrate-stub.mjs | 12 ++ .../migration_from_tsup/tsup.config.ts | 1 + .../packages/a/package.json | 2 +- .../migration_from_tsup_monorepo_failure.md | 2 +- .../migration_from_tsup_monorepo_success.md | 2 +- .../tsdown-migrate-stub.mjs | 2 +- packages/cli/rules/vite-tools.yml | 9 -- .../__snapshots__/migrator.spec.ts.snap | 1 + .../src/migration/__tests__/migrator.spec.ts | 1 + .../cli/src/migration/__tests__/tsup.spec.ts | 113 +++++++++++++++++- packages/cli/src/migration/migrator/tsup.ts | 86 +++++++++---- 14 files changed, 198 insertions(+), 44 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json index e51484ed01..bb5d33c5b1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/package.json @@ -1,7 +1,7 @@ { "name": "migration-from-tsup", "scripts": { - "build": "tsup" + "build": "tsup --config tsup.config.ts" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md index c8d1bbd27a..9c3fba1b57 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_failure.md @@ -52,7 +52,7 @@ the tsup dependency and script are preserved { "name": "migration-from-tsup", "scripts": { - "build": "tsup" + "build": "tsup --config tsup.config.ts" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md index d5e5b84e64..41651d8d01 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md @@ -17,8 +17,10 @@ VITE+ - The Unified Toolchain for the Web tsup configuration detected. Auto-migrating to tsdown... ◇ Migrated . to Vite+ • Node pnpm -• 3 config updates applied, 1 file had imports rewritten +• 2 config updates applied, 1 file had imports rewritten • tsup config migrated to tsdown (`vp pack`) +! Warnings: + - tsdown-migrate: The splitting option is currently unsupported in tsdown. Code splitting is always enabled and cannot be disabled. → Manual follow-up: - Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown ``` @@ -42,6 +44,7 @@ export default defineConfig({ entry: ['src/index.ts'], dts: true, format: ['esm', 'cjs'], + splitting: false, target: false, }); ``` @@ -70,7 +73,7 @@ tsup is removed and its script uses vp pack { "name": "migration-from-tsup", "scripts": { - "build": "vp pack" + "build": "vp pack --config tsdown.config.ts" }, "devDependencies": { "vite": "catalog:", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs index 5becedd62c..32ad8b4d84 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsdown-migrate-stub.mjs @@ -14,6 +14,12 @@ if (!validArgs || process.env.TSDOWN_MIGRATE_STUB_FAIL === '1') { process.exit(1); } +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); +packageJson.scripts.build = packageJson.scripts.build.replace(/\btsup(?:-node)?\b/g, 'tsdown'); +packageJson.devDependencies.tsdown = '0.22.14'; +delete packageJson.devDependencies.tsup; +fs.writeFileSync('package.json', `${JSON.stringify(packageJson, null, 2)}\n`); + fs.writeFileSync( 'tsdown.config.ts', `import { defineConfig } from 'tsdown'; @@ -22,7 +28,13 @@ export default defineConfig({ entry: ['src/index.ts'], dts: true, format: ['esm', 'cjs'], + splitting: false, target: false, }); `, ); +fs.unlinkSync('tsup.config.ts'); + +console.error( + '\n WARN The splitting option is currently unsupported in tsdown. Code splitting is always enabled and cannot be disabled.\n', +); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts index 46e0ee5056..a203d9f1f2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/tsup.config.ts @@ -4,4 +4,5 @@ export default defineConfig({ entry: ['src/index.ts'], dts: true, format: ['esm', 'cjs'], + splitting: false, }); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json index 9bc7f148f8..8f5468d9c8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json @@ -1,7 +1,7 @@ { "name": "a", "scripts": { - "build": "tsup" + "build": "tsup --config tsup.config.ts" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md index a80871fc3e..4aabc5d607 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md @@ -52,7 +52,7 @@ package a manifest is restored { "name": "a", "scripts": { - "build": "tsup" + "build": "tsup --config tsup.config.ts" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md index 9f98026863..49d5ebf141 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md @@ -56,7 +56,7 @@ package a uses vp pack { "name": "a", "scripts": { - "build": "vp pack" + "build": "vp pack --config tsdown.config.ts" }, "devDependencies": { "vite": "catalog:", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs index 34325d302e..16907bdf78 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs @@ -16,7 +16,7 @@ if (!validArgs) { } const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); -packageJson.scripts.build = 'tsdown'; +packageJson.scripts.build = packageJson.scripts.build.replace(/\btsup(?:-node)?\b/g, 'tsdown'); packageJson.devDependencies.tsdown = '0.22.14'; delete packageJson.devDependencies.tsup; fs.writeFileSync('package.json', `${JSON.stringify(packageJson, null, 2)}\n`); diff --git a/packages/cli/rules/vite-tools.yml b/packages/cli/rules/vite-tools.yml index 2b02711e7a..7e438987fc 100644 --- a/packages/cli/rules/vite-tools.yml +++ b/packages/cli/rules/vite-tools.yml @@ -84,12 +84,3 @@ rule: kind: command_name regex: '^tsdown$' fix: vp pack - -# tsup => vp pack ---- -id: replace-tsup -language: bash -rule: - kind: command_name - regex: '^tsup$' -fix: vp pack diff --git a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap index e62998d564..b4616c9b9f 100644 --- a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap +++ b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap @@ -84,6 +84,7 @@ exports[`rewritePackageJson > should rewrite package.json scripts and extract st "ready_new": "vp install && vp fmt && vp lint --type-aware && vp test -r && vp build -r", "test": "vp test", "test_run": "vp test run && vp test --ui", + "unmigrated_pack": "tsup --config tsup.config.ts", "version": "vp --version", "version_short": "vp -v", "wrapped_build": "bunx --bun vp build", diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 34c9bd43b5..d75bbb879f 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -175,6 +175,7 @@ describe('rewritePackageJson', () => { fmt_config: 'oxfmt --config .oxfmt.json', pack: 'tsdown', pack_watch: 'tsdown --watch', + unmigrated_pack: 'tsup --config tsup.config.ts', preview: 'vite preview', optimize: 'vite optimize', build: 'pnpm install && vite build -r && vite run build --watch && tsdown && tsc || exit 1', diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index bd4db9f26a..430cff2878 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -4,9 +4,10 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockConfirm, mockInfo } = vi.hoisted(() => ({ +const { mockConfirm, mockInfo, mockWarn } = vi.hoisted(() => ({ mockConfirm: vi.fn(), mockInfo: vi.fn(), + mockWarn: vi.fn(), })); vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ @@ -15,7 +16,7 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ log: { info: mockInfo, success: vi.fn(), - warn: vi.fn(), + warn: mockWarn, }, })); @@ -29,8 +30,8 @@ vi.mock('../../utils/prompts.ts', () => ({ import { PackageManager } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; -import { displayRelative } from '../../utils/path.ts'; import { confirmTsupMigration, detectTsupProject, migrateTsupToTsdown } from '../migrator/tsup.ts'; +import { createMigrationReport } from '../report.ts'; const mockRunCommandSilently = vi.mocked(runCommandSilently); @@ -69,6 +70,7 @@ describe('tsup migration', () => { mockRunCommandSilently.mockReset(); mockConfirm.mockReset(); mockInfo.mockReset(); + mockWarn.mockReset(); }); it('passes the package manager as a separate CLI argument', async () => { @@ -169,6 +171,107 @@ describe('tsup migration', () => { } }); + it('preserves workspace packages that do not have a tsup config', async () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + '{"name":"workspace","private":true}\n', + ); + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + for (const workspacePackage of packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync( + path.join(packagePath, 'package.json'), + `${JSON.stringify( + { + name: workspacePackage.name, + scripts: { build: 'tsup' }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`, + ); + } + fs.writeFileSync(path.join(projectPath, 'packages/a/tsup.config.ts'), 'export default {};\n'); + const packageBPath = path.join(projectPath, 'packages/b/package.json'); + const originalPackageB = fs.readFileSync(packageBPath, 'utf8'); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, undefined, packages, { + silent: true, + }), + ).resolves.toBe(true); + + expect(mockRunCommandSilently).toHaveBeenCalledTimes(1); + expect(mockRunCommandSilently).toHaveBeenCalledWith( + expect.objectContaining({ cwd: path.join(projectPath, 'packages/a') }), + ); + expect(fs.readFileSync(packageBPath, 'utf8')).toBe(originalPackageB); + }); + + it('rewrites explicit tsup config paths after migration', async () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + `${JSON.stringify( + { + name: 'fixture', + scripts: { build: 'tsup --config ./tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`, + ); + mockRunCommandSilently.mockImplementation(async () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.scripts.build = 'tsdown --config ./tsup.config.ts'; + packageJson.devDependencies.tsdown = '0.22.14'; + delete packageJson.devDependencies.tsup; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + fs.writeFileSync(path.join(projectPath, 'tsdown.config.ts'), 'export default {};\n'); + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(true); + + const packageJson = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8')); + expect(packageJson.scripts.build).toBe('tsdown --config ./tsdown.config.ts'); + }); + + it('adds successful tsdown-migrate warnings to the migration report', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from('\n WARN The plugins option requires manual migration.\n'), + stderr: Buffer.from( + 'Progress: resolved 1\n\n WARN The splitting option is currently unsupported in tsdown.\n', + ), + }); + const report = createMigrationReport(); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, 'tsup.config.ts', undefined, { + silent: true, + report, + }), + ).resolves.toBe(true); + + expect(report.warnings).toEqual([ + 'tsdown-migrate: The plugins option requires manual migration.', + 'tsdown-migrate: The splitting option is currently unsupported in tsdown.', + ]); + }); + it('shows the migration skill when automatic migration is declined', async () => { mockConfirm.mockResolvedValue(false); @@ -191,9 +294,7 @@ describe('tsup migration', () => { ).resolves.toBe(false); expect(mockInfo).toHaveBeenCalledWith( - `Automatic tsup migration failed.\n\n${manualMigrationOptions( - displayRelative(projectPath) || 'the project root', - )}\n`, + `Automatic tsup migration failed.\n\n${manualMigrationOptions()}\n`, ); }); }); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index daa3478e0e..d860a7f57e 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -12,7 +12,7 @@ import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; -import { type MigrationReport } from '../report.ts'; +import { addMigrationWarning, type MigrationReport } from '../report.ts'; function showTsdownMigrationOptions( targetLabel = 'the project root', @@ -106,15 +106,26 @@ function restoreTsupMigrationTargets(snapshots: Map) return failures; } -/** - * Run `vp dlx tsdown-migrate` in `cwd` with graceful error handling. - * Returns true on success, false on failure (spawn error or non-zero exit). - */ +function extractTsdownMigrationWarnings(output: Buffer): string[] { + return output + .toString() + .replaceAll('\r\n', '\n') + .split(/\n\s*\n/) + .map((block) => + block + .trim() + .match(/^WARN\s+([\s\S]+)$/)?.[1] + ?.trim(), + ) + .filter((warning): warning is string => !!warning); +} + +/** Run `vp dlx tsdown-migrate` in `cwd` with graceful error handling. */ async function runTsdownMigrateStep( vpBin: string, cwd: string, packageManager: PackageManager, -): Promise { +): Promise<{ ok: boolean; warnings: string[] }> { try { const result = await runCommandSilently({ command: vpBin, @@ -134,11 +145,17 @@ async function runTsdownMigrateStep( if (stderr) { prompts.log.warn(`⚠ ${stderr}`); } - return false; + return { ok: false, warnings: [] }; } - return true; + return { + ok: true, + warnings: [ + ...extractTsdownMigrationWarnings(result.stdout), + ...extractTsdownMigrationWarnings(result.stderr), + ], + }; } catch { - return false; + return { ok: false, warnings: [] }; } } @@ -169,11 +186,13 @@ export async function migrateTsupToTsdown( // finds. Preserve those files across all targets so a later failure can // roll the complete workspace back to its pre-migration state. const snapshots = snapshotTsupMigrationTargets(tsupTargets); + const migrationWarnings: { targetLabel: string; warning: string }[] = []; spinner.start('Migrating tsup config to tsdown...'); for (const target of tsupTargets) { - const targetLabel = displayRelative(target) || 'the project root'; - const migrateOk = await runTsdownMigrateStep(vpBin, target, packageManager); - if (!migrateOk) { + const targetLabel = + target === projectPath ? 'the project root' : displayRelative(target, projectPath); + const migrateResult = await runTsdownMigrateStep(vpBin, target, packageManager); + if (!migrateResult.ok) { spinner.stop(); const restoreFailures = restoreTsupMigrationTargets(snapshots); if (restoreFailures.length > 0) { @@ -186,18 +205,32 @@ export async function migrateTsupToTsdown( showTsdownMigrationOptions(targetLabel, true); return false; } + for (const warning of migrateResult.warnings) { + migrationWarnings.push({ targetLabel, warning }); + } } spinner.stop('tsup config migrated to tsdown.config'); + + for (const { targetLabel, warning } of migrationWarnings) { + const message = + targetLabel === 'the project root' + ? `tsdown-migrate: ${warning}` + : `tsdown-migrate (${targetLabel}): ${warning}`; + if (options?.report) { + addMigrationWarning(options.report, message); + } else { + prompts.log.warn(message); + } + } } if (options?.report) { options.report.tsupMigrated = true; } - // Cleanup runs uniformly across the root and every workspace package — - // delete tsup config files and remove the `tsup` dependency from - // package.json. Mirrors the eslint/prettier cleanup pass. - for (const target of targets) { + // Only clean packages that tsdown-migrate processed. Other packages can + // still use tsup when they do not have a config that can be migrated. + for (const target of tsupTargets) { if (!fs.existsSync(path.join(target, 'package.json'))) { continue; } @@ -255,15 +288,26 @@ function rewriteTsupPackageJson(packageJsonPath: string): void { return; } editJsonFile<{ + scripts?: Record; devDependencies?: Record; dependencies?: Record; }>(packageJsonPath, (pkg) => { let changed = false; - // Remove the tsup dependency itself. Scripts (`"build": "tsup"`) are - // already rewritten to `vp pack` generically by `rewriteScripts` (see - // `replace-tsup` in rules/vite-tools.yml), and `tsdown` is a managed - // vite-plus-bundled dependency (see `REMOVE_PACKAGES`), so neither needs - // handling here. + // tsdown-migrate rewrites the command to tsdown. Normalize explicit config + // paths as a safeguard before the generic tsdown -> vp pack rewrite runs. + for (const [name, script] of Object.entries(pkg.scripts ?? {})) { + let rewritten = script; + for (const configFile of TSUP_CONFIG_FILES) { + rewritten = rewritten.replaceAll(configFile, configFile.replace('tsup', 'tsdown')); + } + if (rewritten !== script) { + pkg.scripts![name] = rewritten; + changed = true; + } + } + + // Remove any tsup dependency left by the external migrator. The tsdown + // dependency is removed later because vite-plus bundles it. for (const field of ['devDependencies', 'dependencies'] as const) { if (pkg[field]?.tsup) { delete pkg[field].tsup; From f14a5b5bcc0ddb84d8601048222e88fa425b2c06 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 21:35:40 +0800 Subject: [PATCH 16/20] fix(migrate): harden tsup workspace migration --- .../snapshots/migration_from_tsup_success.md | 2 +- .../packages/a/package.json | 1 + .../packages/b/package.json | 1 + .../snapshots.toml | 49 +++-- .../migration_from_tsup_monorepo_failure.md | 4 +- ...n_from_tsup_monorepo_shared_root_config.md | 174 ++++++++++++++++++ .../migration_from_tsup_monorepo_success.md | 61 +++++- ...ub.mjs => tsdown-migrate-failure-stub.mjs} | 0 .../cli/src/migration/__tests__/tsup.spec.ts | 82 ++++++++- packages/cli/src/migration/migrator/tsup.ts | 153 +++++++++++++-- 10 files changed, 485 insertions(+), 42 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_shared_root_config.md rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/{tsdown-migrate-stub.mjs => tsdown-migrate-failure-stub.mjs} (100%) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md index 41651d8d01..72f95eddaf 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md @@ -73,7 +73,7 @@ tsup is removed and its script uses vp pack { "name": "migration-from-tsup", "scripts": { - "build": "vp pack --config tsdown.config.ts" + "build": "vp pack" }, "devDependencies": { "vite": "catalog:", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json index 8f5468d9c8..28f3b737e8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/a/package.json @@ -1,5 +1,6 @@ { "name": "a", + "type": "module", "scripts": { "build": "tsup --config tsup.config.ts" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json index 1bf79296a5..199f558499 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/packages/b/package.json @@ -1,5 +1,6 @@ { "name": "b", + "type": "module", "scripts": { "build": "tsup" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml index 6b4d368429..2bd431bb0d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots.toml @@ -1,24 +1,51 @@ [[case]] name = "migration_from_tsup_monorepo_success" vp = "local" -env = { VP_CLI_BIN = "../../tsdown-migrate-stub.mjs" } +local-registry = true steps = [ - { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub tsdown-migrate so the migration stays offline", continue-on-failure = true }, - { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "all workspace-only tsup configs should migrate automatically", continue-on-failure = true }, - { argv = ["vpt", "stat-file", "packages/a/tsup.config.ts", "--assert-not", "file"], comment = "package a original config is removed", continue-on-failure = true }, - { argv = ["vpt", "print-file", "packages/a/tsdown.config.ts"], comment = "package a gets a converted config", continue-on-failure = true }, - { argv = ["vpt", "print-file", "packages/a/package.json"], comment = "package a uses vp pack", continue-on-failure = true }, - { argv = ["vpt", "stat-file", "packages/b/tsup.config.ts", "--assert-not", "file"], comment = "package b original config is removed", continue-on-failure = true }, - { argv = ["vpt", "print-file", "packages/b/tsdown.config.ts"], comment = "package b gets a converted config", continue-on-failure = true }, - { argv = ["vpt", "print-file", "packages/b/package.json"], comment = "package b uses vp pack", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "the real tsdown-migrate package should migrate all workspace-only tsup configs", timeout = 120000 }, + { argv = ["vpt", "stat-file", "packages/a/tsup.config.ts", "--assert-not", "file"], comment = "package a original config is removed" }, + { argv = ["vpt", "print-file", "packages/a/tsdown.config.ts"], comment = "package a gets a converted config" }, + { argv = ["vpt", "print-file", "packages/a/package.json"], comment = "package a uses vp pack" }, + { argv = ["vpt", "stat-file", "packages/b/tsup.config.ts", "--assert-not", "file"], comment = "package b original config is removed" }, + { argv = ["vpt", "print-file", "packages/b/tsdown.config.ts"], comment = "package b gets a converted config" }, + { argv = ["vpt", "print-file", "packages/b/package.json"], comment = "package b uses vp pack" }, + { argv = ["vp", "run", "build"], cwd = "packages/a", comment = "package a migrated vp pack script builds successfully" }, + { argv = ["vpt", "list-dir", "packages/a/dist"], comment = "package a build artifacts are created" }, + { argv = ["vp", "run", "build"], cwd = "packages/b", comment = "package b migrated vp pack script builds successfully" }, + { argv = ["vpt", "list-dir", "packages/b/dist"], comment = "package b build artifacts are created" }, +] + +[[case]] +name = "migration_from_tsup_monorepo_shared_root_config" +vp = "local" +local-registry = true +steps = [ + { argv = ["vpt", "cp", "packages/a/tsup.config.ts", "tsup.config.ts"], snapshot = false }, + { argv = ["vpt", "rm", "packages/a/tsup.config.ts", "packages/b/tsup.config.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.tsup", "^8.5.0"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.typescript", "^5.9.2"], snapshot = false }, + { argv = ["vpt", "json-edit", "packages/a/package.json", "scripts.build", "tsup --config ../../tsup.config.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", "packages/b/package.json", "scripts.build", "tsup --config ../../tsup.config.ts"], snapshot = false }, + { argv = ["vp", "install"], comment = "install the original shared-config tsup workspace", snapshot = false, timeout = 120000 }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a shared root tsup config should be preserved with a warning", timeout = 120000 }, + { argv = ["vpt", "stat-file", "tsup.config.ts", "--assert", "file"], comment = "the shared tsup config is preserved" }, + { argv = ["vpt", "stat-file", "tsdown.config.ts", "--assert", "file"], comment = "the root also gets a migrated tsdown config" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the root keeps tsup for the shared config" }, + { argv = ["vpt", "print-file", "packages/a/package.json"], comment = "package a keeps its shared-config tsup script" }, + { argv = ["vpt", "print-file", "packages/b/package.json"], comment = "package b keeps its shared-config tsup script" }, + { argv = ["vp", "run", "build"], cwd = "packages/a", comment = "package a shared-config build remains usable" }, + { argv = ["vpt", "list-dir", "packages/a/dist"], comment = "package a build artifacts are created" }, + { argv = ["vp", "run", "build"], cwd = "packages/b", comment = "package b shared-config build remains usable" }, + { argv = ["vpt", "list-dir", "packages/b/dist"], comment = "package b build artifacts are created" }, ] [[case]] name = "migration_from_tsup_monorepo_failure" vp = "local" -env = { VP_CLI_BIN = "../../tsdown-migrate-stub.mjs", TSDOWN_MIGRATE_STUB_FAIL_IN = "b" } +env = { VP_CLI_BIN = "../../tsdown-migrate-failure-stub.mjs", TSDOWN_MIGRATE_STUB_FAIL_IN = "b" } steps = [ - { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], comment = "stub a failure after package a succeeds", continue-on-failure = true }, + { argv = ["vpt", "chmod", "+x", "tsdown-migrate-failure-stub.mjs"], comment = "stub a failure after package a succeeds", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a later package failure should roll back every package", continue-on-failure = true }, { argv = ["vpt", "stat-file", "packages/a/tsup.config.ts", "--assert", "file"], comment = "package a original config is restored", continue-on-failure = true }, { argv = ["vpt", "stat-file", "packages/a/tsdown.config.ts", "--assert-not", "file"], comment = "package a converted config is removed", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md index 4aabc5d607..4c9c7d5ec1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_failure.md @@ -1,6 +1,6 @@ # migration_from_tsup_monorepo_failure -## `vpt chmod +x tsdown-migrate-stub.mjs` +## `vpt chmod +x tsdown-migrate-failure-stub.mjs` stub a failure after package a succeeds @@ -51,6 +51,7 @@ package a manifest is restored ``` { "name": "a", + "type": "module", "scripts": { "build": "tsup --config tsup.config.ts" }, @@ -84,6 +85,7 @@ package b manifest is restored ``` { "name": "b", + "type": "module", "scripts": { "build": "tsup" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_shared_root_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_shared_root_config.md new file mode 100644 index 0000000000..739daf78ca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_shared_root_config.md @@ -0,0 +1,174 @@ +# migration_from_tsup_monorepo_shared_root_config + +## `vpt cp packages/a/tsup.config.ts tsup.config.ts` + + +## `vpt rm packages/a/tsup.config.ts packages/b/tsup.config.ts` + + +## `vpt json-edit package.json devDependencies.tsup ^8.5.0` + + +## `vpt json-edit package.json devDependencies.typescript ^5.9.2` + + +## `vpt json-edit packages/a/package.json scripts.build 'tsup --config ../../tsup.config.ts'` + + +## `vpt json-edit packages/b/package.json scripts.build 'tsup --config ../../tsup.config.ts'` + + +## `vp install` + +install the original shared-config tsup workspace + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +a shared root tsup config should be preserved with a warning + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +✔ Created vite.config.ts in vite.config.ts + +✔ Added import for tsdown.config.ts in vite.config.ts + +Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown +◇ Migrated . to Vite+ +• Node pnpm +• 1 file had imports rewritten +• tsup config migrated to tsdown (`vp pack`) +! Warnings: + - tsup.config.ts is shared by packages/a, packages/b. It was preserved and must be migrated manually. +→ Manual follow-up: + - Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown +``` + +## `vpt stat-file tsup.config.ts --assert file` + +the shared tsup config is preserved + +``` +tsup.config.ts: file +``` + +## `vpt stat-file tsdown.config.ts --assert file` + +the root also gets a migrated tsdown config + +``` +tsdown.config.ts: file +``` + +## `vpt print-file package.json` + +the root keeps tsup for the shared config + +``` +{ + "devDependencies": { + "typescript": "^5.9.2", + "vite": "catalog:", + "tsup": "^8.5.0", + "vite-plus": "catalog:" + }, + "name": "migration-from-tsup-monorepo", + "packageManager": "pnpm@10.18.0", + "private": true +} +``` + +## `vpt print-file packages/a/package.json` + +package a keeps its shared-config tsup script + +``` +{ + "devDependencies": { + "tsup": "^8.5.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "a", + "scripts": { + "build": "tsup --config ../../tsup.config.ts" + }, + "type": "module" +} +``` + +## `vpt print-file packages/b/package.json` + +package b keeps its shared-config tsup script + +``` +{ + "devDependencies": { + "tsup": "^8.5.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "b", + "scripts": { + "build": "tsup --config ../../tsup.config.ts" + }, + "type": "module" +} +``` + +## `cd packages/a && vp run build` + +package a shared-config build remains usable + +``` +~/packages/a$ tsup --config ../../tsup.config.ts ⊘ cache disabled +CLI Building entry: src/index.ts +CLI tsup +CLI Using tsup config: /tsup.config.ts +CLI Target: node16 +CJS Build start +CJS dist/index.cjs B +CJS ⚡️ Build success in +DTS Build start +DTS ⚡️ Build success in +DTS dist/index.d.cts B +``` + +## `vpt list-dir packages/a/dist` + +package a build artifacts are created + +``` +index.cjs +index.d.cts +``` + +## `cd packages/b && vp run build` + +package b shared-config build remains usable + +``` +~/packages/b$ tsup --config ../../tsup.config.ts ⊘ cache disabled +CLI Building entry: src/index.ts +CLI tsup +CLI Using tsup config: /tsup.config.ts +CLI Target: node16 +CJS Build start +CJS dist/index.cjs B +CJS ⚡️ Build success in +DTS Build start +DTS ⚡️ Build success in +DTS dist/index.d.cts B +``` + +## `vpt list-dir packages/b/dist` + +package b build artifacts are created + +``` +index.cjs +index.d.cts +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md index 49d5ebf141..977c4342c1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md @@ -1,15 +1,8 @@ # migration_from_tsup_monorepo_success -## `vpt chmod +x tsdown-migrate-stub.mjs` - -stub tsdown-migrate so the migration stays offline - -``` -``` - ## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` -all workspace-only tsup configs should migrate automatically +the real tsdown-migrate package should migrate all workspace-only tsup configs ``` VITE+ - The Unified Toolchain for the Web @@ -55,8 +48,9 @@ package a uses vp pack ``` { "name": "a", + "type": "module", "scripts": { - "build": "vp pack --config tsdown.config.ts" + "build": "vp pack" }, "devDependencies": { "vite": "catalog:", @@ -96,6 +90,7 @@ package b uses vp pack ``` { "name": "b", + "type": "module", "scripts": { "build": "vp pack" }, @@ -105,3 +100,51 @@ package b uses vp pack } } ``` + +## `cd packages/a && vp run build` + +package a migrated vp pack script builds successfully + +``` +~/packages/a$ vp pack ⊘ cache disabled +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.cjs kB │ gzip: kB +ℹ 1 files, total: kB +ℹ dist/index.d.cts kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` + +## `vpt list-dir packages/a/dist` + +package a build artifacts are created + +``` +index.cjs +index.d.cts +``` + +## `cd packages/b && vp run build` + +package b migrated vp pack script builds successfully + +``` +~/packages/b$ vp pack ⊘ cache disabled +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.cjs kB │ gzip: kB +ℹ 1 files, total: kB +ℹ dist/index.d.cts kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` + +## `vpt list-dir packages/b/dist` + +package b build artifacts are created + +``` +index.cjs +index.d.cts +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-failure-stub.mjs similarity index 100% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-stub.mjs rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/tsdown-migrate-failure-stub.mjs diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index 430cff2878..0fde7ef505 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -30,6 +30,7 @@ vi.mock('../../utils/prompts.ts', () => ({ import { PackageManager } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; +import { readJsonFile } from '../../utils/json.ts'; import { confirmTsupMigration, detectTsupProject, migrateTsupToTsdown } from '../migrator/tsup.ts'; import { createMigrationReport } from '../report.ts'; @@ -214,13 +215,80 @@ describe('tsup migration', () => { expect(fs.readFileSync(packageBPath, 'utf8')).toBe(originalPackageB); }); - it('rewrites explicit tsup config paths after migration', async () => { + it('preserves a root config shared by multiple workspace packages', async () => { + const originalRootConfig = fs.readFileSync(path.join(projectPath, 'tsup.config.ts'), 'utf8'); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + for (const workspacePackage of packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync( + path.join(packagePath, 'package.json'), + `${JSON.stringify( + { + name: workspacePackage.name, + scripts: { build: 'tsup --config ../../tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`, + ); + } + mockRunCommandSilently.mockImplementation(async ({ cwd }) => { + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.devDependencies.tsdown = '0.22.14'; + delete packageJson.devDependencies.tsup; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + fs.writeFileSync(path.join(cwd, 'tsdown.config.ts'), 'export default {};\n'); + fs.unlinkSync(path.join(cwd, 'tsup.config.ts')); + return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }); + const report = createMigrationReport(); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, 'tsup.config.ts', packages, { + silent: true, + report, + }), + ).resolves.toBe(true); + + expect(mockRunCommandSilently).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(path.join(projectPath, 'tsup.config.ts'), 'utf8')).toBe( + originalRootConfig, + ); + expect(fs.existsSync(path.join(projectPath, 'tsdown.config.ts'))).toBe(true); + expect(readJsonFile(path.join(projectPath, 'package.json')).devDependencies).toMatchObject({ + tsup: '^8.5.0', + tsdown: '0.22.14', + }); + for (const workspacePackage of packages) { + expect( + readJsonFile(path.join(projectPath, workspacePackage.path, 'package.json')), + ).toMatchObject({ + scripts: { build: 'tsup --config ../../tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }); + } + expect(report.warnings).toContain( + 'tsup.config.ts is shared by packages/a, packages/b. It was preserved and must be migrated manually.', + ); + }); + + it('removes explicit standard tsup config options after migration', async () => { fs.writeFileSync( path.join(projectPath, 'package.json'), `${JSON.stringify( { name: 'fixture', - scripts: { build: 'tsup --config ./tsup.config.ts' }, + scripts: { + build: 'tsup --config ./tsup.config.ts', + watch: 'tsup --watch --config=tsup.config.ts', + wrapped: 'cross-env NODE_ENV=test tsup -c "tsup.config.ts" --watch', + }, devDependencies: { tsup: '^8.5.0' }, }, null, @@ -230,7 +298,9 @@ describe('tsup migration', () => { mockRunCommandSilently.mockImplementation(async () => { const packageJsonPath = path.join(projectPath, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - packageJson.scripts.build = 'tsdown --config ./tsup.config.ts'; + for (const name of Object.keys(packageJson.scripts)) { + packageJson.scripts[name] = packageJson.scripts[name].replace('tsup', 'tsdown'); + } packageJson.devDependencies.tsdown = '0.22.14'; delete packageJson.devDependencies.tsup; fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); @@ -246,7 +316,11 @@ describe('tsup migration', () => { ).resolves.toBe(true); const packageJson = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8')); - expect(packageJson.scripts.build).toBe('tsdown --config ./tsdown.config.ts'); + expect(packageJson.scripts).toEqual({ + build: 'tsdown', + watch: 'tsdown --watch', + wrapped: 'cross-env NODE_ENV=test tsdown --watch', + }); }); it('adds successful tsdown-migrate warnings to the migration report', async () => { diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index d860a7f57e..01579b4fda 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -79,6 +79,74 @@ const TSDOWN_MIGRATION_FILES = [ ...TSUP_CONFIG_FILES.map((file) => file.replace('tsup', 'tsdown')), ]; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const STANDARD_TSUP_CONFIG_FILE_PATTERN = TSUP_CONFIG_FILES.flatMap((file) => [ + file, + file.replace('tsup', 'tsdown'), +]) + .map(escapeRegExp) + .join('|'); +const TSDOWN_STANDARD_CONFIG_OPTION_RE = new RegExp( + String.raw`(\btsdown(?:-node)?\b(?:(?!&&|\|\||[;|]).)*?)\s+(?:--config(?:=|\s+)|-c\s+)["']?(?:\.[\\/])?(?:${STANDARD_TSUP_CONFIG_FILE_PATTERN})["']?(?=\s|$|[;&|])`, + 'g', +); +const TSUP_CONFIG_OPTION_RE = + /\btsup(?:-node)?\b(?:(?!&&|\|\||[;|]).)*?\s+(?:--config(?:=|\s+)|-c\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))(?=\s|$|[;&|])/g; + +function removeStandardTsdownConfigOption(script: string): string { + return script.replace(TSDOWN_STANDARD_CONFIG_OPTION_RE, '$1'); +} + +function resolveScriptConfigPath(packagePath: string, configPath: string): string { + return path.resolve(packagePath, configPath.replace(/[\\/]/g, path.sep)); +} + +function collectSharedTsupConfigs( + projectPath: string, + packages: WorkspacePackage[] | undefined, + tsupTargets: string[], +): Map { + const targetSet = new Set(tsupTargets.map((target) => path.resolve(target))); + const migratedConfigPaths = new Set( + tsupTargets.flatMap((target) => + TSUP_CONFIG_FILES.map((file) => path.join(target, file)).filter((file) => + fs.existsSync(file), + ), + ), + ); + const sharedConfigs = new Map(); + + for (const workspacePackage of packages ?? []) { + const packagePath = path.join(projectPath, workspacePackage.path); + if (targetSet.has(path.resolve(packagePath))) { + continue; + } + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const packageJson = readJsonFile(packageJsonPath) as { scripts?: Record }; + for (const script of Object.values(packageJson.scripts ?? {})) { + for (const match of script.matchAll(TSUP_CONFIG_OPTION_RE)) { + const configPath = resolveScriptConfigPath(packagePath, match[1] ?? match[2] ?? match[3]); + if (!migratedConfigPaths.has(configPath)) { + continue; + } + const consumers = sharedConfigs.get(configPath) ?? []; + if (!consumers.includes(workspacePackage.path)) { + consumers.push(workspacePackage.path); + } + sharedConfigs.set(configPath, consumers); + } + } + } + + return sharedConfigs; +} + function snapshotTsupMigrationTargets(targets: string[]): Map { const snapshots = new Map(); for (const target of targets) { @@ -180,6 +248,7 @@ export async function migrateTsupToTsdown( const tsupTargets = targets.filter((target) => target === projectPath ? !!tsupConfigFile : !!detectConfigs(target).tsupConfig, ); + const sharedConfigs = collectSharedTsupConfigs(projectPath, packages, tsupTargets); if (tsupTargets.length > 0) { // tsdown-migrate rewrites package.json and renames every tsup config it @@ -211,6 +280,40 @@ export async function migrateTsupToTsdown( } spinner.stop('tsup config migrated to tsdown.config'); + // A package outside `tsupTargets` can explicitly load a config owned by a + // migration target. Keep that tsup config and dependency available so the + // package's unchanged tsup script does not point to a deleted file. The + // generated tsdown config is still available to migrate independently. + for (const configPath of sharedConfigs.keys()) { + const contents = snapshots.get(configPath); + if (contents !== undefined) { + fs.writeFileSync(configPath, contents); + } + const packageJsonPath = path.join(path.dirname(configPath), 'package.json'); + const originalPackageJson = snapshots.get(packageJsonPath); + if (originalPackageJson && fs.existsSync(packageJsonPath)) { + const original = JSON.parse(originalPackageJson.toString()) as { + devDependencies?: Record; + dependencies?: Record; + }; + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + }>(packageJsonPath, (pkg) => { + let changed = false; + for (const field of ['devDependencies', 'dependencies'] as const) { + const spec = original[field]?.tsup; + if (spec && pkg[field]?.tsup !== spec) { + pkg[field] ??= {}; + pkg[field].tsup = spec; + changed = true; + } + } + return changed ? pkg : undefined; + }); + } + } + for (const { targetLabel, warning } of migrationWarnings) { const message = targetLabel === 'the project root' @@ -222,6 +325,16 @@ export async function migrateTsupToTsdown( prompts.log.warn(message); } } + for (const [configPath, consumers] of sharedConfigs) { + const message = `${displayRelative(configPath, projectPath)} is shared by ${consumers + .toSorted() + .join(', ')}. It was preserved and must be migrated manually.`; + if (options?.report) { + addMigrationWarning(options.report, message); + } else { + prompts.log.warn(message); + } + } } if (options?.report) { @@ -234,18 +347,26 @@ export async function migrateTsupToTsdown( if (!fs.existsSync(path.join(target, 'package.json'))) { continue; } - deleteTsupConfigFiles(target, options?.report, options?.silent); - rewriteTsupPackageJson(path.join(target, 'package.json')); + const preservedConfigs = new Set( + [...sharedConfigs.keys()].filter((configPath) => path.dirname(configPath) === target), + ); + deleteTsupConfigFiles(target, preservedConfigs, options?.report, options?.silent); + rewriteTsupPackageJson(path.join(target, 'package.json'), preservedConfigs.size > 0); } return true; } -function deleteTsupConfigFiles(basePath: string, report?: MigrationReport, silent = false): void { +function deleteTsupConfigFiles( + basePath: string, + preservedConfigs: ReadonlySet, + report?: MigrationReport, + silent = false, +): void { const configs = detectConfigs(basePath); if (configs.tsupConfig && configs.tsupConfig !== TSUP_PACKAGE_JSON_CONFIG) { const configPath = path.join(basePath, configs.tsupConfig); - if (fs.existsSync(configPath)) { + if (!preservedConfigs.has(configPath) && fs.existsSync(configPath)) { fs.unlinkSync(configPath); if (report) { report.removedConfigCount++; @@ -262,7 +383,7 @@ function deleteTsupConfigFiles(basePath: string, report?: MigrationReport, silen continue; // already handled above } const configPath = path.join(basePath, file); - if (fs.existsSync(configPath)) { + if (!preservedConfigs.has(configPath) && fs.existsSync(configPath)) { fs.unlinkSync(configPath); if (report) { report.removedConfigCount++; @@ -283,7 +404,7 @@ function deleteTsupConfigFiles(basePath: string, report?: MigrationReport, silen }); } -function rewriteTsupPackageJson(packageJsonPath: string): void { +function rewriteTsupPackageJson(packageJsonPath: string, preserveTsupDependency = false): void { if (!fs.existsSync(packageJsonPath)) { return; } @@ -293,13 +414,11 @@ function rewriteTsupPackageJson(packageJsonPath: string): void { dependencies?: Record; }>(packageJsonPath, (pkg) => { let changed = false; - // tsdown-migrate rewrites the command to tsdown. Normalize explicit config - // paths as a safeguard before the generic tsdown -> vp pack rewrite runs. + // tsdown-migrate rewrites the command to tsdown. Vite+ Pack automatically + // loads standard tsdown config files and does not accept `--config`, so + // remove that redundant option before the generic tsdown -> vp pack rule. for (const [name, script] of Object.entries(pkg.scripts ?? {})) { - let rewritten = script; - for (const configFile of TSUP_CONFIG_FILES) { - rewritten = rewritten.replaceAll(configFile, configFile.replace('tsup', 'tsdown')); - } + const rewritten = removeStandardTsdownConfigOption(script); if (rewritten !== script) { pkg.scripts![name] = rewritten; changed = true; @@ -308,10 +427,12 @@ function rewriteTsupPackageJson(packageJsonPath: string): void { // Remove any tsup dependency left by the external migrator. The tsdown // dependency is removed later because vite-plus bundles it. - for (const field of ['devDependencies', 'dependencies'] as const) { - if (pkg[field]?.tsup) { - delete pkg[field].tsup; - changed = true; + if (!preserveTsupDependency) { + for (const field of ['devDependencies', 'dependencies'] as const) { + if (pkg[field]?.tsup) { + delete pkg[field].tsup; + changed = true; + } } } return changed ? pkg : undefined; From c1e03fb1eb23f36d9edf0bde6fb5f2cb812535a7 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 22:14:29 +0800 Subject: [PATCH 17/20] fix(migrate): handle tsup migration edge cases --- .../migration_from_tsup/snapshots.toml | 22 ++ .../migration_from_tsup_config_collision.md | 66 +++++ .../migration_from_tsup_nested_runner.md | 49 ++++ .../cli/src/migration/__tests__/tsup.spec.ts | 205 +++++++++++++++ packages/cli/src/migration/migrator/tsup.ts | 233 +++++++++++++----- 5 files changed, 519 insertions(+), 56 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml index 4448699c6c..15cbc828d1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml @@ -22,3 +22,25 @@ steps = [ { argv = ["vpt", "stat-file", "tsdown.config.ts", "--assert-not", "file"], comment = "no converted config is left behind", continue-on-failure = true }, { argv = ["vpt", "print-file", "package.json"], comment = "the tsup dependency and script are preserved", continue-on-failure = true }, ] + +[[case]] +name = "migration_from_tsup_config_collision" +vp = "local" +steps = [ + { argv = ["vpt", "write-file", "tsdown.config.ts", "export default { existing: true };\n"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "an existing tsdown config should stop automatic migration before it overwrites files", continue-on-failure = true }, + { argv = ["vpt", "print-file", "tsup.config.ts"], comment = "the original tsup config is unchanged" }, + { argv = ["vpt", "print-file", "tsdown.config.ts"], comment = "the existing tsdown config is unchanged" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the tsup dependency and script are unchanged" }, +] + +[[case]] +name = "migration_from_tsup_nested_runner" +vp = "local" +env = { VP_CLI_BIN = "./tsdown-migrate-stub.mjs" } +steps = [ + { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.build", 'concurrently "tsup --watch --config=tsup.config.ts" "tsc --watch"'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a nested tsdown command should migrate to vp pack" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the quoted runner command uses vp pack" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md new file mode 100644 index 0000000000..a182910c85 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md @@ -0,0 +1,66 @@ +# migration_from_tsup_config_collision + +## `vpt write-file tsdown.config.ts 'export default { existing: true }; +'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +an existing tsdown config should stop automatic migration before it overwrites files + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +Automatic tsup migration was skipped because these tsdown config files already exist: + tsdown.config.ts + +Choose one of these manual migration methods: + 1. Run `vp dlx tsdown-migrate` in the project root. + 2. Use the tsdown migration skill: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Complete the tsup migration manually, then re-run `vp migrate`. +``` + +## `vpt print-file tsup.config.ts` + +the original tsup config is unchanged + +``` +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: ['esm', 'cjs'], + splitting: false, +}); +``` + +## `vpt print-file tsdown.config.ts` + +the existing tsdown config is unchanged + +``` +export default { existing: true }; +``` + +## `vpt print-file package.json` + +the tsup dependency and script are unchanged + +``` +{ + "name": "migration-from-tsup", + "scripts": { + "build": "tsup --config tsup.config.ts" + }, + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md new file mode 100644 index 0000000000..009307ed5e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md @@ -0,0 +1,49 @@ +# migration_from_tsup_nested_runner + +## `vpt chmod +x tsdown-migrate-stub.mjs` + + +## `vpt json-edit package.json scripts.build 'concurrently "tsup --watch --config=tsup.config.ts" "tsc --watch"'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +a nested tsdown command should migrate to vp pack + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten +• tsup config migrated to tsdown (`vp pack`) +! Warnings: + - tsdown-migrate: The splitting option is currently unsupported in tsdown. Code splitting is always enabled and cannot be disabled. +→ Manual follow-up: + - Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown +``` + +## `vpt print-file package.json` + +the quoted runner command uses vp pack + +``` +{ + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-from-tsup", + "scripts": { + "build": "concurrently \"vp pack --watch\" \"tsc --watch\"" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index 0fde7ef505..f870e0d156 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -95,6 +95,28 @@ describe('tsup migration', () => { ); }); + it('refuses to overwrite an existing tsdown config', async () => { + const tsupConfigPath = path.join(projectPath, 'tsup.config.ts'); + const tsdownConfigPath = path.join(projectPath, 'tsdown.config.ts'); + const originalTsupConfig = fs.readFileSync(tsupConfigPath, 'utf8'); + const originalTsdownConfig = 'export default { existing: true };\n'; + fs.writeFileSync(tsdownConfigPath, originalTsdownConfig); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).not.toHaveBeenCalled(); + expect(fs.readFileSync(tsupConfigPath, 'utf8')).toBe(originalTsupConfig); + expect(fs.readFileSync(tsdownConfigPath, 'utf8')).toBe(originalTsdownConfig); + expect(mockWarn).toHaveBeenCalledWith( + 'Automatic tsup migration was skipped because these tsdown config files already exist:\n tsdown.config.ts', + ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + }); + it('detects a workspace-only tsup config', () => { fs.writeFileSync( path.join(projectPath, 'package.json'), @@ -278,6 +300,142 @@ describe('tsup migration', () => { ); }); + it('preserves a workspace config consumed by the root package', async () => { + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + fs.writeFileSync( + path.join(projectPath, 'package.json'), + `${JSON.stringify( + { + name: 'workspace', + private: true, + scripts: { build: 'tsup --config packages/a/tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`, + ); + const packagePath = path.join(projectPath, 'packages/a'); + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync( + path.join(packagePath, 'package.json'), + '{"name":"a","devDependencies":{"tsup":"^8.5.0"}}\n', + ); + const originalConfig = 'export default { entry: ["src/index.ts"] };\n'; + fs.writeFileSync(path.join(packagePath, 'tsup.config.ts'), originalConfig); + mockRunCommandSilently.mockImplementation(async ({ cwd }) => { + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.devDependencies.tsdown = '0.22.14'; + delete packageJson.devDependencies.tsup; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + fs.writeFileSync(path.join(cwd, 'tsdown.config.ts'), 'export default {};\n'); + fs.unlinkSync(path.join(cwd, 'tsup.config.ts')); + return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }); + const report = createMigrationReport(); + + await expect( + migrateTsupToTsdown( + projectPath, + false, + PackageManager.pnpm, + undefined, + [{ name: 'a', path: 'packages/a' }], + { silent: true, report }, + ), + ).resolves.toBe(true); + + expect(fs.readFileSync(path.join(packagePath, 'tsup.config.ts'), 'utf8')).toBe(originalConfig); + expect(fs.existsSync(path.join(packagePath, 'tsdown.config.ts'))).toBe(true); + expect(readJsonFile(path.join(projectPath, 'package.json'))).toMatchObject({ + scripts: { build: 'tsup --config packages/a/tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }); + expect(readJsonFile(path.join(packagePath, 'package.json')).devDependencies).toMatchObject({ + tsup: '^8.5.0', + tsdown: '0.22.14', + }); + expect(report.warnings).toContain( + 'packages/a/tsup.config.ts is shared by the project root. It was preserved and must be migrated manually.', + ); + }); + + it('preserves shared usage when a migration target consumes another target config', async () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + '{"name":"workspace","private":true}\n', + ); + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + for (const workspacePackage of packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync( + path.join(packagePath, 'package.json'), + `${JSON.stringify( + { + name: workspacePackage.name, + scripts: + workspacePackage.name === 'a' + ? { local: 'tsup', shared: 'tsup --config ../b/tsup.config.ts' } + : { local: 'tsup' }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`, + ); + fs.writeFileSync( + path.join(packagePath, 'tsup.config.ts'), + `export default { name: '${workspacePackage.name}' };\n`, + ); + } + mockRunCommandSilently.mockImplementation(async ({ cwd }) => { + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + for (const scriptName of Object.keys(packageJson.scripts)) { + packageJson.scripts[scriptName] = packageJson.scripts[scriptName].replaceAll( + 'tsup', + 'tsdown', + ); + } + packageJson.devDependencies.tsdown = '0.22.14'; + delete packageJson.devDependencies.tsup; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + fs.writeFileSync(path.join(cwd, 'tsdown.config.ts'), 'export default {};\n'); + fs.unlinkSync(path.join(cwd, 'tsup.config.ts')); + return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }); + const report = createMigrationReport(); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, undefined, packages, { + silent: true, + report, + }), + ).resolves.toBe(true); + + expect(readJsonFile(path.join(projectPath, 'packages/a/package.json'))).toMatchObject({ + scripts: { local: 'tsdown', shared: 'tsup --config ../b/tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0', tsdown: '0.22.14' }, + }); + expect(fs.existsSync(path.join(projectPath, 'packages/a/tsup.config.ts'))).toBe(false); + expect(fs.existsSync(path.join(projectPath, 'packages/a/tsdown.config.ts'))).toBe(true); + expect(readJsonFile(path.join(projectPath, 'packages/b/package.json'))).toMatchObject({ + scripts: { local: 'tsdown' }, + devDependencies: { tsup: '^8.5.0', tsdown: '0.22.14' }, + }); + expect(fs.existsSync(path.join(projectPath, 'packages/b/tsup.config.ts'))).toBe(true); + expect(fs.existsSync(path.join(projectPath, 'packages/b/tsdown.config.ts'))).toBe(true); + expect(report.warnings).toContain( + 'packages/b/tsup.config.ts is shared by packages/a. It was preserved and must be migrated manually.', + ); + }); + it('removes explicit standard tsup config options after migration', async () => { fs.writeFileSync( path.join(projectPath, 'package.json'), @@ -323,6 +481,53 @@ describe('tsup migration', () => { }); }); + it('rewrites tsdown commands nested in quoted runner arguments', async () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + `${JSON.stringify( + { + name: 'fixture', + scripts: { + build: 'concurrently "tsup --watch --config=tsup.config.ts" "tsc --watch"', + wrapped: 'concurrently "pnpm exec tsup --watch" "tsc --watch"', + singleQuoted: "concurrently 'tsup' 'tsc'", + }, + devDependencies: { tsup: '^8.5.0' }, + }, + null, + 2, + )}\n`, + ); + mockRunCommandSilently.mockImplementation(async () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + for (const scriptName of Object.keys(packageJson.scripts)) { + packageJson.scripts[scriptName] = packageJson.scripts[scriptName].replaceAll( + 'tsup', + 'tsdown', + ); + } + packageJson.devDependencies.tsdown = '0.22.14'; + delete packageJson.devDependencies.tsup; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + fs.writeFileSync(path.join(projectPath, 'tsdown.config.ts'), 'export default {};\n'); + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(true); + + expect(readJsonFile(path.join(projectPath, 'package.json')).scripts).toEqual({ + build: 'concurrently "vp pack --watch" "tsc --watch"', + wrapped: 'concurrently "pnpm exec vp pack --watch" "tsc --watch"', + singleQuoted: "concurrently 'vp pack' 'tsc'", + }); + }); + it('adds successful tsdown-migrate warnings to the migration report', async () => { mockRunCommandSilently.mockResolvedValue({ exitCode: 0, diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 01579b4fda..7e772f4063 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -4,6 +4,7 @@ import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { rewriteScripts } from '../../../binding/index.js'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; @@ -95,11 +96,53 @@ const TSDOWN_STANDARD_CONFIG_OPTION_RE = new RegExp( ); const TSUP_CONFIG_OPTION_RE = /\btsup(?:-node)?\b(?:(?!&&|\|\||[;|]).)*?\s+(?:--config(?:=|\s+)|-c\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))(?=\s|$|[;&|])/g; +const QUOTED_SHELL_ARGUMENT_RE = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/g; +const NESTED_TSDOWN_TOKEN_RE = /(?:^|[\s;&|])tsdown(?=$|[\s;&|])/; +const NESTED_TSDOWN_TOKEN_GLOBAL_RE = /(^|[\s;&|])tsdown(?=$|[\s;&|])/g; +const REPLACE_TSDOWN_SCRIPT_RULE = `--- +id: replace-tsdown-after-migration +language: bash +rule: + kind: command_name + regex: '^tsdown$' +fix: vp pack +`; + +interface SharedTsupConsumer { + label: string; + packagePath: string; + scriptNames: Set; +} + +interface SharedTsupConfig { + consumers: Map; +} function removeStandardTsdownConfigOption(script: string): string { return script.replace(TSDOWN_STANDARD_CONFIG_OPTION_RE, '$1'); } +function rewriteNestedTsdownCommands(script: string): string { + return script.replace( + QUOTED_SHELL_ARGUMENT_RE, + (quoted, doubleQuoted: string | undefined, singleQuoted: string | undefined) => { + const quote = doubleQuoted === undefined ? "'" : '"'; + const originalCommand = doubleQuoted ?? singleQuoted ?? ''; + if (!NESTED_TSDOWN_TOKEN_RE.test(originalCommand)) { + return quoted; + } + const command = removeStandardTsdownConfigOption(originalCommand); + const rewritten = rewriteScripts(JSON.stringify({ command }), REPLACE_TSDOWN_SCRIPT_RULE); + const parsed = rewritten ? (JSON.parse(rewritten) as { command: string }).command : command; + // Command wrappers are not all represented as nested `command_name` + // nodes. Replace any executable token the Bash rewrite did not consume + // so no quoted runner command depends on the removed tsdown package. + const migrated = parsed.replace(NESTED_TSDOWN_TOKEN_GLOBAL_RE, '$1vp pack'); + return `${quote}${migrated}${quote}`; + }, + ); +} + function resolveScriptConfigPath(packagePath: string, configPath: string): string { return path.resolve(packagePath, configPath.replace(/[\\/]/g, path.sep)); } @@ -108,8 +151,7 @@ function collectSharedTsupConfigs( projectPath: string, packages: WorkspacePackage[] | undefined, tsupTargets: string[], -): Map { - const targetSet = new Set(tsupTargets.map((target) => path.resolve(target))); +): Map { const migratedConfigPaths = new Set( tsupTargets.flatMap((target) => TSUP_CONFIG_FILES.map((file) => path.join(target, file)).filter((file) => @@ -117,29 +159,37 @@ function collectSharedTsupConfigs( ), ), ); - const sharedConfigs = new Map(); + const sharedConfigs = new Map(); + const projectRoot = path.resolve(projectPath); + const consumers = [ + { label: 'the project root', packagePath: projectRoot }, + ...(packages ?? []).map((workspacePackage) => ({ + label: workspacePackage.path, + packagePath: path.resolve(projectPath, workspacePackage.path), + })), + ]; - for (const workspacePackage of packages ?? []) { - const packagePath = path.join(projectPath, workspacePackage.path); - if (targetSet.has(path.resolve(packagePath))) { - continue; - } + for (const { label, packagePath } of consumers) { const packageJsonPath = path.join(packagePath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { continue; } const packageJson = readJsonFile(packageJsonPath) as { scripts?: Record }; - for (const script of Object.values(packageJson.scripts ?? {})) { + for (const [scriptName, script] of Object.entries(packageJson.scripts ?? {})) { for (const match of script.matchAll(TSUP_CONFIG_OPTION_RE)) { const configPath = resolveScriptConfigPath(packagePath, match[1] ?? match[2] ?? match[3]); - if (!migratedConfigPaths.has(configPath)) { + if (!migratedConfigPaths.has(configPath) || path.dirname(configPath) === packagePath) { continue; } - const consumers = sharedConfigs.get(configPath) ?? []; - if (!consumers.includes(workspacePackage.path)) { - consumers.push(workspacePackage.path); - } - sharedConfigs.set(configPath, consumers); + const sharedConfig = sharedConfigs.get(configPath) ?? { consumers: new Map() }; + const consumer = sharedConfig.consumers.get(packagePath) ?? { + label, + packagePath, + scriptNames: new Set(), + }; + consumer.scriptNames.add(scriptName); + sharedConfig.consumers.set(packagePath, consumer); + sharedConfigs.set(configPath, sharedConfig); } } } @@ -147,6 +197,80 @@ function collectSharedTsupConfigs( return sharedConfigs; } +function collectTsupConfigCollisions(tsupTargets: string[]): string[] { + const collisions: string[] = []; + for (const target of tsupTargets) { + const tsdownConfig = detectConfigs(target).tsdownConfig; + if (tsdownConfig) { + collisions.push(path.join(target, tsdownConfig)); + } + } + return collisions; +} + +function restoreSharedTsupUsage( + sharedConfigs: Map, + snapshots: Map, +): Set { + const preservedDependencyTargets = new Set(); + const restoredScripts = new Map>(); + + for (const [configPath, sharedConfig] of sharedConfigs) { + const contents = snapshots.get(configPath); + if (contents !== undefined) { + fs.writeFileSync(configPath, contents); + } + preservedDependencyTargets.add(path.dirname(configPath)); + for (const consumer of sharedConfig.consumers.values()) { + preservedDependencyTargets.add(consumer.packagePath); + const scriptNames = restoredScripts.get(consumer.packagePath) ?? new Set(); + for (const scriptName of consumer.scriptNames) { + scriptNames.add(scriptName); + } + restoredScripts.set(consumer.packagePath, scriptNames); + } + } + + for (const target of preservedDependencyTargets) { + const packageJsonPath = path.join(target, 'package.json'); + const originalPackageJson = snapshots.get(packageJsonPath); + if (!originalPackageJson || !fs.existsSync(packageJsonPath)) { + continue; + } + const original = JSON.parse(originalPackageJson.toString()) as { + scripts?: Record; + devDependencies?: Record; + dependencies?: Record; + }; + editJsonFile<{ + scripts?: Record; + devDependencies?: Record; + dependencies?: Record; + }>(packageJsonPath, (pkg) => { + let changed = false; + for (const field of ['devDependencies', 'dependencies'] as const) { + const spec = original[field]?.tsup; + if (spec && pkg[field]?.tsup !== spec) { + pkg[field] ??= {}; + pkg[field].tsup = spec; + changed = true; + } + } + for (const scriptName of restoredScripts.get(target) ?? []) { + const script = original.scripts?.[scriptName]; + if (script !== undefined && pkg.scripts?.[scriptName] !== script) { + pkg.scripts ??= {}; + pkg.scripts[scriptName] = script; + changed = true; + } + } + return changed ? pkg : undefined; + }); + } + + return preservedDependencyTargets; +} + function snapshotTsupMigrationTargets(targets: string[]): Map { const snapshots = new Map(); for (const target of targets) { @@ -244,11 +368,29 @@ export async function migrateTsupToTsdown( // every directory that has one (root and/or workspace packages) so each // gets its own `tsdown.config.*`, which `mergeTsdownConfigFile` then picks // up per-project — mirroring how `tsdown.config.*` itself is merged. - const targets = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; + const rootPath = path.resolve(projectPath); + const targets = [rootPath, ...(packages ?? []).map((p) => path.resolve(rootPath, p.path))]; const tsupTargets = targets.filter((target) => - target === projectPath ? !!tsupConfigFile : !!detectConfigs(target).tsupConfig, + target === rootPath ? !!tsupConfigFile : !!detectConfigs(target).tsupConfig, ); const sharedConfigs = collectSharedTsupConfigs(projectPath, packages, tsupTargets); + const configCollisions = collectTsupConfigCollisions(tsupTargets); + if (configCollisions.length > 0) { + prompts.log.warn( + `Automatic tsup migration was skipped because these tsdown config files already exist:\n${configCollisions + .map((configPath) => ` ${displayRelative(configPath, projectPath)}`) + .join('\n')}`, + ); + const firstCollisionTarget = path.dirname(configCollisions[0]); + const targetLabel = + firstCollisionTarget === rootPath + ? 'the project root' + : displayRelative(firstCollisionTarget, projectPath); + showTsdownMigrationOptions(targetLabel); + return false; + } + + let preservedDependencyTargets = new Set(); if (tsupTargets.length > 0) { // tsdown-migrate rewrites package.json and renames every tsup config it @@ -259,7 +401,7 @@ export async function migrateTsupToTsdown( spinner.start('Migrating tsup config to tsdown...'); for (const target of tsupTargets) { const targetLabel = - target === projectPath ? 'the project root' : displayRelative(target, projectPath); + target === rootPath ? 'the project root' : displayRelative(target, projectPath); const migrateResult = await runTsdownMigrateStep(vpBin, target, packageManager); if (!migrateResult.ok) { spinner.stop(); @@ -280,39 +422,10 @@ export async function migrateTsupToTsdown( } spinner.stop('tsup config migrated to tsdown.config'); - // A package outside `tsupTargets` can explicitly load a config owned by a - // migration target. Keep that tsup config and dependency available so the - // package's unchanged tsup script does not point to a deleted file. The - // generated tsdown config is still available to migrate independently. - for (const configPath of sharedConfigs.keys()) { - const contents = snapshots.get(configPath); - if (contents !== undefined) { - fs.writeFileSync(configPath, contents); - } - const packageJsonPath = path.join(path.dirname(configPath), 'package.json'); - const originalPackageJson = snapshots.get(packageJsonPath); - if (originalPackageJson && fs.existsSync(packageJsonPath)) { - const original = JSON.parse(originalPackageJson.toString()) as { - devDependencies?: Record; - dependencies?: Record; - }; - editJsonFile<{ - devDependencies?: Record; - dependencies?: Record; - }>(packageJsonPath, (pkg) => { - let changed = false; - for (const field of ['devDependencies', 'dependencies'] as const) { - const spec = original[field]?.tsup; - if (spec && pkg[field]?.tsup !== spec) { - pkg[field] ??= {}; - pkg[field].tsup = spec; - changed = true; - } - } - return changed ? pkg : undefined; - }); - } - } + // A root or workspace package can explicitly load a config owned by a + // different migration target. Keep that tsup config, its dependency, and + // each affected script so no consumer points to a renamed file. + preservedDependencyTargets = restoreSharedTsupUsage(sharedConfigs, snapshots); for (const { targetLabel, warning } of migrationWarnings) { const message = @@ -325,8 +438,11 @@ export async function migrateTsupToTsdown( prompts.log.warn(message); } } - for (const [configPath, consumers] of sharedConfigs) { - const message = `${displayRelative(configPath, projectPath)} is shared by ${consumers + for (const [configPath, sharedConfig] of sharedConfigs) { + const message = `${displayRelative(configPath, projectPath)} is shared by ${[ + ...sharedConfig.consumers.values(), + ] + .map((consumer) => consumer.label) .toSorted() .join(', ')}. It was preserved and must be migrated manually.`; if (options?.report) { @@ -348,10 +464,15 @@ export async function migrateTsupToTsdown( continue; } const preservedConfigs = new Set( - [...sharedConfigs.keys()].filter((configPath) => path.dirname(configPath) === target), + [...sharedConfigs.keys()].filter( + (configPath) => path.dirname(configPath) === path.resolve(target), + ), ); deleteTsupConfigFiles(target, preservedConfigs, options?.report, options?.silent); - rewriteTsupPackageJson(path.join(target, 'package.json'), preservedConfigs.size > 0); + rewriteTsupPackageJson( + path.join(target, 'package.json'), + preservedDependencyTargets.has(path.resolve(target)), + ); } return true; @@ -418,7 +539,7 @@ function rewriteTsupPackageJson(packageJsonPath: string, preserveTsupDependency // loads standard tsdown config files and does not accept `--config`, so // remove that redundant option before the generic tsdown -> vp pack rule. for (const [name, script] of Object.entries(pkg.scripts ?? {})) { - const rewritten = removeStandardTsdownConfigOption(script); + const rewritten = removeStandardTsdownConfigOption(rewriteNestedTsdownCommands(script)); if (rewritten !== script) { pkg.scripts![name] = rewritten; changed = true; From b669f6ced8ea30768861248b0b842f71be67f0f5 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 22:55:19 +0800 Subject: [PATCH 18/20] fix(migrate): guard unsupported tsup configs --- .../migration_from_tsup/snapshots.toml | 26 +++- .../migration_from_tsup_config_collision.md | 2 +- .../migration_from_tsup_custom_config.md | 72 ++++++++++ ...ation_from_tsup_inline_config_collision.md | 61 ++++++++ .../migration_from_tsup_nested_runner.md | 49 ------- .../cli/src/migration/__tests__/tsup.spec.ts | 130 +++++++++++------- packages/cli/src/migration/migrator/tsup.ts | 125 +++++++++++------ 7 files changed, 320 insertions(+), 145 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml index 15cbc828d1..f048490604 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml @@ -35,12 +35,26 @@ steps = [ ] [[case]] -name = "migration_from_tsup_nested_runner" +name = "migration_from_tsup_inline_config_collision" +vp = "local" +steps = [ + { argv = ["vpt", "rm", "tsup.config.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.build", "tsup"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "tsup", '{"entry":["src/index.ts"]}'], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "tsdown", '{"entry":["src/existing.ts"]}'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "an existing inline tsdown config should stop automatic migration", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "both inline configs are unchanged" }, +] + +[[case]] +name = "migration_from_tsup_custom_config" vp = "local" -env = { VP_CLI_BIN = "./tsdown-migrate-stub.mjs" } steps = [ - { argv = ["vpt", "chmod", "+x", "tsdown-migrate-stub.mjs"], snapshot = false }, - { argv = ["vpt", "json-edit", "package.json", "scripts.build", 'concurrently "tsup --watch --config=tsup.config.ts" "tsc --watch"'], snapshot = false }, - { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a nested tsdown command should migrate to vp pack" }, - { argv = ["vpt", "print-file", "package.json"], comment = "the quoted runner command uses vp pack" }, + { argv = ["vpt", "mkdir", "-p", "configs"], snapshot = false }, + { argv = ["vpt", "write-file", "configs/legacy.ts", "export default {};\n"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.build", "tsup --config configs/legacy.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a custom config should stop automatic migration before any files change", continue-on-failure = true }, + { argv = ["vpt", "print-file", "tsup.config.ts"], comment = "the standard config is unchanged" }, + { argv = ["vpt", "print-file", "configs/legacy.ts"], comment = "the custom config is unchanged" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the custom-config script and tsup dependency are unchanged" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md index a182910c85..e3015ba7d0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md @@ -15,7 +15,7 @@ VITE+ - The Unified Toolchain for the Web tsup configuration detected. Auto-migrating to tsdown... -Automatic tsup migration was skipped because these tsdown config files already exist: +Automatic tsup migration was skipped because these tsdown configs already exist: tsdown.config.ts Choose one of these manual migration methods: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md new file mode 100644 index 0000000000..38531fce92 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md @@ -0,0 +1,72 @@ +# migration_from_tsup_custom_config + +## `vpt mkdir -p configs` + + +## `vpt write-file configs/legacy.ts 'export default {}; +'` + + +## `vpt json-edit package.json scripts.build 'tsup --config configs/legacy.ts'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +a custom config should stop automatic migration before any files change + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically: + package.json#build -> configs/legacy.ts + +Choose one of these manual migration methods: + 1. Run `vp dlx tsdown-migrate` in the project root. + 2. Use the tsdown migration skill: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Complete the tsup migration manually, then re-run `vp migrate`. +``` + +## `vpt print-file tsup.config.ts` + +the standard config is unchanged + +``` +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + dts: true, + format: ['esm', 'cjs'], + splitting: false, +}); +``` + +## `vpt print-file configs/legacy.ts` + +the custom config is unchanged + +``` +export default {}; +``` + +## `vpt print-file package.json` + +the custom-config script and tsup dependency are unchanged + +``` +{ + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + }, + "name": "migration-from-tsup", + "scripts": { + "build": "tsup --config configs/legacy.ts" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md new file mode 100644 index 0000000000..41a28eb9ef --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md @@ -0,0 +1,61 @@ +# migration_from_tsup_inline_config_collision + +## `vpt rm tsup.config.ts` + + +## `vpt json-edit package.json scripts.build tsup` + + +## `vpt json-edit package.json tsup '{"entry":["src/index.ts"]}'` + + +## `vpt json-edit package.json tsdown '{"entry":["src/existing.ts"]}'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +an existing inline tsdown config should stop automatic migration + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +Automatic tsup migration was skipped because these tsdown configs already exist: + package.json#tsdown + +Choose one of these manual migration methods: + 1. Run `vp dlx tsdown-migrate` in the project root. + 2. Use the tsdown migration skill: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Complete the tsup migration manually, then re-run `vp migrate`. +``` + +## `vpt print-file package.json` + +both inline configs are unchanged + +``` +{ + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + }, + "name": "migration-from-tsup", + "scripts": { + "build": "tsup" + }, + "tsdown": { + "entry": [ + "src/existing.ts" + ] + }, + "tsup": { + "entry": [ + "src/index.ts" + ] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md deleted file mode 100644 index 009307ed5e..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_nested_runner.md +++ /dev/null @@ -1,49 +0,0 @@ -# migration_from_tsup_nested_runner - -## `vpt chmod +x tsdown-migrate-stub.mjs` - - -## `vpt json-edit package.json scripts.build 'concurrently "tsup --watch --config=tsup.config.ts" "tsc --watch"'` - - -## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` - -a nested tsdown command should migrate to vp pack - -``` -VITE+ - The Unified Toolchain for the Web - -tsup configuration detected. Auto-migrating to tsdown... -◇ Migrated . to Vite+ -• Node pnpm -• 2 config updates applied, 1 file had imports rewritten -• tsup config migrated to tsdown (`vp pack`) -! Warnings: - - tsdown-migrate: The splitting option is currently unsupported in tsdown. Code splitting is always enabled and cannot be disabled. -→ Manual follow-up: - - Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown -``` - -## `vpt print-file package.json` - -the quoted runner command uses vp pack - -``` -{ - "devDependencies": { - "vite": "catalog:", - "vite-plus": "catalog:" - }, - "name": "migration-from-tsup", - "scripts": { - "build": "concurrently \"vp pack --watch\" \"tsc --watch\"" - }, - "devEngines": { - "packageManager": { - "name": "pnpm", - "version": "", - "onFail": "download" - } - } -} -``` diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index f870e0d156..cc904ffcd5 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -112,7 +112,86 @@ describe('tsup migration', () => { expect(fs.readFileSync(tsupConfigPath, 'utf8')).toBe(originalTsupConfig); expect(fs.readFileSync(tsdownConfigPath, 'utf8')).toBe(originalTsdownConfig); expect(mockWarn).toHaveBeenCalledWith( - 'Automatic tsup migration was skipped because these tsdown config files already exist:\n tsdown.config.ts', + 'Automatic tsup migration was skipped because these tsdown configs already exist:\n tsdown.config.ts', + ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + }); + + it('refuses to overwrite an inline tsdown config', async () => { + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + const packageJsonPath = path.join(projectPath, 'package.json'); + const originalPackageJson = { + name: 'fixture', + scripts: { build: 'tsup' }, + devDependencies: { tsup: '^8.5.0' }, + tsup: { entry: ['src/index.ts'] }, + tsdown: { entry: ['src/existing.ts'] }, + }; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(originalPackageJson, null, 2)}\n`); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'package.json#tsup', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).not.toHaveBeenCalled(); + expect(readJsonFile(packageJsonPath)).toEqual(originalPackageJson); + expect(mockWarn).toHaveBeenCalledWith( + 'Automatic tsup migration was skipped because these tsdown configs already exist:\n package.json#tsdown', + ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + }); + + it('refuses to migrate a script that uses a custom tsup config', async () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const originalPackageJson = { + name: 'fixture', + scripts: { build: 'tsup --config configs/legacy.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }; + fs.mkdirSync(path.join(projectPath, 'configs')); + fs.writeFileSync(path.join(projectPath, 'configs/legacy.ts'), 'export default {};\n'); + fs.writeFileSync(packageJsonPath, `${JSON.stringify(originalPackageJson, null, 2)}\n`); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).not.toHaveBeenCalled(); + expect(readJsonFile(packageJsonPath)).toEqual(originalPackageJson); + expect(fs.existsSync(path.join(projectPath, 'tsup.config.ts'))).toBe(true); + expect(mockWarn).toHaveBeenCalledWith( + 'Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n package.json#build -> configs/legacy.ts', + ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + }); + + it('refuses to remove selectors for multiple standard tsup configs', async () => { + fs.writeFileSync(path.join(projectPath, 'tsup.config.js'), 'export default {};\n'); + const packageJsonPath = path.join(projectPath, 'package.json'); + const originalPackageJson = { + name: 'fixture', + scripts: { + buildTs: 'tsup --config tsup.config.ts', + buildJs: 'tsup --config tsup.config.js', + }, + devDependencies: { tsup: '^8.5.0' }, + }; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(originalPackageJson, null, 2)}\n`); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).not.toHaveBeenCalled(); + expect(readJsonFile(packageJsonPath)).toEqual(originalPackageJson); + expect(mockWarn).toHaveBeenCalledWith( + 'Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n package.json#buildJs -> tsup.config.js', ); expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); }); @@ -446,6 +525,7 @@ describe('tsup migration', () => { build: 'tsup --config ./tsup.config.ts', watch: 'tsup --watch --config=tsup.config.ts', wrapped: 'cross-env NODE_ENV=test tsup -c "tsup.config.ts" --watch', + quotedData: "echo 'tsdown'", }, devDependencies: { tsup: '^8.5.0' }, }, @@ -478,53 +558,7 @@ describe('tsup migration', () => { build: 'tsdown', watch: 'tsdown --watch', wrapped: 'cross-env NODE_ENV=test tsdown --watch', - }); - }); - - it('rewrites tsdown commands nested in quoted runner arguments', async () => { - fs.writeFileSync( - path.join(projectPath, 'package.json'), - `${JSON.stringify( - { - name: 'fixture', - scripts: { - build: 'concurrently "tsup --watch --config=tsup.config.ts" "tsc --watch"', - wrapped: 'concurrently "pnpm exec tsup --watch" "tsc --watch"', - singleQuoted: "concurrently 'tsup' 'tsc'", - }, - devDependencies: { tsup: '^8.5.0' }, - }, - null, - 2, - )}\n`, - ); - mockRunCommandSilently.mockImplementation(async () => { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - for (const scriptName of Object.keys(packageJson.scripts)) { - packageJson.scripts[scriptName] = packageJson.scripts[scriptName].replaceAll( - 'tsup', - 'tsdown', - ); - } - packageJson.devDependencies.tsdown = '0.22.14'; - delete packageJson.devDependencies.tsup; - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); - fs.writeFileSync(path.join(projectPath, 'tsdown.config.ts'), 'export default {};\n'); - fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); - return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; - }); - - await expect( - migrateTsupToTsdown(projectPath, false, PackageManager.pnpm, 'tsup.config.ts', undefined, { - silent: true, - }), - ).resolves.toBe(true); - - expect(readJsonFile(path.join(projectPath, 'package.json')).scripts).toEqual({ - build: 'concurrently "vp pack --watch" "tsc --watch"', - wrapped: 'concurrently "pnpm exec vp pack --watch" "tsc --watch"', - singleQuoted: "concurrently 'vp pack' 'tsc'", + quotedData: "echo 'tsdown'", }); }); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index 7e772f4063..f8eada5cb1 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -4,7 +4,6 @@ import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import { rewriteScripts } from '../../../binding/index.js'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATE_VERSION, TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; @@ -96,17 +95,6 @@ const TSDOWN_STANDARD_CONFIG_OPTION_RE = new RegExp( ); const TSUP_CONFIG_OPTION_RE = /\btsup(?:-node)?\b(?:(?!&&|\|\||[;|]).)*?\s+(?:--config(?:=|\s+)|-c\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))(?=\s|$|[;&|])/g; -const QUOTED_SHELL_ARGUMENT_RE = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/g; -const NESTED_TSDOWN_TOKEN_RE = /(?:^|[\s;&|])tsdown(?=$|[\s;&|])/; -const NESTED_TSDOWN_TOKEN_GLOBAL_RE = /(^|[\s;&|])tsdown(?=$|[\s;&|])/g; -const REPLACE_TSDOWN_SCRIPT_RULE = `--- -id: replace-tsdown-after-migration -language: bash -rule: - kind: command_name - regex: '^tsdown$' -fix: vp pack -`; interface SharedTsupConsumer { label: string; @@ -118,29 +106,19 @@ interface SharedTsupConfig { consumers: Map; } -function removeStandardTsdownConfigOption(script: string): string { - return script.replace(TSDOWN_STANDARD_CONFIG_OPTION_RE, '$1'); +interface TsdownConfigCollision { + configPath: string; + targetPath: string; } -function rewriteNestedTsdownCommands(script: string): string { - return script.replace( - QUOTED_SHELL_ARGUMENT_RE, - (quoted, doubleQuoted: string | undefined, singleQuoted: string | undefined) => { - const quote = doubleQuoted === undefined ? "'" : '"'; - const originalCommand = doubleQuoted ?? singleQuoted ?? ''; - if (!NESTED_TSDOWN_TOKEN_RE.test(originalCommand)) { - return quoted; - } - const command = removeStandardTsdownConfigOption(originalCommand); - const rewritten = rewriteScripts(JSON.stringify({ command }), REPLACE_TSDOWN_SCRIPT_RULE); - const parsed = rewritten ? (JSON.parse(rewritten) as { command: string }).command : command; - // Command wrappers are not all represented as nested `command_name` - // nodes. Replace any executable token the Bash rewrite did not consume - // so no quoted runner command depends on the removed tsdown package. - const migrated = parsed.replace(NESTED_TSDOWN_TOKEN_GLOBAL_RE, '$1vp pack'); - return `${quote}${migrated}${quote}`; - }, - ); +interface UnsupportedTsupConfigConsumer { + configPath: string; + packagePath: string; + scriptName: string; +} + +function removeStandardTsdownConfigOption(script: string): string { + return script.replace(TSDOWN_STANDARD_CONFIG_OPTION_RE, '$1'); } function resolveScriptConfigPath(packagePath: string, configPath: string): string { @@ -197,17 +175,64 @@ function collectSharedTsupConfigs( return sharedConfigs; } -function collectTsupConfigCollisions(tsupTargets: string[]): string[] { - const collisions: string[] = []; +function collectTsupConfigCollisions(tsupTargets: string[]): TsdownConfigCollision[] { + const collisions: TsdownConfigCollision[] = []; for (const target of tsupTargets) { const tsdownConfig = detectConfigs(target).tsdownConfig; if (tsdownConfig) { - collisions.push(path.join(target, tsdownConfig)); + collisions.push({ configPath: path.join(target, tsdownConfig), targetPath: target }); + } + const packageJsonPath = path.join(target, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath); + if (Object.hasOwn(packageJson, 'tsdown')) { + collisions.push({ configPath: `${packageJsonPath}#tsdown`, targetPath: target }); + } } } return collisions; } +function collectUnsupportedTsupConfigConsumers( + tsupTargets: string[], +): UnsupportedTsupConfigConsumer[] { + const migratedConfigPaths = new Set( + tsupTargets.flatMap((target) => + TSUP_CONFIG_FILES.map((file) => path.join(target, file)).filter((file) => + fs.existsSync(file), + ), + ), + ); + const unsupported: UnsupportedTsupConfigConsumer[] = []; + + for (const target of tsupTargets) { + const packageJsonPath = path.join(target, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const detectedConfig = detectConfigs(target).tsupConfig; + const defaultConfigPath = + detectedConfig && detectedConfig !== TSUP_PACKAGE_JSON_CONFIG + ? path.join(target, detectedConfig) + : undefined; + const packageJson = readJsonFile(packageJsonPath) as { scripts?: Record }; + + for (const [scriptName, script] of Object.entries(packageJson.scripts ?? {})) { + for (const match of script.matchAll(TSUP_CONFIG_OPTION_RE)) { + const configPath = resolveScriptConfigPath(target, match[1] ?? match[2] ?? match[3]); + const isDefaultConfig = configPath === defaultConfigPath; + const isSharedMigratedConfig = + migratedConfigPaths.has(configPath) && path.dirname(configPath) !== target; + if (!isDefaultConfig && !isSharedMigratedConfig) { + unsupported.push({ configPath, packagePath: target, scriptName }); + } + } + } + } + + return unsupported; +} + function restoreSharedTsupUsage( sharedConfigs: Map, snapshots: Map, @@ -373,15 +398,14 @@ export async function migrateTsupToTsdown( const tsupTargets = targets.filter((target) => target === rootPath ? !!tsupConfigFile : !!detectConfigs(target).tsupConfig, ); - const sharedConfigs = collectSharedTsupConfigs(projectPath, packages, tsupTargets); const configCollisions = collectTsupConfigCollisions(tsupTargets); if (configCollisions.length > 0) { prompts.log.warn( - `Automatic tsup migration was skipped because these tsdown config files already exist:\n${configCollisions - .map((configPath) => ` ${displayRelative(configPath, projectPath)}`) + `Automatic tsup migration was skipped because these tsdown configs already exist:\n${configCollisions + .map(({ configPath }) => ` ${displayRelative(configPath, projectPath)}`) .join('\n')}`, ); - const firstCollisionTarget = path.dirname(configCollisions[0]); + const firstCollisionTarget = configCollisions[0].targetPath; const targetLabel = firstCollisionTarget === rootPath ? 'the project root' @@ -389,6 +413,25 @@ export async function migrateTsupToTsdown( showTsdownMigrationOptions(targetLabel); return false; } + const unsupportedConfigConsumers = collectUnsupportedTsupConfigConsumers(tsupTargets); + if (unsupportedConfigConsumers.length > 0) { + prompts.log.warn( + `Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n${unsupportedConfigConsumers + .map( + ({ configPath, packagePath, scriptName }) => + ` ${displayRelative(path.join(packagePath, 'package.json'), projectPath)}#${scriptName} -> ${displayRelative(configPath, projectPath)}`, + ) + .join('\n')}`, + ); + const firstConsumerTarget = unsupportedConfigConsumers[0].packagePath; + const targetLabel = + firstConsumerTarget === rootPath + ? 'the project root' + : displayRelative(firstConsumerTarget, projectPath); + showTsdownMigrationOptions(targetLabel); + return false; + } + const sharedConfigs = collectSharedTsupConfigs(projectPath, packages, tsupTargets); let preservedDependencyTargets = new Set(); @@ -539,7 +582,7 @@ function rewriteTsupPackageJson(packageJsonPath: string, preserveTsupDependency // loads standard tsdown config files and does not accept `--config`, so // remove that redundant option before the generic tsdown -> vp pack rule. for (const [name, script] of Object.entries(pkg.scripts ?? {})) { - const rewritten = removeStandardTsdownConfigOption(rewriteNestedTsdownCommands(script)); + const rewritten = removeStandardTsdownConfigOption(script); if (rewritten !== script) { pkg.scripts![name] = rewritten; changed = true; From 08d971b37a49217ecae44fa9cf5e67210cfdc306 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 23:29:02 +0800 Subject: [PATCH 19/20] fix(migrate): reject unsupported tsup config inputs --- .../migration_from_tsup/snapshots.toml | 16 +++++- .../migration_from_tsup_custom_config.md | 11 +++- .../migration_from_tsup_inline_config.md | 53 ++++++++++++++++++ .../cli/src/migration/__tests__/tsup.spec.ts | 49 +++++++++++++++++ packages/cli/src/migration/migrator/tsup.ts | 55 ++++++++++++++++--- 5 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml index f048490604..53a8b9f96b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots.toml @@ -46,6 +46,17 @@ steps = [ { argv = ["vpt", "print-file", "package.json"], comment = "both inline configs are unchanged" }, ] +[[case]] +name = "migration_from_tsup_inline_config" +vp = "local" +steps = [ + { argv = ["vpt", "rm", "tsup.config.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.build", "tsup"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "tsup", '{"entry":["src/index.ts"]}'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "an inline tsup config should stop automatic migration", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "the inline tsup config is unchanged" }, +] + [[case]] name = "migration_from_tsup_custom_config" vp = "local" @@ -53,8 +64,9 @@ steps = [ { argv = ["vpt", "mkdir", "-p", "configs"], snapshot = false }, { argv = ["vpt", "write-file", "configs/legacy.ts", "export default {};\n"], snapshot = false }, { argv = ["vpt", "json-edit", "package.json", "scripts.build", "tsup --config configs/legacy.ts"], snapshot = false }, - { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a custom config should stop automatic migration before any files change", continue-on-failure = true }, + { argv = ["vpt", "json-edit", "package.json", "scripts.irregular", "tsup --config ././tsup.config.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "unsupported config paths should stop automatic migration before any files change", continue-on-failure = true }, { argv = ["vpt", "print-file", "tsup.config.ts"], comment = "the standard config is unchanged" }, { argv = ["vpt", "print-file", "configs/legacy.ts"], comment = "the custom config is unchanged" }, - { argv = ["vpt", "print-file", "package.json"], comment = "the custom-config script and tsup dependency are unchanged" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the unsupported scripts and tsup dependency are unchanged" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md index 38531fce92..161a21ba93 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md @@ -10,9 +10,12 @@ ## `vpt json-edit package.json scripts.build 'tsup --config configs/legacy.ts'` +## `vpt json-edit package.json scripts.irregular 'tsup --config ././tsup.config.ts'` + + ## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` -a custom config should stop automatic migration before any files change +unsupported config paths should stop automatic migration before any files change **Exit code:** 1 @@ -23,6 +26,7 @@ tsup configuration detected. Auto-migrating to tsdown... Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically: package.json#build -> configs/legacy.ts + package.json#irregular -> ././tsup.config.ts Choose one of these manual migration methods: 1. Run `vp dlx tsdown-migrate` in the project root. @@ -56,7 +60,7 @@ export default {}; ## `vpt print-file package.json` -the custom-config script and tsup dependency are unchanged +the unsupported scripts and tsup dependency are unchanged ``` { @@ -66,7 +70,8 @@ the custom-config script and tsup dependency are unchanged }, "name": "migration-from-tsup", "scripts": { - "build": "tsup --config configs/legacy.ts" + "build": "tsup --config configs/legacy.ts", + "irregular": "tsup --config ././tsup.config.ts" } } ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md new file mode 100644 index 0000000000..f7cea33004 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md @@ -0,0 +1,53 @@ +# migration_from_tsup_inline_config + +## `vpt rm tsup.config.ts` + + +## `vpt json-edit package.json scripts.build tsup` + + +## `vpt json-edit package.json tsup '{"entry":["src/index.ts"]}'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +an inline tsup config should stop automatic migration + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +tsup configuration detected. Auto-migrating to tsdown... + +Automatic tsup migration was skipped because these inline tsup configs cannot be migrated automatically: + package.json#tsup + +Choose one of these manual migration methods: + 1. Run `vp dlx tsdown-migrate` in the project root. + 2. Use the tsdown migration skill: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Complete the tsup migration manually, then re-run `vp migrate`. +``` + +## `vpt print-file package.json` + +the inline tsup config is unchanged + +``` +{ + "devDependencies": { + "tsup": "^8.5.0", + "vite": "^7.0.0" + }, + "name": "migration-from-tsup", + "scripts": { + "build": "tsup" + }, + "tsup": { + "entry": [ + "src/index.ts" + ] + } +} +``` diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index cc904ffcd5..51b888be63 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -143,6 +143,31 @@ describe('tsup migration', () => { expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); }); + it('refuses to migrate an inline tsup config', async () => { + fs.unlinkSync(path.join(projectPath, 'tsup.config.ts')); + const packageJsonPath = path.join(projectPath, 'package.json'); + const originalPackageJson = { + name: 'fixture', + scripts: { build: 'tsup' }, + devDependencies: { tsup: '^8.5.0' }, + tsup: { entry: ['src/index.ts'] }, + }; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(originalPackageJson, null, 2)}\n`); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'package.json#tsup', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).not.toHaveBeenCalled(); + expect(readJsonFile(packageJsonPath)).toEqual(originalPackageJson); + expect(mockWarn).toHaveBeenCalledWith( + 'Automatic tsup migration was skipped because these inline tsup configs cannot be migrated automatically:\n package.json#tsup', + ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + }); + it('refuses to migrate a script that uses a custom tsup config', async () => { const packageJsonPath = path.join(projectPath, 'package.json'); const originalPackageJson = { @@ -169,6 +194,30 @@ describe('tsup migration', () => { expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); }); + it('refuses a default config path that cleanup cannot remove', async () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const originalPackageJson = { + name: 'fixture', + scripts: { build: 'tsup --config ././tsup.config.ts' }, + devDependencies: { tsup: '^8.5.0' }, + }; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(originalPackageJson, null, 2)}\n`); + + await expect( + migrateTsupToTsdown(projectPath, false, PackageManager.npm, 'tsup.config.ts', undefined, { + silent: true, + }), + ).resolves.toBe(false); + + expect(mockRunCommandSilently).not.toHaveBeenCalled(); + expect(readJsonFile(packageJsonPath)).toEqual(originalPackageJson); + expect(fs.existsSync(path.join(projectPath, 'tsup.config.ts'))).toBe(true); + expect(mockWarn).toHaveBeenCalledWith( + 'Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n package.json#build -> ././tsup.config.ts', + ); + expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + }); + it('refuses to remove selectors for multiple standard tsup configs', async () => { fs.writeFileSync(path.join(projectPath, 'tsup.config.js'), 'export default {};\n'); const packageJsonPath = path.join(projectPath, 'package.json'); diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index f8eada5cb1..eeabbf373c 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -106,13 +106,13 @@ interface SharedTsupConfig { consumers: Map; } -interface TsdownConfigCollision { +interface TsupConfigLocation { configPath: string; targetPath: string; } interface UnsupportedTsupConfigConsumer { - configPath: string; + configArgument: string; packagePath: string; scriptName: string; } @@ -175,8 +175,8 @@ function collectSharedTsupConfigs( return sharedConfigs; } -function collectTsupConfigCollisions(tsupTargets: string[]): TsdownConfigCollision[] { - const collisions: TsdownConfigCollision[] = []; +function collectTsupConfigCollisions(tsupTargets: string[]): TsupConfigLocation[] { + const collisions: TsupConfigLocation[] = []; for (const target of tsupTargets) { const tsdownConfig = detectConfigs(target).tsdownConfig; if (tsdownConfig) { @@ -193,6 +193,25 @@ function collectTsupConfigCollisions(tsupTargets: string[]): TsdownConfigCollisi return collisions; } +function collectInlineTsupConfigs(tsupTargets: string[]): TsupConfigLocation[] { + return tsupTargets.flatMap((target) => { + const packageJsonPath = path.join(target, 'package.json'); + if (!fs.existsSync(packageJsonPath) || !Object.hasOwn(readJsonFile(packageJsonPath), 'tsup')) { + return []; + } + return [{ configPath: `${packageJsonPath}#tsup`, targetPath: target }]; + }); +} + +function isRemovableDefaultConfigArgument(configArgument: string, defaultConfig?: string): boolean { + return ( + !!defaultConfig && + (configArgument === defaultConfig || + configArgument === `./${defaultConfig}` || + configArgument === `.\\${defaultConfig}`) + ); +} + function collectUnsupportedTsupConfigConsumers( tsupTargets: string[], ): UnsupportedTsupConfigConsumer[] { @@ -219,12 +238,15 @@ function collectUnsupportedTsupConfigConsumers( for (const [scriptName, script] of Object.entries(packageJson.scripts ?? {})) { for (const match of script.matchAll(TSUP_CONFIG_OPTION_RE)) { - const configPath = resolveScriptConfigPath(target, match[1] ?? match[2] ?? match[3]); - const isDefaultConfig = configPath === defaultConfigPath; + const configArgument = match[1] ?? match[2] ?? match[3]; + const configPath = resolveScriptConfigPath(target, configArgument); + const isDefaultConfig = + configPath === defaultConfigPath && + isRemovableDefaultConfigArgument(configArgument, detectedConfig); const isSharedMigratedConfig = migratedConfigPaths.has(configPath) && path.dirname(configPath) !== target; if (!isDefaultConfig && !isSharedMigratedConfig) { - unsupported.push({ configPath, packagePath: target, scriptName }); + unsupported.push({ configArgument, packagePath: target, scriptName }); } } } @@ -413,13 +435,28 @@ export async function migrateTsupToTsdown( showTsdownMigrationOptions(targetLabel); return false; } + const inlineTsupConfigs = collectInlineTsupConfigs(tsupTargets); + if (inlineTsupConfigs.length > 0) { + prompts.log.warn( + `Automatic tsup migration was skipped because these inline tsup configs cannot be migrated automatically:\n${inlineTsupConfigs + .map(({ configPath }) => ` ${displayRelative(configPath, projectPath)}`) + .join('\n')}`, + ); + const firstInlineTarget = inlineTsupConfigs[0].targetPath; + const targetLabel = + firstInlineTarget === rootPath + ? 'the project root' + : displayRelative(firstInlineTarget, projectPath); + showTsdownMigrationOptions(targetLabel); + return false; + } const unsupportedConfigConsumers = collectUnsupportedTsupConfigConsumers(tsupTargets); if (unsupportedConfigConsumers.length > 0) { prompts.log.warn( `Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n${unsupportedConfigConsumers .map( - ({ configPath, packagePath, scriptName }) => - ` ${displayRelative(path.join(packagePath, 'package.json'), projectPath)}#${scriptName} -> ${displayRelative(configPath, projectPath)}`, + ({ configArgument, packagePath, scriptName }) => + ` ${displayRelative(path.join(packagePath, 'package.json'), projectPath)}#${scriptName} -> ${configArgument}`, ) .join('\n')}`, ); From 5d06a6c69213bae99d6d758650b6a1e75b5a72b6 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 23:46:45 +0800 Subject: [PATCH 20/20] fix(migrate): tailor tsup fallback guidance --- .../migration_from_tsup_config_collision.md | 10 ++-- .../migration_from_tsup_custom_config.md | 11 ++-- .../migration_from_tsup_inline_config.md | 10 ++-- ...ation_from_tsup_inline_config_collision.md | 10 ++-- .../cli/src/migration/__tests__/tsup.spec.ts | 60 +++++++++++++++++-- packages/cli/src/migration/migrator/tsup.ts | 52 +++++++++------- 6 files changed, 109 insertions(+), 44 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md index e3015ba7d0..3f49988459 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_config_collision.md @@ -18,10 +18,12 @@ tsup configuration detected. Auto-migrating to tsdown... Automatic tsup migration was skipped because these tsdown configs already exist: tsdown.config.ts -Choose one of these manual migration methods: - 1. Run `vp dlx tsdown-migrate` in the project root. - 2. Use the tsdown migration skill: - https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Resolve this configuration conflict manually: + 1. Merge the tsup and tsdown configurations into `pack` in `vite.config.*`. + 2. Do not run `tsdown-migrate`. It can overwrite the existing tsdown configuration. + +Use the tsdown migration skill for guidance: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md Complete the tsup migration manually, then re-run `vp migrate`. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md index 161a21ba93..c3336cbf6b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_custom_config.md @@ -28,10 +28,13 @@ Automatic tsup migration was skipped because these scripts use configs that cann package.json#build -> configs/legacy.ts package.json#irregular -> ././tsup.config.ts -Choose one of these manual migration methods: - 1. Run `vp dlx tsdown-migrate` in the project root. - 2. Use the tsdown migration skill: - https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Resolve these config paths manually: + 1. Migrate each listed config into `pack` in `vite.config.*`. + 2. Update each listed script. + 3. Do not run `tsdown-migrate`. It cannot safely resolve these config paths. + +Use the tsdown migration skill for guidance: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md Complete the tsup migration manually, then re-run `vp migrate`. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md index f7cea33004..9c4b69c16c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config.md @@ -23,10 +23,12 @@ tsup configuration detected. Auto-migrating to tsdown... Automatic tsup migration was skipped because these inline tsup configs cannot be migrated automatically: package.json#tsup -Choose one of these manual migration methods: - 1. Run `vp dlx tsdown-migrate` in the project root. - 2. Use the tsdown migration skill: - https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Resolve this inline configuration manually: + 1. Move each `package.json#tsup` configuration into `pack` in `vite.config.*`. + 2. Do not run `tsdown-migrate`. Vite+ Pack does not read `package.json#tsdown`. + +Use the tsdown migration skill for guidance: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md Complete the tsup migration manually, then re-run `vp migrate`. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md index 41a28eb9ef..14141390e3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_inline_config_collision.md @@ -26,10 +26,12 @@ tsup configuration detected. Auto-migrating to tsdown... Automatic tsup migration was skipped because these tsdown configs already exist: package.json#tsdown -Choose one of these manual migration methods: - 1. Run `vp dlx tsdown-migrate` in the project root. - 2. Use the tsdown migration skill: - https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md +Resolve this configuration conflict manually: + 1. Merge the tsup and tsdown configurations into `pack` in `vite.config.*`. + 2. Do not run `tsdown-migrate`. It can overwrite the existing tsdown configuration. + +Use the tsdown migration skill for guidance: + https://github.com/rolldown/tsdown/blob/main/skills/tsdown-migrate/SKILL.md Complete the tsup migration manually, then re-run `vp migrate`. ``` diff --git a/packages/cli/src/migration/__tests__/tsup.spec.ts b/packages/cli/src/migration/__tests__/tsup.spec.ts index 51b888be63..bcb270e6b5 100644 --- a/packages/cli/src/migration/__tests__/tsup.spec.ts +++ b/packages/cli/src/migration/__tests__/tsup.spec.ts @@ -45,6 +45,15 @@ function manualMigrationOptions(targetLabel = 'the project root'): string { ].join('\n'); } +function migrationSkillGuidance(instructions: string[]): string { + return [ + ...instructions, + '', + 'Use the tsdown migration skill for guidance:', + ` ${TSDOWN_MIGRATION_SKILL_URL}`, + ].join('\n'); +} + describe('tsup migration', () => { let projectPath: string; @@ -114,7 +123,13 @@ describe('tsup migration', () => { expect(mockWarn).toHaveBeenCalledWith( 'Automatic tsup migration was skipped because these tsdown configs already exist:\n tsdown.config.ts', ); - expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + expect(mockInfo).toHaveBeenCalledWith( + migrationSkillGuidance([ + 'Resolve this configuration conflict manually:', + ' 1. Merge the tsup and tsdown configurations into `pack` in `vite.config.*`.', + ' 2. Do not run `tsdown-migrate`. It can overwrite the existing tsdown configuration.', + ]), + ); }); it('refuses to overwrite an inline tsdown config', async () => { @@ -140,7 +155,13 @@ describe('tsup migration', () => { expect(mockWarn).toHaveBeenCalledWith( 'Automatic tsup migration was skipped because these tsdown configs already exist:\n package.json#tsdown', ); - expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + expect(mockInfo).toHaveBeenCalledWith( + migrationSkillGuidance([ + 'Resolve this configuration conflict manually:', + ' 1. Merge the tsup and tsdown configurations into `pack` in `vite.config.*`.', + ' 2. Do not run `tsdown-migrate`. It can overwrite the existing tsdown configuration.', + ]), + ); }); it('refuses to migrate an inline tsup config', async () => { @@ -165,7 +186,13 @@ describe('tsup migration', () => { expect(mockWarn).toHaveBeenCalledWith( 'Automatic tsup migration was skipped because these inline tsup configs cannot be migrated automatically:\n package.json#tsup', ); - expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + expect(mockInfo).toHaveBeenCalledWith( + migrationSkillGuidance([ + 'Resolve this inline configuration manually:', + ' 1. Move each `package.json#tsup` configuration into `pack` in `vite.config.*`.', + ' 2. Do not run `tsdown-migrate`. Vite+ Pack does not read `package.json#tsdown`.', + ]), + ); }); it('refuses to migrate a script that uses a custom tsup config', async () => { @@ -191,7 +218,14 @@ describe('tsup migration', () => { expect(mockWarn).toHaveBeenCalledWith( 'Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n package.json#build -> configs/legacy.ts', ); - expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + expect(mockInfo).toHaveBeenCalledWith( + migrationSkillGuidance([ + 'Resolve these config paths manually:', + ' 1. Migrate each listed config into `pack` in `vite.config.*`.', + ' 2. Update each listed script.', + ' 3. Do not run `tsdown-migrate`. It cannot safely resolve these config paths.', + ]), + ); }); it('refuses a default config path that cleanup cannot remove', async () => { @@ -215,7 +249,14 @@ describe('tsup migration', () => { expect(mockWarn).toHaveBeenCalledWith( 'Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n package.json#build -> ././tsup.config.ts', ); - expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + expect(mockInfo).toHaveBeenCalledWith( + migrationSkillGuidance([ + 'Resolve these config paths manually:', + ' 1. Migrate each listed config into `pack` in `vite.config.*`.', + ' 2. Update each listed script.', + ' 3. Do not run `tsdown-migrate`. It cannot safely resolve these config paths.', + ]), + ); }); it('refuses to remove selectors for multiple standard tsup configs', async () => { @@ -242,7 +283,14 @@ describe('tsup migration', () => { expect(mockWarn).toHaveBeenCalledWith( 'Automatic tsup migration was skipped because these scripts use configs that cannot be migrated automatically:\n package.json#buildJs -> tsup.config.js', ); - expect(mockInfo).toHaveBeenCalledWith(manualMigrationOptions()); + expect(mockInfo).toHaveBeenCalledWith( + migrationSkillGuidance([ + 'Resolve these config paths manually:', + ' 1. Migrate each listed config into `pack` in `vite.config.*`.', + ' 2. Update each listed script.', + ' 3. Do not run `tsdown-migrate`. It cannot safely resolve these config paths.', + ]), + ); }); it('detects a workspace-only tsup config', () => { diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index eeabbf373c..772ac7bdde 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -31,6 +31,17 @@ function showTsdownMigrationOptions( prompts.log.info(lines.join('\n')); } +function showTsdownMigrationSkillGuidance(instructions: string[]): void { + prompts.log.info( + [ + ...instructions, + '', + 'Use the tsdown migration skill for guidance:', + ` ${TSDOWN_MIGRATION_SKILL_URL}`, + ].join('\n'), + ); +} + export function detectTsupProject( projectPath: string, packages?: WorkspacePackage[], @@ -108,7 +119,6 @@ interface SharedTsupConfig { interface TsupConfigLocation { configPath: string; - targetPath: string; } interface UnsupportedTsupConfigConsumer { @@ -180,13 +190,13 @@ function collectTsupConfigCollisions(tsupTargets: string[]): TsupConfigLocation[ for (const target of tsupTargets) { const tsdownConfig = detectConfigs(target).tsdownConfig; if (tsdownConfig) { - collisions.push({ configPath: path.join(target, tsdownConfig), targetPath: target }); + collisions.push({ configPath: path.join(target, tsdownConfig) }); } const packageJsonPath = path.join(target, 'package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = readJsonFile(packageJsonPath); if (Object.hasOwn(packageJson, 'tsdown')) { - collisions.push({ configPath: `${packageJsonPath}#tsdown`, targetPath: target }); + collisions.push({ configPath: `${packageJsonPath}#tsdown` }); } } } @@ -199,7 +209,7 @@ function collectInlineTsupConfigs(tsupTargets: string[]): TsupConfigLocation[] { if (!fs.existsSync(packageJsonPath) || !Object.hasOwn(readJsonFile(packageJsonPath), 'tsup')) { return []; } - return [{ configPath: `${packageJsonPath}#tsup`, targetPath: target }]; + return [{ configPath: `${packageJsonPath}#tsup` }]; }); } @@ -427,12 +437,11 @@ export async function migrateTsupToTsdown( .map(({ configPath }) => ` ${displayRelative(configPath, projectPath)}`) .join('\n')}`, ); - const firstCollisionTarget = configCollisions[0].targetPath; - const targetLabel = - firstCollisionTarget === rootPath - ? 'the project root' - : displayRelative(firstCollisionTarget, projectPath); - showTsdownMigrationOptions(targetLabel); + showTsdownMigrationSkillGuidance([ + 'Resolve this configuration conflict manually:', + ' 1. Merge the tsup and tsdown configurations into `pack` in `vite.config.*`.', + ' 2. Do not run `tsdown-migrate`. It can overwrite the existing tsdown configuration.', + ]); return false; } const inlineTsupConfigs = collectInlineTsupConfigs(tsupTargets); @@ -442,12 +451,11 @@ export async function migrateTsupToTsdown( .map(({ configPath }) => ` ${displayRelative(configPath, projectPath)}`) .join('\n')}`, ); - const firstInlineTarget = inlineTsupConfigs[0].targetPath; - const targetLabel = - firstInlineTarget === rootPath - ? 'the project root' - : displayRelative(firstInlineTarget, projectPath); - showTsdownMigrationOptions(targetLabel); + showTsdownMigrationSkillGuidance([ + 'Resolve this inline configuration manually:', + ' 1. Move each `package.json#tsup` configuration into `pack` in `vite.config.*`.', + ' 2. Do not run `tsdown-migrate`. Vite+ Pack does not read `package.json#tsdown`.', + ]); return false; } const unsupportedConfigConsumers = collectUnsupportedTsupConfigConsumers(tsupTargets); @@ -460,12 +468,12 @@ export async function migrateTsupToTsdown( ) .join('\n')}`, ); - const firstConsumerTarget = unsupportedConfigConsumers[0].packagePath; - const targetLabel = - firstConsumerTarget === rootPath - ? 'the project root' - : displayRelative(firstConsumerTarget, projectPath); - showTsdownMigrationOptions(targetLabel); + showTsdownMigrationSkillGuidance([ + 'Resolve these config paths manually:', + ' 1. Migrate each listed config into `pack` in `vite.config.*`.', + ' 2. Update each listed script.', + ' 3. Do not run `tsdown-migrate`. It cannot safely resolve these config paths.', + ]); return false; } const sharedConfigs = collectSharedTsupConfigs(projectPath, packages, tsupTargets);