Problem
packages/cli uses a split build strategy that causes dependency instability:
- tsc compiles local CLI code (
src/**/*.ts minus global modules) → dist/
- 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.js → dist/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
Problem
packages/cliuses a split build strategy that causes dependency instability:src/**/*.tsminus global modules) →dist/src/create/,src/migration/,src/init/,src/mcp/,src/config/,src/staged/) →dist/global/Since tsc doesn't bundle, its output (
dist/*.js) keeps bareimportstatements. Any package imported by tsc-compiled code must be independenciesso 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
devDependenciesfor the bundled code.This creates confusion and bugs:
detect-indent/detect-newlineincident: These were indevDependenciesbecause they're used by migration code (bundled by rolldown). Butsrc/utils/json.ts(compiled by tsc) also imports them, anddist/utils/json.jsis loaded at runtime bydist/init-config.js→dist/bin.js. Result:ERR_MODULE_NOT_FOUNDin the frm-stack E2E test. Fixed by moving todependencies, 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 independencies; the rolldown path inlines them. This makes it hard to reason about which packages need to be runtime dependencies.validateGlobalBundleExternals()inbuild.tsexists specifically to catch cases where rolldown silently externalizes workspace packages. This is a band-aid for the split build problem.Current Build Architecture
Problems with this split:
dependenciesvalidateGlobalBundleExternals,fix-binding-pathplugin,inject-cjs-requireplugin)Proposed Solution
Migrate the entire
packages/clibuild to tsdown (already used bypackages/prompts). tsdown bundles all code, so:devDependencies(inlined at build time)dependenciesTarget Architecture
Key Considerations
../binding/index.js) — resolved at runtime.d.tsfiles, replacing the current tsc-generated typesdefine-config.tsneeds 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 differentlylint-stagedCJS compatibility: Theinject-cjs-requirerolldown plugin handles CJS deps — tsdown may handle this nativelyRelated
detect-indentbug that exposed this issuepackages/cli/build.ts— current build scriptpackages/cli/rolldown.config.ts— current rolldown config