Skip to content

refactor: migrate packages/cli build from tsc+rolldown to tsdown #744

@fengmk2

Description

@fengmk2

Problem

packages/cli uses a split build strategy that causes dependency instability:

  1. tsc compiles local CLI code (src/**/*.ts minus global modules) → dist/
  2. rolldown bundles global CLI modules (src/create/, src/migration/, src/init/, src/mcp/, src/config/, src/staged/) → dist/global/

Since tsc doesn't bundle, its output (dist/*.js) keeps bare import statements. Any package imported by tsc-compiled code must be in dependencies so it's available when vite-plus is installed in other projects.

Rolldown bundles everything inline (except explicitly externalized packages), so those same packages only need to be devDependencies for the bundled code.

This creates confusion and bugs:

  • detect-indent / detect-newline incident: These were in devDependencies because they're used by migration code (bundled by rolldown). But src/utils/json.ts (compiled by tsc) also imports them, and dist/utils/json.js is loaded at runtime by dist/init-config.jsdist/bin.js. Result: ERR_MODULE_NOT_FOUND in the frm-stack E2E test. Fixed by moving to dependencies, but the root cause is the build architecture.

  • Shared utility modules (src/utils/json.ts, src/utils/terminal.ts, etc.) are used by both tsc-compiled and rolldown-bundled code. The tsc path requires their transitive deps in dependencies; the rolldown path inlines them. This makes it hard to reason about which packages need to be runtime dependencies.

  • validateGlobalBundleExternals() in build.ts exists specifically to catch cases where rolldown silently externalizes workspace packages. This is a band-aid for the split build problem.

Current Build Architecture

src/bin.ts ──────────────────────────────── tsc ──→ dist/bin.js
src/init-config.ts ──────────────────────── tsc ──→ dist/init-config.js
src/utils/*.ts ──────────────────────────── tsc ──→ dist/utils/*.js
src/resolve-*.ts ────────────────────────── tsc ──→ dist/resolve-*.js
src/define-config.ts ────────────────────── tsc ──→ dist/define-config.js + .cjs

src/create/bin.ts ───────────────────── rolldown ──→ dist/global/create.js
src/migration/bin.ts ────────────────── rolldown ──→ dist/global/migrate.js
src/config/bin.ts ───────────────────── rolldown ──→ dist/global/config.js
src/mcp/bin.ts ──────────────────────── rolldown ──→ dist/global/mcp.js
src/staged/bin.ts ───────────────────── rolldown ──→ dist/global/staged.js
src/version.ts ──────────────────────── rolldown ──→ dist/global/version.js

Problems with this split:

  • Bare imports in tsc output require runtime dependencies
  • Same utility imported by both paths → unclear dependency classification
  • No tree-shaking for tsc output
  • Two different bundler configs to maintain
  • Hacks needed (validateGlobalBundleExternals, fix-binding-path plugin, inject-cjs-require plugin)

Proposed Solution

Migrate the entire packages/cli build to tsdown (already used by packages/prompts). tsdown bundles all code, so:

  • All third-party packages become devDependencies (inlined at build time)
  • Only packages that must be resolved at runtime (NAPI binding, oxlint/oxfmt binaries, vite-plus-core/test re-exports) stay in dependencies
  • No more confusion about dependency classification
  • Single build tool, single config
  • Tree-shaking for all outputs

Target Architecture

src/bin.ts ──────────────────────────── tsdown ──→ dist/bin.js
src/create/bin.ts ───────────────────── tsdown ──→ dist/global/create.js
src/migration/bin.ts ────────────────── tsdown ──→ dist/global/migrate.js
src/config/bin.ts ───────────────────── tsdown ──→ dist/global/config.js
src/mcp/bin.ts ──────────────────────── tsdown ──→ dist/global/mcp.js
src/staged/bin.ts ───────────────────── tsdown ──→ dist/global/staged.js
src/version.ts ──────────────────────── tsdown ──→ dist/global/version.js
src/define-config.ts ────────────────── tsdown ──→ dist/define-config.js + .cjs + .d.ts

Key Considerations

  • NAPI binding: Must remain external (../binding/index.js) — resolved at runtime
  • Binary packages (oxlint, oxfmt, oxlint-tsgolint): Must remain external — resolved at runtime for platform-specific binaries
  • Re-export shims (vite-plus-core, vite-plus-test): Must remain external — the whole point is to delegate to these packages
  • Type declarations: tsdown generates .d.ts files, replacing the current tsc-generated types
  • CJS output: define-config.ts needs both ESM and CJS output (tsdown supports this)
  • treeshake: false: Currently required for rolldown global modules (dynamic imports treated as pure) — verify if tsdown handles this differently
  • lint-staged CJS compatibility: The inject-cjs-require rolldown plugin handles CJS deps — tsdown may handle this natively

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions