Skip to content

feat: unify package management through Mill with BunModule traits#27

Draft
arcaputo3 wants to merge 3 commits intomainfrom
chore/mill-scalajs-bun-plugin
Draft

feat: unify package management through Mill with BunModule traits#27
arcaputo3 wants to merge 3 commits intomainfrom
chore/mill-scalajs-bun-plugin

Conversation

@arcaputo3
Copy link
Copy Markdown
Contributor

@arcaputo3 arcaputo3 commented Mar 20, 2026

Summary

  • Add BunModule and BunNpmModule traits to build.mill that unify Scala and npm dependency management through Mill
  • BunModule: Uses Bun as Scala.js runtime, overrides run to call bun directly
  • BunNpmModule: Declares npm deps in build.mill, generates package.json, runs bun install via Mill tasks
  • NPM dependencies are now the single source of truth in build.mill — no more manual package.json sync
  • Traits will be extracted to a standalone mill-scalajs-bun plugin repo once validated

New Mill tasks

Task Description
./mill agent.bunInstall Generate package.json + run bun install
./mill agent.generatePackageJson Write package.json to project root for IDE compat
./mill agent.bunOutdated Check for outdated npm packages
./mill agent.run Compile + install + run with Bun (single command)

Why this is better

The root problem: JS library versions in Scala facades drift from npm versions in package.json. SDK bumps (like #25) require manual sync across both. Anthropic pushes patches nearly daily.

With BunNpmModule, npm deps are declared alongside Maven deps in build.mill. Downstream agents (e.g. typst2pptx-scala-agent) can inherit version requirements from scalagent's build graph, eliminating the class of bugs where Scala facades reference types that don't exist in the installed npm version.

Design decisions

  • Tests use Node.js for the JSEnv test adapter (Bun doesn't support stdin piping that the Scala.js test adapter requires). Runtime execution uses Bun.
  • bunInstall writes to Task.dest (Mill convention), not project root. bun.lock is copied in/out for reproducible installs.
  • generatePackageJson is a Task.Command that writes to project root for TypeScript LSP / IDE tooling.

Test plan

  • ./mill agent.compile passes
  • ./mill agent.test passes (all tests green)
  • ./mill examples.compile passes
  • ./mill agent.bunInstall generates correct package.json and installs deps
  • ./mill agent.generatePackageJson writes IDE-compatible package.json
  • ./mill examples.run executes example with Bun (requires API key)

Closes #26

🤖 Generated with Claude Code

Add BunModule and BunNpmModule traits to build.mill that:
- BunModule: Uses Bun as Scala.js runtime, overrides run to call bun directly
- BunNpmModule: Declares npm deps in build.mill, generates package.json,
  runs bun install via Mill tasks (bunInstall, generatePackageJson, bunOutdated)

NPM dependencies are now the single source of truth in build.mill.
package.json is generated via `./mill agent.generatePackageJson` for IDE compat.

Tests use Node.js for the JSEnv test adapter (stdin/com protocol), while all
runtime execution uses Bun. These traits will be extracted to a standalone
mill-scalajs-bun plugin repo once validated.

Closes #26

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Mar 20, 2026

Code Review — PR #27: Unify package management through Mill with BunModule traits

Overall this is a solid design direction — making build.mill the single source of truth for npm deps solves a real class of version-drift bugs. A few issues worth addressing before merging:


Bugs

1. bunLockFile reads from the wrong directory for examples

def bunLockFile: T[PathRef] = Task.Source(moduleDir / "bun.lock")

moduleDir for the examples module will be the examples/ subdirectory, not the project root. Since bun.lock lives at the project root, os.exists(lockSrc) will silently return false and examples will always install without a lock file (non-reproducible installs). npmProjectDir is already the right abstraction here — consider using that:

def bunLockFile: T[PathRef] = Task.Source(npmProjectDir() / "bun.lock")

(Note: this needs careful handling since npmProjectDir returns T[os.Path] and Task.Source needs a path, so you'd want to derive it differently or make bunLockFile depend on npmProjectDir.)

2. bun.lock is never written back after install

bunInstall copies bun.lock in (project root → Task.dest) but never copies it back out after bun install runs. If bun install adds or modifies the lock file, those changes are silently discarded. New transitive deps won't be pinned. Consider copying Task.dest / "bun.lock" back to npmProjectDir() after the install call, or at least document this limitation.

3. publicModules.head can throw

val jsFile = linked.dest.path / linked.publicModules.head.jsFileName

This appears in both BunModule.run and BunNpmModule.run. If publicModules is empty (e.g., ESM module with no exported entry point configured), this throws NoSuchElementException. Safer:

linked.publicModules.headOption match {
  case Some(m) => linked.dest.path / m.jsFileName
  case None    => Task.fail("No public modules found in fastLinkJS output")
}

Design Concerns

4. Triple bunInstall execution (agent + examples + test)

agent, examples, and agent.test each have their own bunInstall task that independently runs bun install with the same deps. On a cold build this triggers three installs of identical packages. Consider whether examples and test can reuse agent.bunInstall().path directly rather than having independent install tasks. The BunNpmTests pattern is particularly expensive — tests re-installing all deps is slow CI overhead.

5. package.json is committed but now generated content

The slimmed-down package.json in the diff is still tracked in git, but generatePackageJson will overwrite it. This creates two sources of truth and potential for git noise whenever generatePackageJson is re-run. Either:

  • Add package.json to .gitignore and document that ./mill agent.generatePackageJson is required for IDE setup, or
  • Keep package.json authoritative and drop generatePackageJson (accepting the original version-drift problem for IDE users).

The PR description says "NPM dependencies are now the single source of truth in build.mill" but the committed package.json contradicts this.

6. generatePackageJson writes outside Task.dest

def generatePackageJson() = Task.Command {
  val dir = npmProjectDir()
  os.write.over(dir / "package.json", content)  // writes to project root!

Writing outside Task.dest is acknowledged in the PR design notes, but it means Mill cannot track this output for invalidation — running it twice has different semantics than running it once. This is fine for a Command (vs. a Task) but worth flagging for the future plugin extraction. Consider marking it clearly in the docs as "side-effecting, not tracked by Mill."


Minor

7. Generated package.json missing name field

packageJsonContent omits name. While private: true prevents publishing, some tools (TypeScript LSP, Bun's module resolution) behave differently with unnamed packages. Consider adding the project name as an override point:

def npmPackageName: T[Option[String]] = Task { None }

8. bunOutdated forces bunInstall first

def bunOutdated() = Task.Command {
  val dest = bunInstall().path  // triggers install!

Checking for outdated packages shouldn't require installing first. Users may want to run bunOutdated on the existing lock file without triggering an install. Consider using packageJsonContent to write a temp package.json and running bun outdated there, or just using npmProjectDir() if a package.json already exists.

9. Boilerplate inheritance in examples

Overriding 4 tasks to delegate to agent is repetitive and error-prone (easy to forget one):

override def npmDeps = agent.npmDeps
override def npmDevDeps = agent.npmDevDeps
override def npmPeerDeps = agent.npmPeerDeps
override def npmProjectDir = agent.npmProjectDir

For the plugin extraction, consider a design that supports declaring a "parent npm module" so deps are inherited automatically. For now, a comment like // Must stay in sync with agent.npm* would at least signal the coupling.


What's good

  • The BunModule/BunNpmModule trait separation is clean.
  • The Node.js fallback for the test adapter (with clear explanation) is the right call.
  • Mill Task.Source for bun.lock is the correct way to declare external file dependencies.
  • Surfacing this as a future plugin (mill-scalajs-bun) is a good long-term plan.

The bugs in items 1–3 are worth fixing before merge. Items 4–5 are design decisions that should at minimum be explicitly documented.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unify package management through Mill + BunJSEnv plugin

1 participant