From d2bdc21c793f968c12fb6280ca02a54aa4fde0a7 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Fri, 7 Aug 2026 14:33:02 -0400 Subject: [PATCH 01/18] Make Bun workflows idiomatic for Mill and Scala.js --- .github/workflows/ci.yml | 26 +- .github/workflows/release.yml | 4 +- CHANGELOG.md | 23 +- README.md | 288 +++++++------- build.mill | 13 +- docs/MIGRATING-0.3.md | 68 ++++ example-scalajs/build.mill | 3 +- example-typescript/build.mill | 2 +- examples/build.mill | 18 +- .../resources/invalid-bun-literal/build.mill | 1 + .../resources/managed-bun/build.mill | 13 + .../resources/mixed-workspace/build.mill | 59 +++ .../resources/scalajs-bundle/build.mill | 1 + .../resources/scalajs-bunfig/build.mill | 1 + .../scalajs-dependency-manifests/build.mill | 22 +- .../resources/scalajs-simple/build.mill | 1 + .../resources/scalajs-test/build.mill | 1 + .../resources/scalajs-transitive/build.mill | 2 + .../resources/scalajs-web/build.mill | 21 + .../resources/scalajs-web/index.html | 5 + .../resources/scalajs-web/public/styles.css | 1 + .../resources/scalajs-web/src/Main.scala | 5 + .../resources/typescript-compile/build.mill | 1 - .../resources/typescript-lock/build.mill | 32 ++ .../resources/typescript-web/build.mill | 22 ++ .../resources/typescript-web/index.html | 5 + .../typescript-web/public/styles.css | 1 + .../resources/typescript-web/src/main.ts | 1 + ...unDependencyManifestIntegrationTests.scala | 22 +- .../BunManagedToolchainIntegrationTests.scala | 22 ++ .../mill/bun/BunScalaJSIntegrationTests.scala | 32 +- .../bun/BunTypeScriptIntegrationTests.scala | 31 +- .../bun/BunWorkspaceIntegrationTests.scala | 47 +++ millbun/src/mill/bun/BunManifest.scala | 69 +++- millbun/src/mill/bun/BunToolchainModule.scala | 359 +++++++++++++++++- millbun/src/mill/bun/BunWebSupport.scala | 130 +++++++ millbun/src/mill/bun/BunWorkspaceModule.scala | 156 ++++++++ .../bun/BunTypeScriptModule.scala | 165 +++++++- .../bun/BunTypeScriptWebModule.scala | 114 ++++++ .../scalajslib/bun/BunPublishModule.scala | 49 ++- .../scalajslib/bun/BunScalaJSModule.scala | 188 ++++++--- .../scalajslib/bun/BunScalaJSWebModule.scala | 74 ++++ .../test/src/mill/bun/BunManifestTests.scala | 82 +++- .../test/src/mill/bun/BunToolchainTests.scala | 104 +++++ millbun/test/src/mill/bun/SplitDepTests.scala | 4 +- 45 files changed, 1927 insertions(+), 361 deletions(-) create mode 100644 docs/MIGRATING-0.3.md create mode 100644 millbun/integration/resources/managed-bun/build.mill create mode 100644 millbun/integration/resources/mixed-workspace/build.mill create mode 100644 millbun/integration/resources/scalajs-web/build.mill create mode 100644 millbun/integration/resources/scalajs-web/index.html create mode 100644 millbun/integration/resources/scalajs-web/public/styles.css create mode 100644 millbun/integration/resources/scalajs-web/src/Main.scala create mode 100644 millbun/integration/resources/typescript-lock/build.mill create mode 100644 millbun/integration/resources/typescript-web/build.mill create mode 100644 millbun/integration/resources/typescript-web/index.html create mode 100644 millbun/integration/resources/typescript-web/public/styles.css create mode 100644 millbun/integration/resources/typescript-web/src/main.ts create mode 100644 millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala create mode 100644 millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala create mode 100644 millbun/src/mill/bun/BunWebSupport.scala create mode 100644 millbun/src/mill/bun/BunWorkspaceModule.scala create mode 100644 millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala create mode 100644 millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala create mode 100644 millbun/test/src/mill/bun/BunToolchainTests.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11a0874..d68d02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,31 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Compile - run: ./mill millbun.compile + run: ./mill --no-server millbun.compile - name: Unit Tests - run: ./mill millbun.test + run: ./mill --no-server millbun.test - name: Integration Tests - run: ./mill millbun.integration + run: ./mill --no-server millbun.integration + + managed-bun: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Managed Bun smoke test + env: + MILL_BUN_USE_SYSTEM: 'false' + run: ./mill --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3f63fc..7fe604a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Cache Coursier uses: actions/cache@v4 @@ -91,6 +91,8 @@ jobs: run: ./mill --no-server millbun.test - name: Integration tests + env: + MILL_BUN_USE_SYSTEM: "true" run: ./mill --no-server millbun.integration - name: Reset cached publish metadata diff --git a/CHANGELOG.md b/CHANGELOG.md index da12f99..6ca8938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tag-driven release workflow for Maven Central publishing and GitHub releases. - Release runbook covering secrets, version sweep, annotated tags, and verification. +- Checksum-verified managed Bun 1.3.14 for macOS, Linux, and Windows on x64 and arm64. +- Strict text-lockfile workflow with `bunLock`, frozen installs, and actionable missing-lock failures. +- Canonical `bundle`, `bundleFast`, `compileExecutable`, and `compileExecutables` task names. +- Paired `BunScalaJSWebModule` and `BunTypeScriptWebModule` HTML workflows. +- `npmOptionalDeps`, `npmPeerDeps`, `npmOverrides`, and deterministic dependency conflict detection. +- `BunWorkspaceModule` for one install and lockfile across mixed Scala.js/TypeScript packages. +- Published dependency manifest schema v2 with runtime, optional, and peer requirements. +- `bunDoctor` diagnostics and managed-toolchain CI smoke coverage. + +### Changed + +- Scala.js linking delegates to Mill's standard linker hooks; applications now choose `scalaJSVersion` explicitly. +- Development dependencies are local tooling inputs and are no longer published transitively. +- `bunPackageJsonExtras` rejects dependency fields now represented by typed settings. +- Missing dependency versions are represented as `latest` instead of an empty package.json value. + +### Deprecated + +- Scala.js `bunBundle*` and `bunCompile*` task names in favor of their canonical aliases. +- `bunOptionalDeps` in favor of `npmOptionalDeps`. +- `managedBunExecutable` in favor of `bunExecutableOverride`. +- The TypeScript `bunCompileExecutable: Boolean` switch in favor of the `compileExecutable` task. ## [0.2.1] - Overridable test-time JS env (2026-04-17) @@ -40,4 +62,3 @@ object test extends BunScalaJSTests: super.bunTestJsEnv() + ("NODE_ENV" -> "production") } ``` - diff --git a/README.md b/README.md index 716e277..8c9f970 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,62 @@ # mill-bun-plugin -A [Mill](https://mill-build.org) plugin that adds [Bun](https://bun.sh)-backed workflows for Scala.js and TypeScript projects. +A [Mill](https://mill-build.org) plugin for first-class [Bun](https://bun.sh) workflows in Scala.js and TypeScript projects. -Keeps Mill's task graph, module structure, caching, Scala.js linker integration, and generated `tsconfig` handling — while swapping the JS runtime/package/bundling backend from `node`/`npm`/`esbuild` to Bun. +It keeps Mill's task graph, caching, module relationships, Scala.js linker, and TypeScript configuration while using Bun for dependency installation, execution, tests, bundling, web development, and native executables. ## Requirements - Mill 1.1.5+ -- Bun 1.2+ on PATH - JDK 17+ -## Quick Start +Bun does not need to be installed. The plugin downloads and verifies Bun 1.3.14 by default on supported macOS, Linux, and Windows x64/arm64 hosts. Set `MILL_BUN_USE_SYSTEM=true` to opt into the Bun on `PATH`. -### Scala.js +## Scala.js quick start ```scala //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build import mill.* import mill.bun.bun -import mill.scalajslib.* import mill.scalajslib.api.* import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def moduleKind = Task { ModuleKind.ESModule } - override def bunDeps = Task { Seq(bun"react@^19.0.0") } - override def bunBundleTarget = Task { "browser" } + override def npmDeps = Task { Seq(bun"lodash@^4.17.21") } object test extends BunScalaJSTests, TestModule.Utest } ``` -`BunScalaJSModule` inherits Mill's bundled current Scala.js version, so you configure `scalaVersion` on the module but do not override `scalaJSVersion`. If you keep Scala.js sources at the build root such as `src/`, override `moduleDir = build.moduleDir`; otherwise Mill will look under `/src`. -`BunScalaJSTests` runs the Scala.js test bridge on Bun as the JS runtime. For ESM apps, the test linker falls back to CommonJS so Bun can execute the Scala.js test bridge without the temporary `file:` importer failure that affects `bun run -`. -For published Scala.js libraries that must carry JS runtime dependencies to downstream consumers, mix in `BunPublishModule`. By default it embeds `META-INF/bun/bun-dependencies.json` so consumers keep resolving transitive Bun packages via manifests. If you need to ship a vendored runtime tree as well, set `bunPublishVendoredRuntime = true` and only do so when the resolved closure is platform-independent. +Scala.js versions are explicit: choose the version your application tests against instead of inheriting a plugin-bundled linker. + +```bash +./mill app.bunLock # generate and commit app/bun.lock (or ./bun.lock with moduleDir above) +./mill app.run +./mill app.bundle +./mill app.compileExecutable +./mill app.test.test +``` -### TypeScript +`BunScalaJSModule` delegates `fastLinkJS`, `fullLinkJS`, and test linking to Mill's standard `ScalaJSModule` hooks. That keeps the plugin compatible with Mill's linker lifecycle and removes its former private linker-worker coupling. + +## TypeScript quick start ```scala //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build @@ -58,162 +65,137 @@ import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { override def moduleDir = build.moduleDir - override def npmDeps = Task { Seq("express@4.21.2") } - override def bunBundleTarget = Task { "bun" } + override def npmDeps = Task { Seq("hono@^4.9.0") } object test extends BunTypeScriptTests } ``` -## `bun""` String Interpolator +```bash +./mill app.bunLock +./mill app.run +./mill app.bundle +./mill app.compileExecutable +./mill app.test.test +``` -The `bun"pkg@version"` interpolator provides compile-time validation of Bun package specifiers. Import it with `import mill.bun.bun` and use it in `bunDeps` declarations: +## Web applications + +Use the paired web traits when HTML and static assets are part of the application: ```scala -import mill.bun.bun +object frontend extends BunScalaJSWebModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def moduleKind = Task { ModuleKind.ESModule } +} + +object admin extends BunTypeScriptWebModule +``` + +Both traits use `index.html` as the entrypoint, copy `public/`, generate a minimal page when HTML is absent, and emit an optimized `dist` from `bundle`. -override def bunDeps = Task { Seq( - bun"react@^19.0.0", - bun"@anthropic-ai/claude-agent-sdk@^0.2.90", - bun"zod@^4.0.0" -)} +```bash +./mill --watch frontend.dev # reliable Scala.js relink + browser reload +./mill admin.dev # Bun-native TypeScript HMR +./mill frontend.bundle +./mill admin.bundle ``` -Invalid or empty specifiers are caught at compile time. The interpolator returns a plain `String`, so it works anywhere `npmDeps` or `bunDeps` accepts strings. - -## Modules - -### `BunToolchainModule` - -Base trait providing Bun discovery and execution helpers. - -| Task | Default | Description | -|------|---------|-------------| -| `bunExecutableName` | `"bun"` | Command name for PATH lookup | -| `managedBunExecutable` | `None` | Hook for a downloaded/managed Bun binary | -| `bunEnv` | `Map.empty` | Environment variables for Bun subprocesses | -| `bunLinker` | `"hoisted"` | Bun linker strategy | -| `bunInstallArgs` | `--save-text-lockfile --linker hoisted` | Default install flags | -| `bunLockfiles` | `Seq("bun.lock", "bun.lockb")` | Lockfile names Bun may produce | -| `bunfigFiles` | auto-detected | Workspace `bunfig.toml` / `.bunfig.toml` configs | -| `bunCompileTargets` | `Seq.empty` | Cross-compilation targets (e.g. `"bun-linux-x64"`, `"bun-darwin-arm64"`) | -| `bunCompileResources` | `Seq.empty` | Extra files/directories for `bun build --compile` workspaces | - -### `BunScalaJSModule` - -Extends `ScalaJSModule` with Bun runtime and bundling. - -| Task | Default | Description | -|------|---------|-------------| -| `npmDeps` | `Seq.empty` | JS packages for `@JSImport` resolution | -| `npmDevDeps` | `Seq.empty` | Dev-only JS packages | -| `bunDeps` | `Seq.empty` | JS packages using `bun"pkg@version"` validated syntax | -| `bunDevDeps` | `Seq.empty` | Dev-only JS packages (independent of `npmDevDeps`) | -| `bunOptionalDeps` | `Seq.empty` | Optional JS packages — installed if available, not fatal if missing | -| `unmanagedDeps` | `Seq.empty` | Local tarballs or package directories | -| `bunPackageJsonExtras` | `ujson.Obj()` | Extra fields merged into generated `package.json` | -| `transitiveNpmDeps` | — | Merged `npmDeps` + `bunDeps` from this module, upstream deps, and classpath manifests | -| `transitiveNpmDevDeps` | — | Merged `npmDevDeps` + `bunDevDeps` from this module, upstream deps, and classpath manifests | -| `classpathBunDeps` | — | Runtime deps auto-populated from dependency JAR manifests | -| `classpathBunDevDeps` | — | Dev deps auto-populated from dependency JAR manifests | -| `classpathBunOptionalDeps` | — | Optional deps auto-populated from dependency JAR manifests | -| `bunBundleTarget` | `"browser"` | `bun build --target` value | -| `bunBundleFormat` | `None` | Output format (`esm`, `cjs`) | -| `bunBundleExternal` | `Seq.empty` | Packages treated as external during bundling | -| `bunBundleSplitting` | `false` | Enable code splitting | -| `bunBundleBytecode` | `false` | Emit Bun bytecode | -| `bunBundleArgs` | `Seq.empty` | Extra raw `bun build` flags | -| `bunBinaryName` | module name | Name for compiled executables | -| `bunInstall` | — | Runs `bun install` for linked output | -| `bunBundle` | — | Full Scala.js bundle via `bun build` | -| `bunBundleFast` | — | Fast bundle from `fastLinkJS` | -| `bunCompileExecutable` | — | Standalone Bun executable | -| `bunCompileExecutables` | — | Cross-compile executables per `bunCompileTargets` | - -### `BunWorkersModule` - -Mix into a `BunTypeScriptModule` to bundle worker entry points from the staged compile workspace instead of raw source files. - -| Task | Default | Description | -|------|---------|-------------| -| `workerEntryPoints` | — | Worker sources to bundle | -| `workerSourceRoots` | `Seq(moduleDir)` | Roots used to preserve worker output layout | -| `workerBundleTarget` | `bunBundleTarget()` | `bun build --target` value for workers | -| `workerBundleFormat` | `Some(bunBundleFormat())` | Optional worker bundle format | -| `workerBundleArgs` | `Seq.empty` | Extra raw flags for worker bundling | -| `bundleWorkers` | — | Bundles all workers under `workers/` while preserving relative paths | - -### `BunSQLiteModule` - -Mix into a `BunTypeScriptModule` to discover and include SQLite database files in `bun build --compile` workspaces via `bunCompileResources`. - -| Task | Default | Description | -|------|---------|-------------| -| `sqliteDatabases` | `Seq.empty` | Explicit SQLite database files to include | -| `sqliteDatabaseDir` | `None` | Directory to scan for `.db`, `.sqlite`, `.sqlite3` files | - -### `BunTypeScriptModule` - -Extends Mill's `TypeScriptModule`, replacing npm/node/esbuild with Bun. -For top-level modules whose sources live at the workspace root, set `override def moduleDir = build.moduleDir`. -When Mill's default `src/.ts` entrypoint is absent, the Bun run/bundle tasks fall back to `src/main.ts`, `src/index.ts`, `main.ts`, and `index.ts`. - -| Task | Default | Description | -|------|---------|-------------| -| `bunRunArgs` | `Seq.empty` | Extra flags for `bun run` | -| `bunBundleTarget` | `"bun"` | `bun build --target` value | -| `bunBundleFormat` | `"esm"` or `"cjs"` | Based on `enableEsm` | -| `bunCompileExecutable` | `false` | Emit standalone executable | -| `bunBundlePackagesExternal` | `false` | Treat all packages as external | -| `bunBundleExternal` | `Seq.empty` | Packages treated as external during bundling | -| `bunBinaryName` | module name | Name for compiled executables | -| `bunPackageJsonExtras` | `ujson.Obj()` | Extra fields merged into generated `package.json` | -| `bunBuildArgs` | `Seq.empty` | Extra raw `bun build` flags | -| `bunTestArgs` | `Seq.empty` | Extra raw `bun test` flags | -| `bunCompileExecutables` | — | Cross-compile executables per `bunCompileTargets` | - -Overrides: `npmInstall` (bun install), `compile` (bun x tsc), `run` (bun run), `bundle` (bun build). -Bundle outputs preserve the compiled workspace layout, including `resources/`, and `bunCompileResources` keep their relative paths beneath the module directory. -Ambient typings are selected from `bunBundleTarget`: `bun` installs pinned `@types/bun`, `node` installs pinned `@types/node`, and `browser` installs neither. - -**`BunTypeScriptTests`** inner trait for test modules: - -| Task | Default | Description | -|------|---------|-------------| -| `bunTestTimeout` | `0` | Test timeout in milliseconds (0 = no timeout) | -| `bunTestReporter` | `"default"` | Reporter format: `"default"`, `"junit"`, or `"json"` | -| `bunCoverageReporters` | `Seq("text", "lcov")` | Coverage reporter formats | - -Test commands: `test`, `testWatch`, `testUpdateSnapshots`, `coverage`, `coverageReport`. - -### `BunPublishModule` - -Mix into a published `BunScalaJSModule` when downstream consumers should receive its runtime JS closure automatically. - -Manifests (`META-INF/bun/bun-dependencies.json`) are always published when the module declares any Bun/npm dependencies. Consumer builds scan classpath JARs for these manifests and merge them into their `package.json` via `classpathBunDeps` / `classpathBunDevDeps` / `classpathBunOptionalDeps`. - -Optionally, enable `bunPublishVendoredRuntime = true` to also embed a vendored `node_modules` tree in the JAR. This gives consumers the exact resolved packages without running `bun install` for those transitive deps. Only enable this when the resolved closure is platform-independent — Bun installs can materialize host-specific binaries. - -| Task | Default | Description | -|------|---------|-------------| -| `bunPublishVendoredRuntime` | `false` | Embed `META-INF/bun/node_modules/**` from a local Bun install | -| `bunDependencyManifest` | — | Writes `META-INF/bun/bun-dependencies.json` for this module's direct runtime JS deps | -| `bunPublishedRuntimeInstall` | — | Resolves this module's direct runtime JS closure in an isolated install workspace | -| `bunVendoredRuntimeBundle` | — | Emits `META-INF/bun/node_modules/**` when vendored publishing is enabled | - -## Examples - -See `example-scalajs/` and `example-typescript/` for complete consumer projects, and `examples/build.mill` for the broader multi-module example matrix used during development. +Configure `webEntryPoints`, `webPublicSources`, `webDevPort`, and `webDevArgs` for non-default layouts. + +## Reproducible installs + +Dependency-bearing modules require a source-controlled text `bun.lock` by default. Generate it with the module's `bunLock` command. Normal installs then use `--frozen-lockfile` and fail before resolution when the lock is missing. + +For migration or intentionally ephemeral builds, set `MILL_BUN_REQUIRE_LOCKFILE=false` or override `bunRequireLockfile`. `bunInstallExtraArgs` accepts additional flags but cannot disable the plugin's lockfile safety. + +The dependency model is shared across Scala.js and TypeScript: + +| Setting | Meaning | +|---|---| +| `npmDeps` | Runtime dependencies | +| `npmDevDeps` | Local development/tool dependencies; never published transitively | +| `npmOptionalDeps` | Optional runtime dependencies | +| `npmPeerDeps` | Requirements supplied by the consumer | +| `npmOverrides` | Explicit resolution for otherwise conflicting declarations | +| `bunPackageJsonExtras` | Unmodeled fields such as `scripts`; typed dependency fields are rejected here | + +The `bun"pkg@specifier"` interpolator is an optional compile-time validator for dependency strings. Unversioned dependencies resolve explicitly to `latest`; contradictory requirements fail unless selected by `npmOverrides`. + +## Managed Bun + +Resolution order is: + +1. `bunExecutableOverride` +2. system `PATH` when `bunUseSystem` or `MILL_BUN_USE_SYSTEM=true` +3. checksum-verified managed Bun 1.3.14 + +Use `./mill app.bunDoctor` to print and validate the resolved executable, version, revision, mode, and linker. Custom mirrors or unbundled Bun versions must configure both `bunArchiveUrl` and `bunArchiveSha256`. + +## Mixed Scala.js and TypeScript workspaces + +`BunWorkspaceModule` gives multiple packages one install and one root lockfile: + +```scala +import mill.bun.* + +object scalaApp extends BunScalaJSModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object tsApp extends BunTypeScriptModule { + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object workspace extends BunWorkspaceModule { + def bunWorkspacePackages = Seq(scalaApp, tsApp) +} +``` + +Run `./mill workspace.bunLock` once, commit `workspace/bun.lock`, and use either package normally. The generated root uses Bun workspaces and both member modules link to the same installed `node_modules`. + +## Publishing Scala.js libraries + +Mix `BunPublishModule` into a published Scala.js library to emit `META-INF/bun/bun-dependencies.json`: + +```scala +object ui extends BunScalaJSModule with BunPublishModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def npmPeerDeps = Task { Seq("react@^19.0.0") } +} +``` + +Manifest schema v2 publishes direct runtime, optional, and peer requirements. Development dependencies remain local. Consumers still read schema v1 manifests, but malformed metadata and contradictory requirements fail clearly instead of being ignored or resolved by order. + +`bunPublishVendoredRuntime = true` can additionally embed `node_modules`; use it only for platform-independent dependency closures. + +## Main modules and tasks + +- `BunToolchainModule`: managed/system Bun resolution, lock policy, environment, install flags, and `bunDoctor`. +- `BunScalaJSModule`: Scala.js linking, Bun runtime, `bundle`, `bundleFast`, and executable compilation. +- `BunTypeScriptModule`: Bun-backed install, TypeScript compile, run, bundle, tests, and executables. +- `BunScalaJSWebModule` / `BunTypeScriptWebModule`: paired HTML development and production builds. +- `BunWorkspaceModule`: one install and lockfile for mixed package graphs. +- `BunPublishModule`: transitive npm metadata for published Scala.js libraries. +- `BunWorkersModule`: bundles TypeScript worker entrypoints while preserving layout. +- `BunSQLiteModule`: stages SQLite resources for compiled executables. + +The older Scala.js `bunBundle*` and `bunCompile*` names remain as compatibility aliases during the 0.x migration. New code should use the idiomatic `bundle*` and `compile*` names. ## Development ```bash -./mill millbun.compile # Compile the plugin -./mill millbun.test # Unit tests -./mill millbun.integration # Integration tests (requires Bun on PATH) +./mill --no-server millbun.compile +./mill --no-server millbun.test +MILL_BUN_USE_SYSTEM=true MILL_BUN_REQUIRE_LOCKFILE=false \ + ./mill --no-server millbun.integration ``` -See `docs/RELEASING.md` for the tag-driven Maven Central release workflow. +See [the 0.3 migration guide](docs/MIGRATING-0.3.md), the runnable `example-*` projects, and [the release runbook](docs/RELEASING.md). ## License diff --git a/build.mill b/build.mill index ddef9a2..efe4ec0 100644 --- a/build.mill +++ b/build.mill @@ -8,15 +8,12 @@ object millbun extends ScalaModule, PublishModule { def scalaVersion = "3.8.2" def jvmVersion = "25" def platformSuffix = "_mill1" - def bundledScalaJSVersion = "1.20.2" def artifactName = "mill-bun" def mvnDeps = Seq( - mvn"com.lihaoyi::mill-libs:$millVersion", - mvn"com.lihaoyi::mill-libs-scalajslib-config-1:$millVersion", - mvn"org.scala-js:scalajs-linker_2.13:$bundledScalaJSVersion", - mvn"org.scala-js:scalajs-js-envs_2.13:1.4.0" + // mill-libs is Scala-cross-published but intentionally not Mill-platform-cross-published. + mvn"com.lihaoyi::mill-libs:$millVersion" ) object test extends ScalaTests, TestModule.Utest { @@ -39,7 +36,11 @@ object millbun extends ScalaModule, PublishModule { Seq(Task.env.getOrElse("COURSIER_REPOSITORIES", "ivy2Local|central")) Map( "MILL_EXECUTABLE_PATH" -> millScript.toString, - "COURSIER_REPOSITORIES" -> repos.mkString("|") + "COURSIER_REPOSITORIES" -> repos.mkString("|"), + // Existing fixtures exercise installed Bun behavior. Dedicated tests cover + // managed resolution and strict lockfile failures. + "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "true"), + "MILL_BUN_REQUIRE_LOCKFILE" -> Task.env.getOrElse("MILL_BUN_REQUIRE_LOCKFILE", "false") ) } diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md new file mode 100644 index 0000000..78ada32 --- /dev/null +++ b/docs/MIGRATING-0.3.md @@ -0,0 +1,68 @@ +# Migrating to 0.3 + +Version 0.3 makes dependency installation reproducible by default and aligns Scala.js and TypeScript around the same public vocabulary. + +## Required changes + +### Choose a Scala.js version + +Every `BunScalaJSModule` now defines its own tested Scala.js version: + +```scala +def scalaJSVersion = "1.22.0" +``` + +The plugin no longer ships or invokes its own Scala.js linker worker. Linking delegates to Mill's `ScalaJSModule` implementation. + +### Generate lockfiles + +Run `bunLock` for every dependency-bearing module and commit the resulting `bun.lock` beside that module's sources: + +```bash +./mill app.bunLock +./mill frontend.bunLock +``` + +Subsequent installs are frozen. During a staged migration only, set `MILL_BUN_REQUIRE_LOCKFILE=false`. + +### Move typed dependency fields out of `bunPackageJsonExtras` + +Use `npmDeps`, `npmDevDeps`, `npmOptionalDeps`, `npmPeerDeps`, and `npmOverrides`. `bunPackageJsonExtras` remains available for unmodeled fields such as `scripts`, but now rejects dependency sections so task invalidation and published metadata remain correct. + +## Renamed APIs + +| 0.2 name | 0.3 name | Status | +|---|---|---| +| `bunBundle` | `bundle` | Compatibility alias retained | +| `bunBundleFast` | `bundleFast` | Compatibility alias retained | +| `bunCompileExecutable` | `compileExecutable` | Compatibility alias retained for Scala.js; Boolean TypeScript setting deprecated | +| `bunCompileExecutables` | `compileExecutables` | Compatibility alias retained | +| `bunOptionalDeps` | `npmOptionalDeps` | Deprecated compatibility setting | +| `managedBunExecutable` | `bunExecutableOverride` | Deprecated compatibility setting | + +The old names are planned for removal at 1.0. + +## Toolchain behavior + +The default is now checksum-verified managed Bun 1.3.14. To preserve the old PATH behavior: + +```bash +export MILL_BUN_USE_SYSTEM=true +``` + +The selected executable must report the configured `bunVersion` unless `bunVerifyVersion` is explicitly disabled. Run `./mill app.bunDoctor` when diagnosing toolchain selection. + +## Published dependency manifests + +New JARs use schema v2: + +- runtime, optional, and peer dependencies are published; +- development dependencies are not transitive; +- contradictory requirements fail unless resolved with `npmOverrides`; +- malformed manifests fail rather than disappearing silently. + +Schema v1 remains readable for backward compatibility. + +## Optional workspace migration + +For repositories with several Scala.js or TypeScript modules, introduce a `BunWorkspaceModule`, list the packages in `bunWorkspacePackages`, and point each member's `bunWorkspaceInstall` at `workspace.bunInstall()`. Then replace per-package locks with `workspace/bun.lock`. diff --git a/example-scalajs/build.mill b/example-scalajs/build.mill index 921f9e4..627a43a 100644 --- a/example-scalajs/build.mill +++ b/example-scalajs/build.mill @@ -1,7 +1,7 @@ //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" def mvnDeps = Seq( mvn"org.scala-js::scalajs-dom::2.8.1" diff --git a/example-typescript/build.mill b/example-typescript/build.mill index 20c0bfb..9d52d4e 100644 --- a/example-typescript/build.mill +++ b/example-typescript/build.mill @@ -1,6 +1,6 @@ //| mill-version: 1.1.5 //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build diff --git a/examples/build.mill b/examples/build.mill index 9d35347..a904533 100644 --- a/examples/build.mill +++ b/examples/build.mill @@ -1,7 +1,7 @@ //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build @@ -18,20 +18,22 @@ import mill.javascriptlib.bun.* // --- Web Server (Bun.serve HTTP server) --- // ./mill webServer.run -// ./mill webServer.bunBundle -// ./mill webServer.bunCompileExecutable +// ./mill webServer.bundle +// ./mill webServer.compileExecutable object webServer extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "web-server" / "src") def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } override def bunBundleTarget = Task { "bun" } } // --- Frontend Todo App (browser bundle) --- -// ./mill todoApp.bunBundle +// ./mill todoApp.bundle object todoApp extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "frontend" / "src") def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") override def moduleKind = Task { ModuleKind.ESModule } override def bunBundleTarget = Task { "browser" } @@ -39,18 +41,20 @@ object todoApp extends BunScalaJSModule { // --- Fullstack (shared types + Bun server + browser frontend) --- // ./mill fullstack.server.run -// ./mill fullstack.frontend.bunBundle +// ./mill fullstack.frontend.bundle // ./mill fullstack.__.compile object fullstack extends Module { object shared extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "fullstack" / "shared") def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } } object server extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "fullstack" / "server") def scalaVersion = shared.scalaVersion + def scalaJSVersion = shared.scalaJSVersion override def moduleKind = Task { ModuleKind.ESModule } override def moduleDeps = Seq(shared) override def bunBundleTarget = Task { "bun" } @@ -59,6 +63,7 @@ object fullstack extends Module { object frontend extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "fullstack" / "frontend") def scalaVersion = shared.scalaVersion + def scalaJSVersion = shared.scalaJSVersion override def moduleKind = Task { ModuleKind.ESModule } override def moduleDeps = Seq(shared) def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") @@ -76,7 +81,7 @@ object fullstack extends Module { // ./mill tsLib.compile # TypeScript type-check // ./mill tsLib.test.test # Bun-native tests // ./mill client.bundle # TS browser bundle (imports tsLib) -// ./mill scalaClient.bunBundle # Scala.js browser bundle +// ./mill scalaClient.bundle # Scala.js browser bundle // ./mill backend.run # JVM Cask server on :8080 // ./mill __.compile # compile EVERYTHING @@ -103,6 +108,7 @@ object client extends BunTypeScriptModule { // --- Scala.js browser frontend (alternative client) --- object scalaClient extends BunScalaJSModule { def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") override def moduleKind = Task { ModuleKind.ESModule } override def bunBundleTarget = Task { "browser" } diff --git a/millbun/integration/resources/invalid-bun-literal/build.mill b/millbun/integration/resources/invalid-bun-literal/build.mill index d67f057..548ca07 100644 --- a/millbun/integration/resources/invalid-bun-literal/build.mill +++ b/millbun/integration/resources/invalid-bun-literal/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.CommonJSModule } override def sources = Task.Sources(moduleDir / "src") diff --git a/millbun/integration/resources/managed-bun/build.mill b/millbun/integration/resources/managed-bun/build.mill new file mode 100644 index 0000000..3fa5678 --- /dev/null +++ b/millbun/integration/resources/managed-bun/build.mill @@ -0,0 +1,13 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.bun.* + +object app extends BunToolchainModule { + override def moduleDir = build.moduleDir +} diff --git a/millbun/integration/resources/mixed-workspace/build.mill b/millbun/integration/resources/mixed-workspace/build.mill new file mode 100644 index 0000000..d066eab --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/build.mill @@ -0,0 +1,59 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.bun.* +import mill.javascriptlib.bun.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object scalaApp extends BunScalaJSModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def moduleKind = Task { ModuleKind.ESModule } + override def npmDeps = Task { Seq("is-even@1.0.0") } + override def classpathBunDeps = Task { Seq.empty } + override def classpathBunOptionalDeps = Task { Seq.empty } + override def classpathBunPeerDeps = Task { Seq.empty } + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object typescriptApp extends BunTypeScriptModule { + override def npmDeps = Task { Seq("is-odd@3.0.1") } + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object workspace extends BunWorkspaceModule { + override def moduleDir = build.moduleDir + def bunWorkspacePackages = Seq(scalaApp, typescriptApp) + override def bunExecutable = Task { "stub-bun" } + + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.walk(cwd / "packages") + .filter(_.last == "package.json") + .foreach { packageJson => + val json = ujson.read(os.read(packageJson)) + Seq("dependencies", "devDependencies", "optionalDependencies").foreach { field => + json.obj.get(field).foreach(_.obj.foreach { case (name, version) => + os.write.over( + cwd / "node_modules" / os.RelPath(name) / "package.json", + ujson.Obj("name" -> name, "version" -> version.str).render(), + createFolders = true + ) + }) + } + } + os.write.over(cwd / "bun.lock", "stub lock") + os.write.over(cwd / ".workspace-installed", args.mkString(" ")) + os.call(Seq("true"), cwd = cwd, env = env) + } +} diff --git a/millbun/integration/resources/scalajs-bundle/build.mill b/millbun/integration/resources/scalajs-bundle/build.mill index a8f5e59..7112db3 100644 --- a/millbun/integration/resources/scalajs-bundle/build.mill +++ b/millbun/integration/resources/scalajs-bundle/build.mill @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-bunfig/build.mill b/millbun/integration/resources/scalajs-bunfig/build.mill index eef2273..bbe37e9 100644 --- a/millbun/integration/resources/scalajs-bunfig/build.mill +++ b/millbun/integration/resources/scalajs-bunfig/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill index 3f7f022..d9b0660 100644 --- a/millbun/integration/resources/scalajs-dependency-manifests/build.mill +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* trait StubBunModule extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.CommonJSModule } override def bunExecutable = Task { "stub-bun" } @@ -58,13 +59,14 @@ trait RecordingStubBunModule extends StubBunModule { object localLib extends StubBunModule { override def sources = Task.Sources(moduleDir / "local-lib") - override def bunOptionalDeps = Task { Seq("optional-local@^1.0.0") } + override def npmOptionalDeps = Task { Seq("optional-local@^1.0.0") } } object publishedLib extends StubBunModule with BunPublishModule { override def sources = Task.Sources(moduleDir / "published-lib") override def bunDevDeps = Task { Seq("dev-only@^2.0.0") } - override def bunOptionalDeps = Task { Seq("optional-published@^3.0.0") } + override def npmOptionalDeps = Task { Seq("optional-published@^3.0.0") } + override def npmPeerDeps = Task { Seq("peer-published@^4.0.0") } } object publishedDevOnlyLib extends StubBunModule with BunPublishModule { @@ -75,13 +77,7 @@ object publishedDevOnlyLib extends StubBunModule with BunPublishModule { object publishedVendoredExtraLib extends RecordingStubBunModule with BunPublishModule { override def sources = Task.Sources(moduleDir / "published-lib") override def bunPublishVendoredRuntime = Task { true } - override def bunPackageJsonExtras = Task { - ujson.Obj( - "dependencies" -> ujson.Obj( - "vendored-extra" -> "^4.0.0" - ) - ) - } + override def npmDeps = Task { Seq("vendored-extra@^4.0.0") } } object appLocal extends StubBunModule { @@ -98,13 +94,7 @@ object appPublished extends StubBunModule { object appExtrasOnly extends RecordingStubBunModule { override def sources = Task.Sources(moduleDir / "app-local") - override def bunPackageJsonExtras = Task { - ujson.Obj( - "dependencies" -> ujson.Obj( - "extras-only" -> "^5.0.0" - ) - ) - } + override def npmDeps = Task { Seq("extras-only@^5.0.0") } } object appVendored extends StubBunModule { diff --git a/millbun/integration/resources/scalajs-simple/build.mill b/millbun/integration/resources/scalajs-simple/build.mill index c494138..f1d2d36 100644 --- a/millbun/integration/resources/scalajs-simple/build.mill +++ b/millbun/integration/resources/scalajs-simple/build.mill @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } } diff --git a/millbun/integration/resources/scalajs-test/build.mill b/millbun/integration/resources/scalajs-test/build.mill index bf40d38..0e9b278 100644 --- a/millbun/integration/resources/scalajs-test/build.mill +++ b/millbun/integration/resources/scalajs-test/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-transitive/build.mill b/millbun/integration/resources/scalajs-transitive/build.mill index a64a9a4..059a350 100644 --- a/millbun/integration/resources/scalajs-transitive/build.mill +++ b/millbun/integration/resources/scalajs-transitive/build.mill @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object lib extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } override def sources = Task.Sources(moduleDir / "lib") @@ -22,6 +23,7 @@ object lib extends BunScalaJSModule { object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = lib.scalaVersion + def scalaJSVersion = lib.scalaJSVersion override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-web/build.mill b/millbun/integration/resources/scalajs-web/build.mill new file mode 100644 index 0000000..cbb8d5f --- /dev/null +++ b/millbun/integration/resources/scalajs-web/build.mill @@ -0,0 +1,21 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.scalalib.* +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object app extends BunScalaJSWebModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") + override def mainClass = Some("Main") + override def moduleKind = Task { ModuleKind.ESModule } +} diff --git a/millbun/integration/resources/scalajs-web/index.html b/millbun/integration/resources/scalajs-web/index.html new file mode 100644 index 0000000..75ab5c8 --- /dev/null +++ b/millbun/integration/resources/scalajs-web/index.html @@ -0,0 +1,5 @@ + + + +
+ diff --git a/millbun/integration/resources/scalajs-web/public/styles.css b/millbun/integration/resources/scalajs-web/public/styles.css new file mode 100644 index 0000000..ecce13a --- /dev/null +++ b/millbun/integration/resources/scalajs-web/public/styles.css @@ -0,0 +1 @@ +body { color: rgb(20, 40, 60); } diff --git a/millbun/integration/resources/scalajs-web/src/Main.scala b/millbun/integration/resources/scalajs-web/src/Main.scala new file mode 100644 index 0000000..8b563d3 --- /dev/null +++ b/millbun/integration/resources/scalajs-web/src/Main.scala @@ -0,0 +1,5 @@ +import org.scalajs.dom.document + +object Main: + def main(args: Array[String]): Unit = + document.getElementById("app").textContent = "Hello from Scala.js web" diff --git a/millbun/integration/resources/typescript-compile/build.mill b/millbun/integration/resources/typescript-compile/build.mill index 8fa780e..119dcd7 100644 --- a/millbun/integration/resources/typescript-compile/build.mill +++ b/millbun/integration/resources/typescript-compile/build.mill @@ -17,7 +17,6 @@ object app extends BunTypeScriptModule { "moduleResolution" -> ujson.Str("bundler") ) } - override def bunCompileExecutable = Task { true } override def bunBundleTarget = Task { "bun" } override def bunCompileResources = Task.Sources("embedded") } diff --git a/millbun/integration/resources/typescript-lock/build.mill b/millbun/integration/resources/typescript-lock/build.mill new file mode 100644 index 0000000..c39c529 --- /dev/null +++ b/millbun/integration/resources/typescript-lock/build.mill @@ -0,0 +1,32 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def npmDeps = Task { Seq("is-even@1.0.0") } + override def bunRequireLockfile = Task { true } + override def bunExecutable = Task { "stub-bun" } + + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.write.over(cwd / ".bun-args", args.mkString(" ")) + os.write.over(cwd / "bun.lock", "stub lock") + os.write.over( + cwd / "node_modules" / "is-even" / "package.json", + ujson.Obj("name" -> "is-even", "version" -> "1.0.0").render(), + createFolders = true + ) + os.call(Seq("true"), cwd = cwd, env = env) + } +} diff --git a/millbun/integration/resources/typescript-web/build.mill b/millbun/integration/resources/typescript-web/build.mill new file mode 100644 index 0000000..ac9bebd --- /dev/null +++ b/millbun/integration/resources/typescript-web/build.mill @@ -0,0 +1,22 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptWebModule { + override def moduleDir = build.moduleDir + override def enableEsm = Task { true } + override def bunBundleTarget = Task { "browser" } + override def compilerOptions = Task { + super.compilerOptions() ++ Map( + "module" -> ujson.Str("esnext"), + "moduleResolution" -> ujson.Str("bundler"), + "lib" -> ujson.Arr("es2020", "dom") + ) + } +} diff --git a/millbun/integration/resources/typescript-web/index.html b/millbun/integration/resources/typescript-web/index.html new file mode 100644 index 0000000..3ec81e3 --- /dev/null +++ b/millbun/integration/resources/typescript-web/index.html @@ -0,0 +1,5 @@ + + + +
+ diff --git a/millbun/integration/resources/typescript-web/public/styles.css b/millbun/integration/resources/typescript-web/public/styles.css new file mode 100644 index 0000000..3184cab --- /dev/null +++ b/millbun/integration/resources/typescript-web/public/styles.css @@ -0,0 +1 @@ +body { color: rgb(60, 40, 20); } diff --git a/millbun/integration/resources/typescript-web/src/main.ts b/millbun/integration/resources/typescript-web/src/main.ts new file mode 100644 index 0000000..75cfa88 --- /dev/null +++ b/millbun/integration/resources/typescript-web/src/main.ts @@ -0,0 +1 @@ +document.getElementById("app")!.textContent = "Hello from TypeScript web"; diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 6911ef8..532cd82 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -27,20 +27,17 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(!res.isSuccess) } - test("published dev-only manifests are still emitted") { + test("published dev-only modules do not emit runtime manifests") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedDevOnlyLib.jar") assert(res.isSuccess) val jar = outputPath(tester, "publishedDevOnlyLib.jar") val manifest = BunManifest.readFromJar(jar) - assert(manifest.isDefined) - assert(manifest.get.dependencies.isEmpty) - assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0")) - assert(manifest.get.optionalDependencies.isEmpty) + assert(manifest.isEmpty) } - test("published manifests include dev-only modules") { + test("published manifests exclude local development dependencies") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedLib.jar") assert(res.isSuccess) @@ -48,9 +45,11 @@ object BunDependencyManifestIntegrationTests extends TestSuite { val jar = outputPath(tester, "publishedLib.jar") val manifest = BunManifest.readFromJar(jar) assert(manifest.isDefined) + assert(manifest.get.schemaVersion == 2) assert(manifest.get.dependencies.isEmpty) - assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0")) + assert(manifest.get.devDependencies.isEmpty) assert(manifest.get.optionalDependencies == Map("optional-published" -> "^3.0.0")) + assert(manifest.get.peerDependencies == Map("peer-published" -> "^4.0.0")) } test("published jars stay manifest-only by default") { @@ -71,17 +70,18 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(packageJson("optionalDependencies").obj("optional-local").str == "^1.0.0") } - test("classpath manifests flow dev and optional deps into generated package.json") { + test("classpath manifests flow publishable deps into generated package.json") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("appPublished.bunInstall") assert(res.isSuccess) val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appPublished" / "bunInstall.dest" / "package.json")) - assert(packageJson("devDependencies").obj("dev-only").str == "^2.0.0") + assert(!packageJson("devDependencies").obj.contains("dev-only")) assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0") + assert(packageJson("peerDependencies").obj("peer-published").str == "^4.0.0") } - test("bunInstall runs when bunPackageJsonExtras adds dependencies") { + test("bunInstall runs for typed npm dependencies") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("appExtrasOnly.bunInstall") assert(res.isSuccess) @@ -91,7 +91,7 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(os.exists(installDir / "node_modules" / "extras-only" / "package.json")) } - test("bunPublishedRuntimeInstall runs for vendored extras-only published deps") { + test("bunPublishedRuntimeInstall runs for vendored typed published deps") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedVendoredExtraLib.bunPublishedRuntimeInstall") assert(res.isSuccess) diff --git a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala new file mode 100644 index 0000000..8b85402 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -0,0 +1,22 @@ +package mill.bun + +import mill.testkit.IntegrationTester +import utest.* + +object BunManagedToolchainIntegrationTests extends TestSuite: + val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + def tests: Tests = Tests: + test("resolved toolchain runs the pinned Bun version"): + val tester = new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / "managed-bun", + millExecutable = millExe, + useInMemory = true + ) + val result = tester.eval("app.bunExecutable") + assert(result.isSuccess) + val executable = tester.out("app.bunExecutable").value[String] + val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim + assert(version == "1.3.14") diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index 2138810..ec7bd04 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -61,31 +61,43 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(res.isSuccess) } - test("bunBundle") { + test("bundle") { val tester = this.tester("scalajs-bundle") - val res = tester.eval("app.bunBundle") + val res = tester.eval("app.bundle") assert(res.isSuccess) - val dist = outputPath(tester, "app.bunBundle") + val dist = outputPath(tester, "app.bundle") val mainJs = bundledScript(dist) assert(runBundledScript(mainJs) == "Hello from scala.js with lodash on bun") } - test("bunCompileExecutable") { + test("web bundle includes HTML CSS and JavaScript") { + val tester = this.tester("scalajs-web") + val res = tester.eval("app.bundle") + assert(res.isSuccess) + + val dist = outputPath(tester, "app.bundle") + val files = os.walk(dist).filter(os.isFile) + assert(files.exists(_.ext == "html")) + assert(files.exists(_.ext == "css")) + assert(files.exists(_.ext == "js")) + } + + test("compileExecutable") { val tester = this.tester("scalajs-bundle") - val res = tester.eval("app.bunCompileExecutable") + val res = tester.eval("app.compileExecutable") assert(res.isSuccess) - val executable = outputPath(tester, "app.bunCompileExecutable") + val executable = outputPath(tester, "app.compileExecutable") assert(runExecutable(executable) == "Hello from scala.js with lodash on bun") } test("transitive npm deps") { val tester = this.tester("scalajs-transitive") - val res = tester.eval("app.bunBundle") + val res = tester.eval("app.bundle") assert(res.isSuccess) - val dist = outputPath(tester, "app.bunBundle") + val dist = outputPath(tester, "app.bundle") val mainJs = bundledScript(dist) assert(runBundledScript(mainJs) == "Hello from transitive scala.js bun") } @@ -111,9 +123,9 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(os.exists(linkedDir / "bunfig.toml")) assert(!os.exists(linkedDir / ".npmrc")) - val compileRes = tester.eval("app.bunCompileExecutable") + val compileRes = tester.eval("app.compileExecutable") assert(compileRes.isSuccess) - val compileWorkspace = tester.workspacePath / "out" / "app" / "bunCompileExecutable.dest" / "workspace" + val compileWorkspace = tester.workspacePath / "out" / "app" / "compileExecutable.dest" / "workspace" assert(os.exists(compileWorkspace / "bunfig.toml")) assert(!os.exists(compileWorkspace / ".npmrc")) diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index d7194d9..f6a42db 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -50,6 +50,18 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(run.out.text().trim == "Hello from bundled TypeScript resources!") } + test("web bundle includes HTML CSS and JavaScript") { + val tester = this.tester("typescript-web") + val res = tester.eval("app.bundle") + assert(res.isSuccess) + + val dist = outputPath(tester, "app.bundle") + val files = os.walk(dist).filter(os.isFile) + assert(files.exists(_.ext == "html")) + assert(files.exists(_.ext == "css")) + assert(files.exists(_.ext == "js")) + } + test("run") { val tester = this.tester("typescript-simple") val res = tester.eval("app.run") @@ -61,10 +73,10 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("compile-executable") { val tester = this.tester("typescript-compile") - val res = tester.eval("app.bundle") + val res = tester.eval("app.compileExecutable") assert(res.isSuccess) - val executable = outputPath(tester, "app.bundle") + val executable = outputPath(tester, "app.compileExecutable") val run = os.call( Seq(executable.toString), cwd = executable / os.up @@ -72,6 +84,21 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(run.out.text().trim == "Hello from compiled TypeScript executable!") } + test("strict installs require a source lock and bunLock creates it") { + val tester = this.tester("typescript-lock") + val missingLock = tester.eval("app.npmInstall") + assert(!missingLock.isSuccess) + + val lockResult = tester.eval("app.bunLock") + assert(lockResult.isSuccess) + assert(os.exists(tester.workspacePath / "bun.lock")) + + val installResult = tester.eval("app.npmInstall") + assert(installResult.isSuccess) + val args = os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / ".bun-args") + assert(args.contains("--frozen-lockfile")) + } + test("bun target ambient types are pinned") { val tester = this.tester("typescript-simple") val res = tester.eval("app.npmInstall") diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala new file mode 100644 index 0000000..2d0d6c9 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -0,0 +1,47 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.* + +object BunWorkspaceIntegrationTests extends TestSuite: + val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + private def tester(): IntegrationTester = + new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / "mixed-workspace", + millExecutable = millExe, + useInMemory = true + ) + + private def outputPath(tester: IntegrationTester, selector: String): os.Path = + tester.out(selector).value[PathRef].path + + def tests: Tests = Tests: + test("mixed Scala.js and TypeScript packages share one install"): + val tester = this.tester() + + val lockResult = tester.eval("workspace.bunLock") + assert(lockResult.isSuccess) + assert(os.exists(tester.workspacePath / "bun.lock")) + + val workspaceResult = tester.eval("workspace.bunInstall") + assert(workspaceResult.isSuccess) + val workspaceInstall = outputPath(tester, "workspace.bunInstall") + assert(os.read(workspaceInstall / ".workspace-installed").contains("--frozen-lockfile")) + val rootJson = ujson.read(os.read(workspaceInstall / "package.json")) + assert(rootJson("workspaces").arr.map(_.str).toSet == Set( + "packages/scalaApp", + "packages/typescriptApp" + )) + assert(os.exists(workspaceInstall / "node_modules" / "is-even" / "package.json")) + assert(os.exists(workspaceInstall / "node_modules" / "is-odd" / "package.json")) + + val scalaResult = tester.eval("scalaApp.bunInstall") + val typescriptResult = tester.eval("typescriptApp.npmInstall") + assert(scalaResult.isSuccess) + assert(typescriptResult.isSuccess) + assert(os.isLink(outputPath(tester, "scalaApp.bunInstall") / "node_modules")) + assert(os.isLink(outputPath(tester, "typescriptApp.npmInstall") / "node_modules")) diff --git a/millbun/src/mill/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala index 53e6834..70a60a6 100644 --- a/millbun/src/mill/bun/BunManifest.scala +++ b/millbun/src/mill/bun/BunManifest.scala @@ -16,7 +16,9 @@ import java.util.jar.JarFile final case class BunManifest( dependencies: Map[String, String], devDependencies: Map[String, String], - optionalDependencies: Map[String, String] + optionalDependencies: Map[String, String], + peerDependencies: Map[String, String] = Map.empty, + schemaVersion: Int = 2 ) object BunManifest: @@ -26,25 +28,46 @@ object BunManifest: /** Serialize manifest to JSON. */ def toJson(manifest: BunManifest): ujson.Obj = + if manifest.schemaVersion != 1 && manifest.schemaVersion != 2 then + throw new IllegalArgumentException(s"Unsupported Bun manifest schemaVersion ${manifest.schemaVersion}") + if manifest.schemaVersion == 2 && manifest.devDependencies.nonEmpty then + throw new IllegalArgumentException("Bun manifest schema v2 does not allow devDependencies") val obj = ujson.Obj( - "dependencies" -> ujson.Obj.from(manifest.dependencies.map((k, v) => k -> ujson.Str(v))), - "devDependencies" -> ujson.Obj.from(manifest.devDependencies.map((k, v) => k -> ujson.Str(v))) + "schemaVersion" -> manifest.schemaVersion, + "dependencies" -> dependencyJson(manifest.dependencies) ) + if manifest.schemaVersion == 1 && manifest.devDependencies.nonEmpty then + obj("devDependencies") = dependencyJson(manifest.devDependencies) if manifest.optionalDependencies.nonEmpty then - obj("optionalDependencies") = ujson.Obj.from( - manifest.optionalDependencies.map((k, v) => k -> ujson.Str(v)) - ) + obj("optionalDependencies") = dependencyJson(manifest.optionalDependencies) + if manifest.peerDependencies.nonEmpty then + obj("peerDependencies") = dependencyJson(manifest.peerDependencies) obj + private def dependencyJson(dependencies: Map[String, String]): ujson.Obj = + ujson.Obj.from(dependencies.toSeq.sortBy(_._1).map((name, version) => name -> ujson.Str(version))) + /** Deserialize manifest from JSON. */ def fromJson(json: ujson.Value): BunManifest = val obj = json.obj + val schemaVersion = obj.get("schemaVersion").map(_.num.toInt).getOrElse(1) + if schemaVersion != 1 && schemaVersion != 2 then + throw new IllegalArgumentException(s"Unsupported Bun manifest schemaVersion $schemaVersion") + if schemaVersion == 2 && obj.contains("devDependencies") then + throw new IllegalArgumentException("Bun manifest schema v2 does not allow devDependencies") + def readDeps(key: String): Map[String, String] = - obj.get(key).map(_.obj.map((k, v) => k -> v.str).toMap).getOrElse(Map.empty) + obj.get(key).map { value => + value.obj.map { case (name, specifier) => + name -> specifier.str + }.toMap + }.getOrElse(Map.empty) BunManifest( dependencies = readDeps("dependencies"), devDependencies = readDeps("devDependencies"), - optionalDependencies = readDeps("optionalDependencies") + optionalDependencies = readDeps("optionalDependencies"), + peerDependencies = readDeps("peerDependencies"), + schemaVersion = schemaVersion ) /** Read a manifest from inside a JAR file. Returns None if no manifest is present. */ @@ -58,23 +81,31 @@ object BunManifest: val is = jar.getInputStream(entry) try Some(fromJson(ujson.read(is))) finally is.close() - catch case _: Exception => None finally jar.close() /** Read a manifest from an unpacked directory (e.g., classes output). */ def readFromDir(dirPath: os.Path): Option[BunManifest] = val manifestFile = dirPath / os.RelPath(ManifestPath) if os.exists(manifestFile) then - try Some(fromJson(ujson.read(os.read(manifestFile)))) - catch case _: Exception => None + Some(fromJson(ujson.read(os.read(manifestFile)))) else None - /** Merge multiple manifests into one. Later entries override earlier ones for the same package. */ + /** Merge publishable fields, rejecting contradictions and discarding legacy v1 development metadata. */ def merge(manifests: Seq[BunManifest]): BunManifest = - manifests.foldLeft(empty) { (acc, m) => - BunManifest( - dependencies = acc.dependencies ++ m.dependencies, - devDependencies = acc.devDependencies ++ m.devDependencies, - optionalDependencies = acc.optionalDependencies ++ m.optionalDependencies - ) - } + def mergeField(field: String, values: Seq[Map[String, String]]): Map[String, String] = + values.flatMap(_.toSeq).groupBy(_._1).map { case (name, entries) => + val specifiers = entries.map(_._2).distinct + if specifiers.size > 1 then + throw new IllegalArgumentException( + s"Conflicting $field dependency '$name': ${specifiers.sorted.mkString(", ")}" + ) + name -> specifiers.head + } + + BunManifest( + dependencies = mergeField("runtime", manifests.map(_.dependencies)), + devDependencies = Map.empty, + optionalDependencies = mergeField("optional", manifests.map(_.optionalDependencies)), + peerDependencies = mergeField("peer", manifests.map(_.peerDependencies)), + schemaVersion = 2 + ) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 0e65db9..9965eaf 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -2,18 +2,187 @@ package mill.bun import mill.* import mill.api.BuildCtx +import java.io.{BufferedInputStream, FileInputStream, FileOutputStream} +import java.net.URI +import java.net.http.{HttpClient, HttpRequest, HttpResponse} +import java.security.MessageDigest +import java.util.zip.ZipInputStream object BunToolchainModule { - /** Parse a dependency string like "react@19.1.1" or "@types/node@22.10.9" into (name, version). */ - def splitDep(input: String): (String, ujson.Str) = input match { - case s if s.startsWith("@") => - val withoutAt = s.drop(1) - val parts = withoutAt.split("@", 2) - ("@" + parts(0), ujson.Str(parts.lift(1).getOrElse(""))) - case _ => - val parts = input.split("@", 2) - (parts(0), ujson.Str(parts.lift(1).getOrElse(""))) + private val ModeledPackageJsonFields = Set( + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + "overrides", + "workspaces" + ) + + private[bun] final case class NpmDependency(name: String, specifier: String) + + private[bun] final case class Distribution( + assetName: String, + executableName: String + ) + + private val Bun1314Checksums = Map( + "bun-darwin-aarch64.zip" -> "d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620", + "bun-darwin-x64.zip" -> "4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633", + "bun-linux-aarch64.zip" -> "a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b", + "bun-linux-x64.zip" -> "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f", + "bun-windows-aarch64.zip" -> "89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953", + "bun-windows-x64.zip" -> "0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922" + ) + + private[bun] def distribution(osName: String, architecture: String): Either[String, Distribution] = { + val osPart = osName.toLowerCase match { + case name if name.contains("mac") || name.contains("darwin") => Right("darwin") + case name if name.contains("linux") => Right("linux") + case name if name.contains("windows") => Right("windows") + case other => Left(s"Unsupported operating system '$other'") + } + val archPart = architecture.toLowerCase match { + case "aarch64" | "arm64" => Right("aarch64") + case "amd64" | "x86_64" | "x64" => Right("x64") + case other => Left(s"Unsupported architecture '$other'") + } + + for { + os <- osPart + arch <- archPart + } yield Distribution( + assetName = s"bun-$os-$arch.zip", + executableName = if (os == "windows") "bun.exe" else "bun" + ) + } + + private[bun] def bundledChecksum(version: String, assetName: String): Option[String] = + if (version == "1.3.14") Bun1314Checksums.get(assetName) else None + + private[bun] def sha256(path: os.Path): String = { + val digest = MessageDigest.getInstance("SHA-256") + val stream = new BufferedInputStream(new FileInputStream(path.toIO)) + val buffer = new Array[Byte](64 * 1024) + try { + var read = stream.read(buffer) + while (read >= 0) { + if (read > 0) digest.update(buffer, 0, read) + read = stream.read(buffer) + } + } finally stream.close() + digest.digest().map(byte => f"${byte & 0xff}%02x").mkString + } + + private[bun] def download(url: String, destination: os.Path): Unit = { + os.makeDir.all(destination / os.up) + val client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .build() + val request = HttpRequest.newBuilder(URI.create(url)) + .header("User-Agent", "mill-bun-plugin") + .GET() + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofFile(destination.toNIO)) + if (response.statusCode() / 100 != 2) { + throw new RuntimeException(s"Unable to download Bun from $url: HTTP ${response.statusCode()}") + } + } + + private[bun] def extractExecutable( + archive: os.Path, + executableName: String, + destination: os.Path + ): Unit = { + val zip = new ZipInputStream(new BufferedInputStream(new FileInputStream(archive.toIO))) + var found = false + try { + var entry = zip.getNextEntry + while (entry != null) { + val entryName = entry.getName.replace('\\', '/') + if (!entry.isDirectory && entryName.split('/').lastOption.contains(executableName)) { + os.makeDir.all(destination / os.up) + val output = new FileOutputStream(destination.toIO) + try zip.transferTo(output) + finally output.close() + found = true + } + zip.closeEntry() + entry = zip.getNextEntry + } + } finally zip.close() + + if (!found) throw new RuntimeException(s"Bun archive does not contain $executableName") + if (executableName != "bun.exe" && !destination.toIO.setExecutable(true)) { + throw new RuntimeException(s"Unable to make downloaded Bun executable: $destination") + } + } + + /** Parse package.json-style `name@specifier` declarations without slicing scoped names incorrectly. */ + private[bun] def parseDependency(input: String): Either[String, NpmDependency] = { + val trimmed = input.trim + if (trimmed.isEmpty) Left("Dependency cannot be empty") + else if (trimmed.startsWith("@")) { + val slash = trimmed.indexOf('/') + if (slash <= 1 || slash == trimmed.length - 1) Left(s"Invalid scoped dependency '$input'") + else { + val separator = trimmed.indexOf('@', slash + 1) + val name = if (separator < 0) trimmed else trimmed.take(separator) + val specifier = if (separator < 0) "latest" else trimmed.drop(separator + 1) + if (specifier.isEmpty) Left(s"Dependency '$input' has an empty specifier") + else Right(NpmDependency(name, specifier)) + } + } else { + val separator = trimmed.indexOf('@') + val name = if (separator < 0) trimmed else trimmed.take(separator) + val specifier = if (separator < 0) "latest" else trimmed.drop(separator + 1) + if (name.isEmpty) Left(s"Dependency '$input' has an empty name") + else if (specifier.isEmpty) Left(s"Dependency '$input' has an empty specifier") + else Right(NpmDependency(name, specifier)) + } + } + + /** Parse a dependency for compatibility with the existing public helper. */ + def splitDep(input: String): (String, ujson.Str) = + parseDependency(input) match { + case Right(dep) => dep.name -> ujson.Str(dep.specifier) + case Left(message) => throw new IllegalArgumentException(message) + } + + /** Resolve duplicate declarations deterministically and fail conflicting specs unless overridden. */ + def dependencyPairs( + inputs: Seq[String], + overrides: Map[String, String] = Map.empty + ): Seq[(String, ujson.Str)] = { + val parsed = inputs.map(input => parseDependency(input).fold( + message => throw new IllegalArgumentException(message), + identity + )) + parsed.groupBy(_.name).toSeq.sortBy(_._1).map { case (name, entries) => + val specifiers = entries.map(_.specifier).distinct + val resolved = overrides.get(name).orElse(specifiers match { + case Seq(specifier) => Some(specifier) + case _ => None + }).getOrElse( + throw new IllegalArgumentException( + s"Conflicting npm dependency '$name': ${specifiers.sorted.mkString(", ")}. " + + "Declare npmOverrides to select one specifier." + ) + ) + name -> ujson.Str(resolved) + } + } + + /** Add unmodeled package.json fields without allowing typed dependency data to be replaced. */ + def mergePackageJson(base: ujson.Obj, extras: ujson.Obj): ujson.Obj = { + val conflicts = extras.value.keySet.intersect(ModeledPackageJsonFields).toSeq.sorted + if (conflicts.nonEmpty) { + throw new IllegalArgumentException( + s"bunPackageJsonExtras cannot replace modeled fields: ${conflicts.mkString(", ")}. " + + "Use npmDeps, npmDevDeps, npmOptionalDeps, npmPeerDeps, or npmOverrides." + ) + } + ujson.Obj.from(base.value.toSeq ++ extras.value.toSeq) } /** Build candidate executable names from a base name and PATHEXT extensions. @@ -30,8 +199,9 @@ object BunToolchainModule { val candidates = executableCandidates(name, sys.env.getOrElse("PATHEXT", "")) pathDirs.iterator + .filter(_.nonEmpty) .flatMap(dir => candidates.iterator.map(c => os.Path(dir) / c)) - .find(os.exists(_)) + .find(path => os.isFile(path) && java.nio.file.Files.isExecutable(path.toNIO)) } /** Copy a generated workspace into a fresh task destination, preserving layout. */ @@ -70,18 +240,53 @@ object BunToolchainModule { trait BunToolchainModule extends Module { + /** Tested Bun release downloaded by default when no override is configured. */ + def bunVersion: T[String] = Task { "1.3.14" } + + /** Resolve Bun from PATH instead of using the managed distribution. */ + def bunUseSystem: T[Boolean] = Task { + Task.env.get("MILL_BUN_USE_SYSTEM").exists(_.equalsIgnoreCase("true")) + } + /** Command name used when resolving Bun from PATH. */ def bunExecutableName: T[String] = Task { "bun" } - /** Future hook for a managed/downloaded Bun binary. */ + /** Explicit Bun binary override. */ + def bunExecutableOverride: T[Option[PathRef]] = Task { None } + + /** @deprecated Use [[bunExecutableOverride]]. */ + @deprecated("Use bunExecutableOverride", "0.3.0") def managedBunExecutable: T[Option[PathRef]] = Task { None } + /** Optional managed distribution mirror. Must be paired with [[bunArchiveSha256]]. */ + def bunArchiveUrl: T[Option[String]] = Task { None } + + /** SHA-256 for [[bunArchiveUrl]], or for an unbundled Bun version. */ + def bunArchiveSha256: T[Option[String]] = Task { None } + + /** Verify that an override/system executable matches [[bunVersion]]. */ + def bunVerifyVersion: T[Boolean] = Task { true } + /** Environment passed to Bun subprocesses. */ def bunEnv: T[Map[String, String]] = Task { Map.empty } + /** Explicit resolutions for conflicting transitive npm dependency declarations. */ + def npmOverrides: T[Map[String, String]] = Task { Map.empty } + /** Lockfile names that Bun may produce. */ def bunLockfiles: T[Seq[String]] = Task { Seq("bun.lock", "bun.lockb") } + /** Source-controlled text lockfile for this module. */ + def bunLockfile: T[Option[PathRef]] = Task.Input { + val path = moduleDir / "bun.lock" + if (os.exists(path)) Some(PathRef(path)) else None + } + + /** Require dependency-bearing modules to provide [[bunLockfile]]. */ + def bunRequireLockfile: T[Boolean] = Task { + !Task.env.get("MILL_BUN_REQUIRE_LOCKFILE").exists(_.equalsIgnoreCase("false")) + } + /** Hoisted installs are the safest default for Node-compatible resolution. */ def bunLinker: T[String] = Task { "hoisted" } @@ -89,6 +294,48 @@ trait BunToolchainModule extends Module { Seq("--save-text-lockfile", "--linker", bunLinker()) } + /** Additional install flags. Lockfile safety flags are controlled by the plugin. */ + def bunInstallExtraArgs: T[Seq[String]] = Task { Seq.empty } + + protected def copyBunLockfile(lockfile: Option[PathRef], destination: os.Path): Unit = + lockfile.foreach(ref => os.copy.over(ref.path, destination / "bun.lock", createFolders = true)) + + protected def resolvedBunInstallArgs( + baseArgs: Seq[String], + extraArgs: Seq[String], + hasLockfile: Boolean, + updateLockfile: Boolean + ): Seq[String] = { + val protectedPrefixes = Seq( + "--no-save", + "--lockfile", + "--frozen-lockfile", + "--save-text-lockfile" + ) + val forbidden = extraArgs.filter(arg => protectedPrefixes.exists(arg.startsWith)) + if (forbidden.nonEmpty) { + throw new IllegalArgumentException( + s"bunInstallExtraArgs cannot override lockfile safety: ${forbidden.mkString(", ")}" + ) + } + baseArgs ++ extraArgs ++ + (if (updateLockfile) Seq("--lockfile-only") + else if (hasLockfile) Seq("--frozen-lockfile") + else Seq.empty) + } + + protected def requireBunLockfile( + hasInstallInputs: Boolean, + lockfile: Option[PathRef], + required: Boolean + ): Unit = { + if (hasInstallInputs && required && lockfile.isEmpty) { + throw new RuntimeException( + s"Missing ${moduleDir / "bun.lock"}. Run this module's bunLock command and commit the generated lockfile." + ) + } + } + /** * Bun config files to copy into generated workspaces. * @@ -118,14 +365,92 @@ trait BunToolchainModule extends Module { */ def bunCompileResources: T[Seq[PathRef]] = Task { Seq.empty } - /** Resolve Bun either from a managed binary or from PATH. */ - def bunExecutable: T[String] = Task { - managedBunExecutable() - .map(_.path.toString) - .orElse(BunToolchainModule.findOnPath(bunExecutableName()).map(_.toString)) + private def downloadedBunExecutable: T[PathRef] = Task { + val version = bunVersion() + val dist = BunToolchainModule.distribution( + System.getProperty("os.name", "unknown"), + System.getProperty("os.arch", "unknown") + ).fold( + message => Task.fail(s"$message. Set bunExecutableOverride or bunUseSystem."), + identity + ) + val customUrl = bunArchiveUrl() + val customChecksum = bunArchiveSha256() + if (customUrl.isDefined != customChecksum.isDefined) { + Task.fail("bunArchiveUrl and bunArchiveSha256 must be configured together.") + } + val url = customUrl.getOrElse( + s"https://github.com/oven-sh/bun/releases/download/bun-v$version/${dist.assetName}" + ) + val checksum = customChecksum + .orElse(BunToolchainModule.bundledChecksum(version, dist.assetName)) .getOrElse(Task.fail( - s"Unable to find Bun executable '${bunExecutableName()}'. Put Bun on PATH or override managedBunExecutable." + s"No bundled checksum for Bun $version (${dist.assetName}). Set bunArchiveUrl and bunArchiveSha256." )) + .toLowerCase + + val archive = Task.dest / dist.assetName + val executable = Task.dest / dist.executableName + BunToolchainModule.download(url, archive) + val actual = BunToolchainModule.sha256(archive) + if (actual != checksum) { + Task.fail(s"Bun archive checksum mismatch for $url: expected $checksum, received $actual") + } + BunToolchainModule.extractExecutable(archive, dist.executableName, executable) + PathRef(executable) + } + + private def verifyBunVersion(executable: String, expected: String, verify: Boolean): Unit = { + if (verify) { + val result = os.proc(executable, "--version").call( + check = false, + stdout = os.Pipe, + stderr = os.Pipe + ) + val actual = result.out.text().trim + if (result.exitCode != 0 || actual != expected) { + throw new RuntimeException( + s"Bun version mismatch for '$executable': expected $expected, received " + + (if (actual.nonEmpty) actual else s"exit code ${result.exitCode}") + ) + } + } + } + + /** Resolve Bun from an explicit override, PATH opt-in, or the managed distribution. */ + def bunExecutable: T[String] = Task { + val explicit = bunExecutableOverride().orElse(managedBunExecutable()).map(_.path.toString) + val resolved = explicit.orElse { + if (bunUseSystem()) { + BunToolchainModule.findOnPath(bunExecutableName()).map(_.toString).orElse( + Some(Task.fail( + s"Unable to find Bun executable '${bunExecutableName()}' on PATH. " + + "Disable bunUseSystem to use managed Bun, or set bunExecutableOverride." + )) + ) + } else Some(downloadedBunExecutable().path.toString) + }.get + verifyBunVersion(resolved, bunVersion(), bunVerifyVersion()) + resolved + } + + /** Print and validate the resolved Bun toolchain. */ + def bunDoctor(): Command[Unit] = Task.Command { + val executable = bunExecutable() + val revision = os.proc(executable, "--revision").call( + check = false, + stdout = os.Pipe, + stderr = os.Pipe + ).out.text().trim + val mode = + if (bunExecutableOverride().orElse(managedBunExecutable()).nonEmpty) "override" + else if (bunUseSystem()) "system" + else "managed" + println(s"Bun mode: $mode") + println(s"Bun executable: $executable") + println(s"Bun version: ${bunVersion()}") + if (revision.nonEmpty) println(s"Bun revision: $revision") + println(s"Bun linker: ${bunLinker()}") } /** Run a Bun command. All task values must be resolved before calling this. */ diff --git a/millbun/src/mill/bun/BunWebSupport.scala b/millbun/src/mill/bun/BunWebSupport.scala new file mode 100644 index 0000000..a64af40 --- /dev/null +++ b/millbun/src/mill/bun/BunWebSupport.scala @@ -0,0 +1,130 @@ +package mill.bun + +import mill.api.PathRef + +private[mill] object BunWebSupport: + + def copyPreservingModuleDir(refs: Seq[PathRef], moduleDir: os.Path, destination: os.Path): Unit = + refs.filter(ref => os.exists(ref.path)).foreach { ref => + val source = ref.path + val target = + if source.startsWith(moduleDir) then destination / source.relativeTo(moduleDir) + else destination / source.last + copyPath(source, target) + } + + def copyContents(source: os.Path, destination: os.Path): Unit = + if os.exists(source) then + if os.isDir(source) then + os.walk(source).foreach { path => + val relative = path.relativeTo(source) + if relative.segments.nonEmpty then + val target = destination / relative + if os.isDir(path) then os.makeDir.all(target) + else os.copy.over(path, target, createFolders = true) + } + else copyPath(source, destination / source.last) + + private def copyPath(source: os.Path, target: os.Path): Unit = + if os.isDir(source) then + os.walk(source).foreach { path => + val destination = target / path.relativeTo(source) + if os.isDir(path) then os.makeDir.all(destination) + else os.copy.over(path, destination, createFolders = true) + } + else os.copy.over(source, target, createFolders = true) + + def htmlEntries( + configured: Seq[PathRef], + moduleDir: os.Path, + destination: os.Path, + generatedScript: String + ): Seq[os.Path] = + val copied = configured + .map(_.path) + .filter(path => os.exists(path) && os.isFile(path)) + .map(path => + if path.startsWith(moduleDir) then destination / path.relativeTo(moduleDir) + else destination / path.last + ) + + if copied.nonEmpty then copied + else + val index = destination / "index.html" + os.write.over( + index, + s""" + | + | + |
+ | + |""".stripMargin, + createFolders = true + ) + Seq(index) + + def syncRoots(roots: Seq[(os.Path, os.Path)]): Unit = + roots.foreach { case (source, target) => + if os.exists(source) then copyPath(source, target) + } + + def runDevelopmentServer( + bunExecutable: String, + htmlEntries: Seq[os.Path], + workingDirectory: os.Path, + port: Int, + extraArgs: Seq[String], + environment: Map[String, String], + syncRoots: Seq[(os.Path, os.Path)] = Seq.empty + ): Unit = + val running = new java.util.concurrent.atomic.AtomicBoolean(true) + val syncThread = + if syncRoots.nonEmpty then + val thread = new Thread( + () => + while running.get() do + try + syncRoots.foreach { case (source, target) => + if os.exists(source) then copyChangedFiles(source, target) + } + Thread.sleep(200) + catch + case _: InterruptedException => () + , + "mill-bun-web-sync" + ) + thread.setDaemon(true) + thread.start() + Some(thread) + else None + + val relativeEntries = htmlEntries.map(_.relativeTo(workingDirectory).toString) + val process = os.proc( + Seq(bunExecutable) ++ relativeEntries ++ Seq(s"--port=$port") ++ extraArgs + ).spawn( + cwd = workingDirectory, + env = environment, + stdin = os.Inherit, + stdout = os.Inherit, + stderr = os.Inherit + ) + + try + process.join() + if process.exitCode() != 0 then + throw new RuntimeException(s"Bun development server exited with ${process.exitCode()}") + finally + running.set(false) + syncThread.foreach(_.interrupt()) + if process.isAlive() then process.destroy() + + private def copyChangedFiles(source: os.Path, target: os.Path): Unit = + if os.isDir(source) then + os.walk(source).foreach { path => + val destination = target / path.relativeTo(source) + if os.isDir(path) then os.makeDir.all(destination) + else if !os.exists(destination) || os.mtime(path) != os.mtime(destination) || os.size(path) != os.size(destination) then + os.copy.over(path, destination, createFolders = true) + } + else if !os.exists(target) || os.mtime(source) != os.mtime(target) || os.size(source) != os.size(target) then + os.copy.over(source, target, createFolders = true) diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala new file mode 100644 index 0000000..a651969 --- /dev/null +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -0,0 +1,156 @@ +package mill.bun + +import mill.* +import mill.api.BuildCtx + +/** A Scala.js or TypeScript package participating in a shared Bun workspace. */ +trait BunPackageModule extends Module: + + /** Optional shared workspace install. Override with `Task { Some(workspace.bunInstall()) }`. */ + def bunWorkspaceInstall: T[Option[PathRef]] = Task { None } + + /** npm package name used in the generated workspace. */ + def bunWorkspacePackageName: T[String] + + /** Complete typed package.json used by [[BunWorkspaceModule]]. */ + def bunWorkspacePackageJson: T[ujson.Obj] + + /** Local package archives/directories passed to the workspace install. */ + def bunWorkspaceUnmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } + +/** Root module that gives Scala.js and TypeScript packages one Bun install and lockfile. + * + * Member modules opt in by returning this module's install from `bunWorkspaceInstall`. + */ +trait BunWorkspaceModule extends BunToolchainModule: + + /** Packages included in this workspace. */ + def bunWorkspacePackages: Seq[BunPackageModule] + + /** Root package name. */ + def bunWorkspaceName: T[String] = Task { + val name = toString + if name.nonEmpty then name.replace('.', '-') else "mill-bun-workspace" + } + + /** Unmodeled root package.json fields such as scripts. */ + def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } + + private def npmRc = Task.Source(BuildCtx.workspaceRoot / ".npmrc") + + private def packageDirectory(name: String): String = + name.stripPrefix("@").replace('/', '+') + + private def resolvedPackages: Task[Seq[(String, ujson.Obj, Seq[PathRef])]] = Task.Anon { + Task.traverse(bunWorkspacePackages) { module => + Task.Anon { + ( + module.bunWorkspacePackageName(), + module.bunWorkspacePackageJson(), + module.bunWorkspaceUnmanagedDeps() + ) + } + }() + } + + /** Generated root and member package.json files before installation. */ + def bunWorkspaceLayout: T[PathRef] = Task { + val packages = resolvedPackages() + val duplicateNames = packages.groupBy(_._1).collect { case (name, entries) if entries.size > 1 => name }.toSeq.sorted + if duplicateNames.nonEmpty then + Task.fail(s"Duplicate Bun workspace package names: ${duplicateNames.mkString(", ")}") + + val directories = packages.map((name, _, _) => name -> packageDirectory(name)) + val duplicateDirectories = directories.groupBy(_._2).collect { + case (directory, entries) if entries.size > 1 => directory + }.toSeq.sorted + if duplicateDirectories.nonEmpty then + Task.fail(s"Bun workspace package names map to duplicate directories: ${duplicateDirectories.mkString(", ")}") + + packages.foreach { case (name, json, _) => + val directory = packageDirectory(name) + os.write.over( + Task.dest / "packages" / directory / "package.json", + json.render(indent = 2), + createFolders = true + ) + } + + val root = ujson.Obj( + "name" -> bunWorkspaceName(), + "private" -> true, + "version" -> "0.0.0", + "packageManager" -> s"bun@${bunVersion()}", + "workspaces" -> ujson.Arr.from(directories.map((_, directory) => ujson.Str(s"packages/$directory"))) + ) + if npmOverrides().nonEmpty then + root("overrides") = ujson.Obj.from( + npmOverrides().toSeq.sortBy(_._1).map((name, specifier) => name -> ujson.Str(specifier)) + ) + val merged = BunToolchainModule.mergePackageJson(root, bunPackageJsonExtras()) + os.write.over(Task.dest / "package.json", merged.render(indent = 2), createFolders = true) + PathRef(Task.dest) + } + + private def copyConfigs(destination: os.Path, npmRcPath: os.Path, bunfigs: Seq[PathRef]): Unit = + if os.exists(npmRcPath) then + os.copy.over(npmRcPath, destination / ".npmrc", createFolders = true) + bunfigs.foreach(ref => + os.copy.over(ref.path, destination / ref.path.last, createFolders = true) + ) + + private def hasDependencyInputs(packages: Seq[(String, ujson.Obj, Seq[PathRef])]): Boolean = + val dependencyFields = Seq("dependencies", "devDependencies", "optionalDependencies", "peerDependencies") + packages.exists { case (_, json, unmanaged) => + unmanaged.nonEmpty || dependencyFields.exists(field => json.value.get(field).exists(_.obj.nonEmpty)) + } + + /** Install every member from one source-controlled root `bun.lock`. */ + def bunInstall: T[PathRef] = Task { + val packages = resolvedPackages() + BunToolchainModule.copyWorkspace(bunWorkspaceLayout().path, Task.dest) + copyConfigs(Task.dest, npmRc().path, bunfigFiles()) + + val lockfile = bunLockfile() + requireBunLockfile(hasDependencyInputs(packages), lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, Task.dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ packages.flatMap(_._3).map(_.path.toString), + cwd = Task.dest, + env = bunEnv() + ) + PathRef(Task.dest) + } + + /** Resolve the full workspace and update its source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + val packages = resolvedPackages() + BunToolchainModule.copyWorkspace(bunWorkspaceLayout().path, Task.dest) + copyConfigs(Task.dest, npmRc().path, bunfigFiles()) + copyBunLockfile(bunLockfile(), Task.dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ) ++ packages.flatMap(_._3).map(_.path.toString), + cwd = Task.dest, + env = bunEnv() + ) + + val generated = Task.dest / "bun.lock" + if !os.exists(generated) then Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) + } diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index eece4db..33da9e9 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -3,9 +3,20 @@ package bun import mill.* import os.* -import mill.bun.BunToolchainModule +import mill.bun.{BunPackageModule, BunToolchainModule} -trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { outer => +trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with BunPackageModule { outer => + + /** Optional packages installed when available. */ + def npmOptionalDeps: T[Seq[String]] = Task { Seq.empty } + + /** Peer requirements supplied by consuming packages. */ + def npmPeerDeps: T[Seq[String]] = Task { Seq.empty } + + /** Development dependencies are local tooling inputs, not transitive runtime requirements. */ + override def transitiveNpmDevDeps: T[Seq[String]] = Task { npmDevDeps() } + + override def bunWorkspaceUnmanagedDeps: T[Seq[PathRef]] = transitiveUnmanagedDeps /** Extra flags passed to `bun run`. */ def bunRunArgs: T[Seq[String]] = Task { Seq.empty } @@ -17,6 +28,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out def bunBundleFormat: T[String] = Task { if (enableEsm()) "esm" else "cjs" } /** Emit a standalone executable instead of a JS bundle. */ + @deprecated("Use the compileExecutable task", "0.3.0") def bunCompileExecutable: T[Boolean] = Task { false } /** Treat all packages as external during bundling. */ @@ -66,22 +78,38 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out Seq(s"typescript@${typeScriptVersion()}") ++ ambientTypeDeps() } - private def mkBunPackageJson: Task[Unit] = Task.Anon { - val dest = Task.dest + override def bunWorkspacePackageName: T[String] = Task { moduleName } + + override def bunWorkspacePackageJson: T[ujson.Obj] = Task { val user = packageJson() + val overrides = npmOverrides() val resolved = ujson.Obj.from( user.copy( name = if (user.name.nonEmpty) user.name else moduleName, version = if (user.version.nonEmpty) user.version else "1.0.0", `type` = if (enableEsm()) "module" else user.`type`, - dependencies = ujson.Obj.from(transitiveNpmDeps().map(BunToolchainModule.splitDep)), - devDependencies = ujson.Obj.from((transitiveNpmDevDeps() ++ tsDeps()).map(BunToolchainModule.splitDep)) + dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + devDependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps() ++ tsDeps(), overrides)) ).cleanJson.obj.toSeq ) - val merged = ujson.Obj.from(resolved.value.toSeq ++ bunPackageJsonExtras().value.toSeq) - os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps(), overrides) + val peers = BunToolchainModule.dependencyPairs(npmPeerDeps(), overrides) + if optional.nonEmpty then resolved("optionalDependencies") = ujson.Obj.from(optional) + if peers.nonEmpty then resolved("peerDependencies") = ujson.Obj.from(peers) + if overrides.nonEmpty then + resolved("overrides") = ujson.Obj.from(overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value))) + + BunToolchainModule.mergePackageJson(resolved, bunPackageJsonExtras()) + } + + private def mkBunPackageJson: Task[Unit] = Task.Anon { + os.write.over( + Task.dest / "package.json", + bunWorkspacePackageJson().render(indent = 2), + createFolders = true + ) } private def resolvedBunfigs: Task[Seq[PathRef]] = Task.Anon { @@ -118,14 +146,62 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out mkBunPackageJson() copyBunWorkspaceConfigs() + bunWorkspaceInstall() match + case Some(workspaceInstall) => + val installed = workspaceInstall.path + if os.exists(installed / "node_modules") then + os.symlink(dest / "node_modules", installed / "node_modules") + bunLockfiles().foreach { name => + val source = installed / name + if os.exists(source) then os.symlink(dest / name, source) + } + case None => + val lockfile = bunLockfile() + requireBunLockfile(true, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunToolEnv() + ) + + PathRef(dest) + } + + /** Resolve dependencies and update the source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + if bunWorkspaceInstall().nonEmpty then + Task.fail("This package uses a Bun workspace. Run the workspace module's bunLock command.") + val dest = Task.dest + os.makeDir.all(dest) + mkBunPackageJson() + copyBunWorkspaceConfigs() + copyBunLockfile(bunLockfile(), dest) + runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ) ++ transitiveUnmanagedDeps().map(_.path.toString), cwd = dest, env = bunToolEnv() ) - PathRef(dest) + val generated = dest / "bun.lock" + if (!os.exists(generated)) Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) } /** * Preserve Mill's compile sandbox preparation, but invoke TypeScript through @@ -232,12 +308,44 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out PathRef(outFile) } + /** Build the configured entrypoint as a standalone Bun executable. */ + def compileExecutable: T[PathRef] = Task { + val compileDir = compile().path + val buildDir = Task.dest / "workspace" + val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString + val outFile = Task.dest / bunBinaryName() + + BunToolchainModule.copyWorkspace(compileDir, buildDir) + BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) + BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) + copyCompileResources(bunCompileResources(), buildDir) + + val packagesExternal = if (bunBundlePackagesExternal()) Seq("--packages", "external") else Nil + val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) + runBun( + bunExecutable(), + Seq( + "build", + mainFile, + "--compile", + "--target", + "bun", + "--outfile", + outFile.toString + ) ++ packagesExternal ++ externalArgs ++ bunBuildArgs(), + cwd = buildDir, + env = bunToolEnv() + ) + + PathRef(outFile) + } + /** * Cross-compile standalone executables for each configured target. * Returns a map of target name to executable PathRef. * Requires `bunCompileTargets` to be non-empty. */ - def bunCompileExecutables: T[Map[String, PathRef]] = Task { + def compileExecutables: T[Map[String, PathRef]] = Task { val targets = bunCompileTargets() if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") @@ -276,6 +384,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out }.toMap } + /** Compatibility alias for the canonical cross-platform executable task. */ + @deprecated("Use compileExecutables", "0.3.0") + def bunCompileExecutables: T[Map[String, PathRef]] = Task { compileExecutables() } + /** * Bun-native nested test module. * @@ -302,13 +414,14 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out // test module's transitive deps; we achieve the same by building one // merged package.json before `bun install`. val user = outer.packageJson() - val outerDeps = outer.transitiveNpmDeps().map(BunToolchainModule.splitDep) - val outerDevDeps = (outer.transitiveNpmDevDeps() ++ outer.tsDeps()).map(BunToolchainModule.splitDep) + val overrides = outer.npmOverrides() + val outerDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides) + val outerDevDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDevDeps() ++ outer.tsDeps(), overrides) val outerPackageNames = (outerDeps.iterator ++ outerDevDeps.iterator).map(_._1).toSet // Test-only deps are dev dependencies — they should not appear in the // production dependencies field, matching Bun/npm convention. - val testDevDeps = (transitiveNpmDeps() ++ transitiveNpmDevDeps() ++ this.tsDeps()) - .map(BunToolchainModule.splitDep) + val testDevDeps = BunToolchainModule + .dependencyPairs(transitiveNpmDeps() ++ this.npmDevDeps() ++ this.tsDeps(), overrides) .filterNot { case (name, _) => outerPackageNames.contains(name) } val resolved = ujson.Obj.from( @@ -321,14 +434,32 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out ).cleanJson.obj.toSeq ) - val merged = ujson.Obj.from(resolved.value.toSeq ++ outer.bunPackageJsonExtras().value.toSeq) + val optional = BunToolchainModule.dependencyPairs(outer.npmOptionalDeps(), overrides) + val peers = BunToolchainModule.dependencyPairs(outer.npmPeerDeps(), overrides) + if optional.nonEmpty then resolved("optionalDependencies") = ujson.Obj.from(optional) + if peers.nonEmpty then resolved("peerDependencies") = ujson.Obj.from(peers) + if overrides.nonEmpty then + resolved("overrides") = ujson.Obj.from( + overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value)) + ) + + val merged = BunToolchainModule.mergePackageJson(resolved, outer.bunPackageJsonExtras()) os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) outer.copyBunWorkspaceConfigs() + val lockfile = bunLockfile() + requireBunLockfile(true, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) + runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), cwd = dest, env = outer.bunToolEnv() ) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala new file mode 100644 index 0000000..fe0fc59 --- /dev/null +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -0,0 +1,114 @@ +package mill.javascriptlib +package bun + +import mill.* +import mill.bun.BunWebSupport + +/** TypeScript web application served and bundled through Bun's HTML pipeline. */ +trait BunTypeScriptWebModule extends BunTypeScriptModule: + + /** HTML entrypoints. A minimal index.html is generated when none exist. */ + def webEntryPoints: T[Seq[PathRef]] = Task.Sources(moduleDir / "index.html") + + /** Static web files copied beneath `public/`. */ + def webPublicSources: T[Seq[PathRef]] = Task.Sources(moduleDir / "public") + + /** Browser entrypoint used when the plugin generates index.html. */ + def webScriptEntryPoint: T[PathRef] = Task.Input { + val candidates = Seq( + moduleDir / "src" / "main.ts", + moduleDir / "src" / "main.tsx", + moduleDir / "src" / "index.ts", + moduleDir / "src" / "index.tsx" + ) + PathRef(candidates.find(os.exists).getOrElse( + throw new RuntimeException( + s"No TypeScript web entrypoint found beneath ${moduleDir / "src"}. Override webScriptEntryPoint." + ) + )) + } + + def webDevPort: T[Int] = Task { 3000 } + + def webDevArgs: T[Seq[String]] = Task { Seq.empty } + + private def prepareWebStage( + destination: os.Path, + sourceRefs: Seq[PathRef], + htmlRefs: Seq[PathRef], + publicRefs: Seq[PathRef], + install: os.Path, + configs: Seq[PathRef], + scriptEntryPoint: os.Path + ): Seq[os.Path] = { + BunWebSupport.copyPreservingModuleDir(sourceRefs, moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(htmlRefs, moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(publicRefs, moduleDir, destination) + if os.exists(install / "node_modules") then + os.symlink(destination / "node_modules", install / "node_modules") + os.copy.over(install / "package.json", destination / "package.json", createFolders = true) + configs.foreach(ref => os.copy.over(ref.path, destination / ref.path.last, createFolders = true)) + + val script = "./" + scriptEntryPoint.relativeTo(moduleDir).toString.replace('\\', '/') + BunWebSupport.htmlEntries(htmlRefs, moduleDir, destination, script) + } + + private def webDevelopmentStage: T[PathRef] = Task { + prepareWebStage( + Task.dest, + sources() ++ generatedSources() ++ resources(), + webEntryPoints(), + webPublicSources(), + npmInstall().path, + bunfigFiles(), + webScriptEntryPoint().path + ) + PathRef(Task.dest) + } + + private def webProductionStage: T[PathRef] = Task { + prepareWebStage( + Task.dest, + sources() ++ generatedSources() ++ resources(), + webEntryPoints(), + webPublicSources(), + npmInstall().path, + bunfigFiles(), + webScriptEntryPoint().path + ) + PathRef(Task.dest) + } + + /** Start Bun's HTML development server with source mirroring for native HMR. */ + def dev(): Command[Unit] = Task.Command { + val stage = webDevelopmentStage().path + val script = "./" + webScriptEntryPoint().path.relativeTo(moduleDir).toString.replace('\\', '/') + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, script) + val syncRoots = (sources() ++ generatedSources() ++ resources() ++ webEntryPoints() ++ webPublicSources()) + .filter(ref => os.exists(ref.path) && ref.path.startsWith(moduleDir)) + .map(ref => ref.path -> (stage / ref.path.relativeTo(moduleDir))) + BunWebSupport.runDevelopmentServer( + bunExecutable(), + entries, + stage, + webDevPort(), + webDevArgs(), + bunRuntimeEnv(), + syncRoots + ) + } + + /** Build complete optimized HTML/CSS/JavaScript assets under `dist`. */ + override def bundle: T[PathRef] = Task { + val stage = webProductionStage().path + val script = "./" + webScriptEntryPoint().path.relativeTo(moduleDir).toString.replace('\\', '/') + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, script) + val destination = Task.dest / "dist" + runBun( + bunExecutable(), + Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBuildArgs(), + cwd = stage, + env = bunToolEnv() + ) + PathRef(destination) + } diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 0c29451..e8a1ecb 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -27,27 +27,16 @@ trait BunPublishModule extends BunScalaJSModule { */ def bunPublishVendoredRuntime: T[Boolean] = Task { false } - private def manifestField(extras: ujson.Obj, key: String, fallback: => Map[String, String]): Map[String, String] = - extras.value.get(key) match - case Some(value) => - try value.obj.map((name, version) => name -> version.str).toMap - catch - case e: Exception => - throw new RuntimeException( - s"BunPublishModule bunPackageJsonExtras.$key must be an object of string versions.", - e - ) - case None => fallback - private def resolvedPublishedManifest: Task[BunManifest] = Task.Anon { - val extras = bunPackageJsonExtras() def typed(deps: Seq[String]): Map[String, String] = - deps.map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + BunToolchainModule.dependencyPairs(deps, npmOverrides()).map((k, v) => k -> v.str).toMap BunManifest( - dependencies = manifestField(extras, "dependencies", typed(npmDeps() ++ bunDeps())), - devDependencies = manifestField(extras, "devDependencies", typed(npmDevDeps() ++ bunDevDeps())), - optionalDependencies = manifestField(extras, "optionalDependencies", typed(bunOptionalDeps())) + dependencies = typed(npmDeps() ++ bunDeps()), + devDependencies = Map.empty, + optionalDependencies = typed(npmOptionalDeps() ++ bunOptionalDeps()), + peerDependencies = typed(npmPeerDeps()), + schemaVersion = 2 ) } @@ -73,8 +62,8 @@ trait BunPublishModule extends BunScalaJSModule { os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } - val deps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep) - val optional = bunOptionalDeps().map(BunToolchainModule.splitDep) + val deps = BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()) + val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps() ++ bunOptionalDeps(), npmOverrides()) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, @@ -88,15 +77,22 @@ trait BunPublishModule extends BunScalaJSModule { case ModuleKind.ESModule => base("type") = "module" case _ => () - val merged = ujson.Obj.from(base.value.toSeq ++ bunPackageJsonExtras().value.toSeq) + val merged = BunToolchainModule.mergePackageJson(base, bunPackageJsonExtras()) os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) - val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty || - bunPackageJsonExtras().value.nonEmpty + val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty + val lockfile = bunLockfile() + requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) if hasRuntimeInputs then runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ unmanagedDeps().map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ unmanagedDeps().map(_.path.toString), cwd = dest, env = bunEnv() ) @@ -126,14 +122,13 @@ trait BunPublishModule extends BunScalaJSModule { val manifest = resolvedPublishedManifest() val hasManifest = manifest.dependencies.nonEmpty || - manifest.devDependencies.nonEmpty || - manifest.optionalDependencies.nonEmpty + manifest.optionalDependencies.nonEmpty || + manifest.peerDependencies.nonEmpty val hasVendoredRuntime = bunPublishVendoredRuntime() && ( manifest.dependencies.nonEmpty || manifest.optionalDependencies.nonEmpty || - unmanagedDeps().nonEmpty || - bunPackageJsonExtras().value.nonEmpty + unmanagedDeps().nonEmpty ) (if hasManifest then Seq(bunDependencyManifest()) else Seq.empty) ++ diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index c4fe0a3..51880e1 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -4,14 +4,13 @@ package bun import mill.* import mill.api.BuildCtx import mill.api.JsonFormatters.given -import mill.bun.{BunManifest, BunToolchainModule, BunVendoredNodeModules} +import mill.bun.{BunManifest, BunPackageModule, BunToolchainModule, BunVendoredNodeModules} import mill.javalib.JavaModule import mill.scalajslib.* import mill.scalajslib.api.* -import mill.scalajslib.config.ScalaJSConfigModule import scala.annotation.tailrec -trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { outer => +trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPackageModule { outer => /** JS packages needed by linked Scala.js output, e.g. packages referenced by @JSImport. */ def npmDeps: T[Seq[String]] = Task { Seq.empty } @@ -77,16 +76,23 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } def transitiveNpmDevDeps: T[Seq[String]] = Task { - val moduleNpm = Task.traverse(recursiveInstallBunModuleDeps)(_.npmDevDeps)().flatten - val moduleBun = Task.traverse(recursiveInstallBunModuleDeps)(_.bunDevDeps)().flatten - moduleNpm ++ moduleBun ++ classpathBunDevDeps() ++ npmDevDeps() ++ bunDevDeps() + npmDevDeps() ++ bunDevDeps() } def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task { Task.traverse(recursiveInstallBunModuleDeps)(_.unmanagedDeps)().flatten ++ unmanagedDeps() } + override def bunWorkspaceUnmanagedDeps: T[Seq[PathRef]] = transitiveUnmanagedDeps + /** Optional JS packages — installed if available, not fatal if missing. */ + def npmOptionalDeps: T[Seq[String]] = Task { Seq.empty } + + /** Peer JS packages that must be supplied by the consuming application. */ + def npmPeerDeps: T[Seq[String]] = Task { Seq.empty } + + /** @deprecated Use [[npmOptionalDeps]]. */ + @deprecated("Use npmOptionalDeps", "0.3.0") def bunOptionalDeps: T[Seq[String]] = Task { Seq.empty } // --------------------------------------------------------------------------- @@ -98,7 +104,10 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out classpathBunManifests().flatMap(_.dependencies).map { case (name, version) => s"$name@$version" } } - /** Scan classpath JARs for embedded bun dev-dependency manifests. */ + /** Read legacy schema v1 development metadata for diagnostics. + * @deprecated Development dependencies are not transitive in schema v2. + */ + @deprecated("Development dependencies are local and no longer transitive", "0.3.0") def classpathBunDevDeps: T[Seq[String]] = Task { classpathBunManifests().flatMap(_.devDependencies).map { case (name, version) => s"$name@$version" } } @@ -108,6 +117,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out classpathBunManifests().flatMap(_.optionalDependencies).map { case (name, version) => s"$name@$version" } } + /** Peer packages declared by published Scala.js libraries. */ + def classpathBunPeerDeps: T[Seq[String]] = Task { + classpathBunManifests().flatMap(_.peerDependencies).map { case (name, version) => s"$name@$version" } + } + /** Manifests from classpath entries that do NOT carry vendored node_modules. * Entries with a vendored tree are handled by `mergeVendoredNodeModules` instead. */ @@ -161,23 +175,40 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out if (name.nonEmpty) name.split('.').last.replace('.', '-') else "app" } + @deprecated("Use transitiveNpmOptionalDeps", "0.3.0") def transitiveBunOptionalDeps: T[Seq[String]] = Task { - val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(_.bunOptionalDeps)().flatten - moduleOptional ++ classpathBunOptionalDeps() ++ bunOptionalDeps() + val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(module => Task.Anon { + module.npmOptionalDeps() ++ module.bunOptionalDeps() + })().flatten + moduleOptional ++ classpathBunOptionalDeps() ++ npmOptionalDeps() ++ bunOptionalDeps() } - private def mkBunPackageJson: Task[Unit] = Task.Anon { - val dest = Task.dest - val allOptional = transitiveBunOptionalDeps().map(BunToolchainModule.splitDep) + def transitiveNpmOptionalDeps: T[Seq[String]] = Task { transitiveBunOptionalDeps() } + + def transitiveNpmPeerDeps: T[Seq[String]] = Task { + val modulePeers = Task.traverse(recursiveInstallBunModuleDeps)(_.npmPeerDeps)().flatten + modulePeers ++ classpathBunPeerDeps() ++ npmPeerDeps() + } + + override def bunWorkspacePackageName: T[String] = Task { defaultPackageName } + + override def bunWorkspacePackageJson: T[ujson.Obj] = Task { + val overrides = npmOverrides() + val allOptional = BunToolchainModule.dependencyPairs(transitiveNpmOptionalDeps(), overrides) + val allPeers = BunToolchainModule.dependencyPairs(transitiveNpmPeerDeps(), overrides) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, "version" -> "0.0.0", - "dependencies" -> ujson.Obj.from(transitiveNpmDeps().map(BunToolchainModule.splitDep)), - "devDependencies" -> ujson.Obj.from(transitiveNpmDevDeps().map(BunToolchainModule.splitDep)) + "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + "devDependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps(), overrides)) ) if allOptional.nonEmpty then base("optionalDependencies") = ujson.Obj.from(allOptional) + if allPeers.nonEmpty then + base("peerDependencies") = ujson.Obj.from(allPeers) + if overrides.nonEmpty then + base("overrides") = ujson.Obj.from(overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value))) val packageType = moduleKind() match { @@ -187,8 +218,15 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out packageType.foreach(tpe => base("type") = tpe) - val merged = ujson.Obj.from(base.value.toSeq ++ bunPackageJsonExtras().value.toSeq) - os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + BunToolchainModule.mergePackageJson(base, bunPackageJsonExtras()) + } + + private def mkBunPackageJson: Task[Unit] = Task.Anon { + os.write.over( + Task.dest / "package.json", + bunWorkspacePackageJson().render(indent = 2), + createFolders = true + ) } private def mergeVendoredNodeModules(entries: Seq[os.Path], destNodeModules: os.Path): Unit = @@ -211,17 +249,37 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out val hasInstallInputs = transitiveNpmDeps().nonEmpty || transitiveNpmDevDeps().nonEmpty || - transitiveBunOptionalDeps().nonEmpty || + transitiveNpmOptionalDeps().nonEmpty || + transitiveNpmPeerDeps().nonEmpty || transitiveUnmanagedDeps().nonEmpty || bunPackageJsonExtras().value.nonEmpty - if hasInstallInputs then - runBun( - bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), - cwd = dest, - env = bunEnv() - ) + bunWorkspaceInstall() match + case Some(workspaceInstall) => + val installed = workspaceInstall.path + if os.exists(installed / "node_modules") then + os.symlink(dest / "node_modules", installed / "node_modules") + bunLockfiles().foreach { name => + val source = installed / name + if os.exists(source) then os.symlink(dest / name, source) + } + case None => + val lockfile = bunLockfile() + requireBunLockfile(hasInstallInputs, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) + + if hasInstallInputs then + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) val ownResourceRoots = resources().map(_.path).toSet val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) @@ -230,6 +288,38 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(dest) } + /** Resolve dependencies and update the source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + if bunWorkspaceInstall().nonEmpty then + Task.fail("This package uses a Bun workspace. Run the workspace module's bunLock command.") + val dest = Task.dest + os.makeDir.all(dest) + if (os.exists(npmRc().path)) os.copy.over(npmRc().path, dest / ".npmrc", createFolders = true) + bunfigFiles().foreach { cfg => + os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) + } + mkBunPackageJson() + copyBunLockfile(bunLockfile(), dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ) ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) + + val generated = dest / "bun.lock" + if (!os.exists(generated)) Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) + } + private def resolvedBunConfigs: Task[Seq[PathRef]] = Task.Anon { bunfigFiles() } @@ -312,7 +402,8 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out ) } - def bunBundle: T[PathRef] = Task { + /** Canonical production bundle task. */ + def bundle: T[PathRef] = Task { val linked = fullLinkJS() val outDir = Task.dest / "dist" @@ -342,7 +433,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(outDir) } - def bunBundleFast: T[PathRef] = Task { + @deprecated("Use bundle", "0.3.0") + def bunBundle: T[PathRef] = Task { bundle() } + + /** Canonical fast-development bundle task. */ + def bundleFast: T[PathRef] = Task { val linked = fastLinkJS() val outDir = Task.dest / "dist" @@ -370,11 +465,14 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(outDir) } + @deprecated("Use bundleFast", "0.3.0") + def bunBundleFast: T[PathRef] = Task { bundleFast() } + private def copyCompileResources(resources: Seq[PathRef], dest: os.Path): Unit = BunToolchainModule.copyPathRefs(resources, dest, Seq(moduleDir)) - /** Convenience task for server-side Scala.js entrypoints. */ - def bunCompileExecutable: T[PathRef] = Task { + /** Build a server-side Scala.js entrypoint as a standalone executable. */ + def compileExecutable: T[PathRef] = Task { val linked = fullLinkJS() val buildDir = Task.dest / "workspace" BunToolchainModule.copyWorkspace(linked.dest.path, buildDir) @@ -401,11 +499,14 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(outFile) } + @deprecated("Use compileExecutable", "0.3.0") + def bunCompileExecutable: T[PathRef] = Task { compileExecutable() } + /** * Cross-compile standalone executables for each configured target. * Returns a map of target name to executable PathRef. */ - def bunCompileExecutables: T[Map[String, PathRef]] = Task { + def compileExecutables: T[Map[String, PathRef]] = Task { val targets = bunCompileTargets() if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") @@ -440,7 +541,10 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out }.toMap } - trait BunScalaJSTests extends ScalaJSConfigTests { + @deprecated("Use compileExecutables", "0.3.0") + def bunCompileExecutables: T[Map[String, PathRef]] = Task { compileExecutables() } + + trait BunScalaJSTests extends ScalaJSTests { override def moduleKind: T[ModuleKind] = Task { outer.moduleKind() match { // Bun rejects the temporary file:-URL importer that Scala.js' Node env @@ -489,27 +593,9 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } override protected def testLinkTask: Task[Report] = Task.Anon { - val linkConfig = - outer.moduleKind() match { - case ModuleKind.ESModule => - outer.scalaJSConfig().withModuleKind(org.scalajs.linker.interface.ModuleKind.CommonJSModule) - case _ => - outer.scalaJSConfig() - } - - linkJs( - worker = mill.scalajslib.config.worker.ScalaJSConfigWorkerExternalModule.scalaJSWorker(), - toolsClasspath = scalaJSToolsClasspath(), - runClasspath = scalaJSTestDeps() ++ runClasspath(), - moduleInitializers = testModuleInitializers(), - forceOutJs = false, - testBridgeInit = true, - importMap = scalaJSImportMap(), - config = linkConfig - ).map { linked => - outer.ensureLinkedWorkspace(linked, outer.bunInstall().path, outer.bunLockfiles(), outer.resolvedBunConfigs()) - linked - } + val linked = super.testLinkTask() + outer.ensureLinkedWorkspace(linked, outer.bunInstall().path, outer.bunLockfiles(), outer.resolvedBunConfigs()) + linked } /** Run Scala.js tests through Mill's test bridge with Bun as the JS runtime. */ diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala new file mode 100644 index 0000000..111d336 --- /dev/null +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala @@ -0,0 +1,74 @@ +package mill.scalajslib +package bun + +import mill.* +import mill.bun.{BunToolchainModule, BunWebSupport} + +/** Scala.js web application served and bundled through Bun's HTML pipeline. */ +trait BunScalaJSWebModule extends BunScalaJSModule: + + /** HTML entrypoints. A minimal index.html is generated when none exist. */ + def webEntryPoints: T[Seq[PathRef]] = Task.Sources(moduleDir / "index.html") + + /** Static web files copied beneath `public/`. */ + def webPublicSources: T[Seq[PathRef]] = Task.Sources(moduleDir / "public") + + def webDevPort: T[Int] = Task { 3000 } + + def webDevArgs: T[Seq[String]] = Task { Seq.empty } + + private def webDevelopmentStage: T[PathRef] = Task { + val linked = fastLinkJS() + val destination = Task.dest + BunWebSupport.copyContents(linked.dest.path, destination) + BunWebSupport.copyPreservingModuleDir(webEntryPoints(), moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(webPublicSources(), moduleDir, destination) + + val entrypoint = primaryEntrypoint(linked) + val stableEntrypoint = destination / "main.js" + if entrypoint != stableEntrypoint then os.copy.over(entrypoint, stableEntrypoint, createFolders = true) + BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, destination, "./main.js") + PathRef(destination) + } + + private def webProductionStage: T[PathRef] = Task { + val linked = fullLinkJS() + val destination = Task.dest + BunWebSupport.copyContents(linked.dest.path, destination) + BunWebSupport.copyPreservingModuleDir(webEntryPoints(), moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(webPublicSources(), moduleDir, destination) + + val entrypoint = primaryEntrypoint(linked) + val stableEntrypoint = destination / "main.js" + if entrypoint != stableEntrypoint then os.copy.over(entrypoint, stableEntrypoint, createFolders = true) + BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, destination, "./main.js") + PathRef(destination) + } + + /** Start Bun's HTML development server. Use `mill --watch app.dev` for Scala relinking. */ + def dev(): Command[Unit] = Task.Command { + val stage = webDevelopmentStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, "./main.js") + BunWebSupport.runDevelopmentServer( + bunExecutable(), + entries, + stage, + webDevPort(), + webDevArgs(), + bunEnv() + ) + } + + /** Build complete optimized HTML/CSS/JavaScript assets under `dist`. */ + override def bundle: T[PathRef] = Task { + val stage = webProductionStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, "./main.js") + val destination = Task.dest / "dist" + runBun( + bunExecutable(), + Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBundleArgs(), + cwd = stage, + env = bunEnv() + ) + PathRef(destination) + } diff --git a/millbun/test/src/mill/bun/BunManifestTests.scala b/millbun/test/src/mill/bun/BunManifestTests.scala index 41d1e4a..707ff8a 100644 --- a/millbun/test/src/mill/bun/BunManifestTests.scala +++ b/millbun/test/src/mill/bun/BunManifestTests.scala @@ -8,45 +8,69 @@ object BunManifestTests extends TestSuite { test("empty manifest serialization") { val json = BunManifest.toJson(BunManifest.empty) val parsed = BunManifest.fromJson(json) + assert(parsed.schemaVersion == 2) assert(parsed.dependencies.isEmpty) assert(parsed.devDependencies.isEmpty) assert(parsed.optionalDependencies.isEmpty) + assert(parsed.peerDependencies.isEmpty) } - test("round-trip with dependencies") { + test("schema v2 round-trip with publishable dependencies") { val manifest = BunManifest( dependencies = Map( "@anthropic-ai/claude-agent-sdk" -> "^0.2.90", "zod" -> "^4.0.0" ), - devDependencies = Map("@types/bun" -> "^1.3.5"), - optionalDependencies = Map.empty + devDependencies = Map.empty, + optionalDependencies = Map("fsevents" -> "^2.3.3"), + peerDependencies = Map("react" -> "^19.0.0") ) val json = BunManifest.toJson(manifest) val parsed = BunManifest.fromJson(json) assert(parsed.dependencies == manifest.dependencies) - assert(parsed.devDependencies == manifest.devDependencies) + assert(parsed.optionalDependencies == manifest.optionalDependencies) + assert(parsed.peerDependencies == manifest.peerDependencies) + assert(!json.obj.contains("devDependencies")) } - test("round-trip with optional dependencies") { - val manifest = BunManifest( - dependencies = Map("react" -> "^19.0.0"), - devDependencies = Map.empty, - optionalDependencies = Map("@openai/codex-sdk" -> "^0.118.0") + test("schema v1 remains readable") { + val json = ujson.Obj( + "schemaVersion" -> 1, + "dependencies" -> ujson.Obj("react" -> "^18.0.0"), + "devDependencies" -> ujson.Obj("typescript" -> "^5.0.0") ) - val json = BunManifest.toJson(manifest) val parsed = BunManifest.fromJson(json) - assert(parsed.optionalDependencies == manifest.optionalDependencies) + assert(parsed.schemaVersion == 1) + assert(parsed.dependencies == Map("react" -> "^18.0.0")) + assert(parsed.devDependencies == Map("typescript" -> "^5.0.0")) } test("fromJson handles missing fields") { val json = ujson.Obj("dependencies" -> ujson.Obj("react" -> "19.0.0")) val parsed = BunManifest.fromJson(json) assert(parsed.dependencies == Map("react" -> "19.0.0")) + assert(parsed.schemaVersion == 1) assert(parsed.devDependencies.isEmpty) assert(parsed.optionalDependencies.isEmpty) } + test("schema v2 rejects dev dependencies") { + val json = ujson.Obj( + "schemaVersion" -> 2, + "dependencies" -> ujson.Obj(), + "devDependencies" -> ujson.Obj("typescript" -> "^5.0.0") + ) + val error = intercept[IllegalArgumentException](BunManifest.fromJson(json)) + assert(error.getMessage.contains("does not allow devDependencies")) + } + + test("unknown schema versions fail clearly") { + val error = intercept[IllegalArgumentException]( + BunManifest.fromJson(ujson.Obj("schemaVersion" -> 99)) + ) + assert(error.getMessage.contains("schemaVersion 99")) + } + test("merge combines manifests") { val m1 = BunManifest( Map("react" -> "^19.0.0"), @@ -60,15 +84,25 @@ object BunManifestTests extends TestSuite { ) val merged = BunManifest.merge(Seq(m1, m2)) assert(merged.dependencies == Map("react" -> "^19.0.0", "zod" -> "^4.0.0")) - assert(merged.devDependencies == Map("typescript" -> "^5.0.0")) + assert(merged.devDependencies.isEmpty) assert(merged.optionalDependencies == Map("lodash" -> "^4.17.0")) + assert(merged.schemaVersion == 2) } - test("merge later entries override earlier") { + test("schema v2 serialization rejects development dependencies") { + val manifest = BunManifest( + dependencies = Map.empty, + devDependencies = Map("typescript" -> "^5.0.0"), + optionalDependencies = Map.empty + ) + intercept[IllegalArgumentException](BunManifest.toJson(manifest)) + } + + test("merge rejects conflicting dependency requirements") { val m1 = BunManifest(Map("react" -> "^18.0.0"), Map.empty, Map.empty) val m2 = BunManifest(Map("react" -> "^19.0.0"), Map.empty, Map.empty) - val merged = BunManifest.merge(Seq(m1, m2)) - assert(merged.dependencies("react") == "^19.0.0") + val error = intercept[IllegalArgumentException](BunManifest.merge(Seq(m1, m2))) + assert(error.getMessage.contains("Conflicting runtime dependency 'react'")) } test("readFromDir returns None for missing directory") { @@ -87,6 +121,12 @@ object BunManifestTests extends TestSuite { assert(result.get.dependencies("react") == "^19.0.0") } + test("readFromDir reports malformed manifests") { + val dir = os.temp.dir() + os.write(dir / os.RelPath(BunManifest.ManifestPath), "{", createFolders = true) + intercept[Exception](BunManifest.readFromDir(dir)) + } + test("JAR round-trip: write manifest, read back") { val tmpDir = os.temp.dir() @@ -126,6 +166,18 @@ object BunManifestTests extends TestSuite { assert(manifest.isEmpty) } + test("readFromJar reports malformed manifests") { + val jarPath = os.temp.dir() / "malformed.jar" + val jarOut = new java.util.jar.JarOutputStream(new java.io.FileOutputStream(jarPath.toIO)) + try { + jarOut.putNextEntry(new java.util.jar.JarEntry(BunManifest.ManifestPath)) + jarOut.write("{".getBytes("UTF-8")) + jarOut.closeEntry() + } finally jarOut.close() + + intercept[Exception](BunManifest.readFromJar(jarPath)) + } + test("readFromJar returns None for nonexistent path") { val result = BunManifest.readFromJar(os.Path("/nonexistent/lib.jar")) assert(result.isEmpty) diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala new file mode 100644 index 0000000..9774c7e --- /dev/null +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -0,0 +1,104 @@ +package mill.bun + +import java.io.FileOutputStream +import java.util.zip.{ZipEntry, ZipOutputStream} +import utest.* + +object BunToolchainTests extends TestSuite: + def tests: Tests = Tests: + test("maps supported platforms to official release assets"): + assert( + BunToolchainModule.distribution("Mac OS X", "aarch64") == Right( + BunToolchainModule.Distribution("bun-darwin-aarch64.zip", "bun") + ) + ) + assert( + BunToolchainModule.distribution("Linux", "amd64") == Right( + BunToolchainModule.Distribution("bun-linux-x64.zip", "bun") + ) + ) + assert( + BunToolchainModule.distribution("Windows 11", "x86_64") == Right( + BunToolchainModule.Distribution("bun-windows-x64.zip", "bun.exe") + ) + ) + + test("rejects unsupported managed platforms"): + assert(BunToolchainModule.distribution("Plan 9", "x64").isLeft) + assert(BunToolchainModule.distribution("Linux", "riscv64").isLeft) + + test("bundles checksums for every supported Bun 1.3.14 asset"): + val assets = Seq( + "bun-darwin-aarch64.zip", + "bun-darwin-x64.zip", + "bun-linux-aarch64.zip", + "bun-linux-x64.zip", + "bun-windows-aarch64.zip", + "bun-windows-x64.zip" + ) + assets.foreach: asset => + val checksum = BunToolchainModule.bundledChecksum("1.3.14", asset) + assert(checksum.exists(_.matches("[0-9a-f]{64}"))) + assert(BunToolchainModule.bundledChecksum("1.3.15", assets.head).isEmpty) + + test("computes SHA-256"): + val file = os.temp(contents = "hello") + assert( + BunToolchainModule.sha256(file) == + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ) + + test("extracts the Bun executable from a release-shaped zip"): + val root = os.temp.dir() + val archive = root / "bun.zip" + val output = new ZipOutputStream(new FileOutputStream(archive.toIO)) + try + output.putNextEntry(new ZipEntry("bun-linux-x64/bun")) + output.write("fake-bun".getBytes("UTF-8")) + output.closeEntry() + finally output.close() + + val executable = root / "bin" / "bun" + BunToolchainModule.extractExecutable(archive, "bun", executable) + assert(os.read(executable) == "fake-bun") + assert(executable.toIO.canExecute) + + test("dependency pairs are deterministic and deduplicate identical declarations"): + val pairs = BunToolchainModule.dependencyPairs( + Seq("zod@^4.0.0", "react@^19.0.0", "zod@^4.0.0") + ) + assert(pairs.map((name, version) => name -> version.str) == Seq( + "react" -> "^19.0.0", + "zod" -> "^4.0.0" + )) + + test("dependency conflicts fail unless explicitly overridden"): + val error = intercept[IllegalArgumentException]( + BunToolchainModule.dependencyPairs(Seq("react@^18", "react@^19")) + ) + assert(error.getMessage.contains("Conflicting npm dependency 'react'")) + + val pairs = BunToolchainModule.dependencyPairs( + Seq("react@^18", "react@^19"), + Map("react" -> "19.1.1") + ) + assert(pairs.map((name, version) => name -> version.str) == Seq("react" -> "19.1.1")) + + test("malformed dependency declarations fail clearly"): + Seq("", "react@", "@types", "@types/bun@").foreach: input => + intercept[IllegalArgumentException](BunToolchainModule.splitDep(input)) + + test("package json extras cannot replace typed dependency fields"): + val error = intercept[IllegalArgumentException]( + BunToolchainModule.mergePackageJson( + ujson.Obj("dependencies" -> ujson.Obj("react" -> "^19")), + ujson.Obj("dependencies" -> ujson.Obj("react" -> "latest")) + ) + ) + assert(error.getMessage.contains("cannot replace modeled fields: dependencies")) + + val merged = BunToolchainModule.mergePackageJson( + ujson.Obj("name" -> "app"), + ujson.Obj("scripts" -> ujson.Obj("check" -> "bun test")) + ) + assert(merged("scripts")("check").str == "bun test") diff --git a/millbun/test/src/mill/bun/SplitDepTests.scala b/millbun/test/src/mill/bun/SplitDepTests.scala index 8545a9f..cb34105 100644 --- a/millbun/test/src/mill/bun/SplitDepTests.scala +++ b/millbun/test/src/mill/bun/SplitDepTests.scala @@ -20,13 +20,13 @@ object SplitDepTests extends TestSuite { test("simple package without version") { val (name, version) = BunToolchainModule.splitDep("react") assert(name == "react") - assert(version.str == "") + assert(version.str == "latest") } test("scoped package without version") { val (name, version) = BunToolchainModule.splitDep("@types/bun") assert(name == "@types/bun") - assert(version.str == "") + assert(version.str == "latest") } test("scoped package with latest tag") { From 18b0926e287ac05279ecde00cc93c33fc10bcc7b Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 14:13:27 -0400 Subject: [PATCH 02/18] Pin Bun 1.4.0, compose the asset matrix, share the download cache Bun 1.4.0 shipped six days before #13 pinned 1.3.14 in eleven places, and the pin was hard to move: `bundledChecksum` matched the version with a literal, and `bunArchiveSha256` was rejected unless `bunArchiveUrl` was set too -- contradicting its own scaladoc. - Replace the single-version checksum map and the `version == "1.3.14"` guard with a version -> asset -> sha256 table holding 1.3.14 and 1.4.0. All 24 entries verified against the official SHASUMS256.txt. - Compose asset names from (os, arch, musl, baseline) instead of enumerating, and reject combinations Bun does not publish. This adds the musl (Alpine) and -baseline (pre-AVX2 x64) builds the plugin previously could not download at all: 12 assets per version, not 6. musl is detected from the dynamic loader; baseline is opt-in via `bunUseBaseline` because the JVM cannot see CPU feature flags. - `bunArchiveSha256` now works alone, deriving the URL from `bunVersion`, so running an unbundled Bun is a two-line override. A custom `bunArchiveUrl` still requires a checksum so a mirror stays verified. - Add a checksum-keyed shared download cache. `downloadedBunExecutable` is a task on the trait, so a build with N Bun modules previously downloaded the ~35MB archive N times into N task dests. - `BunToolchainModule.DefaultBunVersion` is the single pin; `bunVersion` and `bunTypesVersion` both derive from it. `@types/bun` is published in lockstep with Bun, so the stale 1.3.11 pin can no longer drift. - Point the integration suite at the managed toolchain by default. It ran with MILL_BUN_USE_SYSTEM=true, so it tested whatever Bun the developer had rather than the version the plugin pins -- and broke outright once the pin moved ahead of the local Bun. The shared cache makes this affordable: the whole suite now uses one download. Unit 57 passing; integration 37 passing against managed Bun 1.4.0 with a single cached archive. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 5 +- README.md | 15 +- build.mill | 8 +- docs/MIGRATING-0.3.md | 2 +- .../resources/typescript-env/build.mill | 3 + .../BunManagedToolchainIntegrationTests.scala | 2 +- .../bun/BunTypeScriptIntegrationTests.scala | 3 +- millbun/src/mill/bun/BunToolchainModule.scala | 210 +++++++++++++++--- .../bun/BunTypeScriptModule.scala | 8 +- .../test/src/mill/bun/BunToolchainTests.scala | 107 ++++++++- 12 files changed, 315 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d68d02a..e36b0e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.14 + bun-version: 1.4.0 - name: Compile run: ./mill --no-server millbun.compile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fe604a..f84f2aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.14 + bun-version: 1.4.0 - name: Cache Coursier uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca8938..1418fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tag-driven release workflow for Maven Central publishing and GitHub releases. - Release runbook covering secrets, version sweep, annotated tags, and verification. -- Checksum-verified managed Bun 1.3.14 for macOS, Linux, and Windows on x64 and arm64. +- Checksum-verified managed Bun 1.4.0 for macOS, Linux, and Windows on x64 and arm64, including + musl (auto-detected) and `-baseline` (via `bunUseBaseline`) builds. +- `bunArchiveSha256` may be set on its own to run a Bun version with no bundled checksum; the + download URL is derived from `bunVersion` and the detected platform. - Strict text-lockfile workflow with `bunLock`, frozen installs, and actionable missing-lock failures. - Canonical `bundle`, `bundleFast`, `compileExecutable`, and `compileExecutables` task names. - Paired `BunScalaJSWebModule` and `BunTypeScriptWebModule` HTML workflows. diff --git a/README.md b/README.md index 8c9f970..aefe80d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It keeps Mill's task graph, caching, module relationships, Scala.js linker, and - Mill 1.1.5+ - JDK 17+ -Bun does not need to be installed. The plugin downloads and verifies Bun 1.3.14 by default on supported macOS, Linux, and Windows x64/arm64 hosts. Set `MILL_BUN_USE_SYSTEM=true` to opt into the Bun on `PATH`. +Bun does not need to be installed. The plugin downloads and checksum-verifies Bun 1.4.0 by default on macOS, Linux, and Windows x64/arm64. musl-based Linux (Alpine) is detected automatically; on x64 CPUs without AVX2, set `bunUseBaseline`. Set `MILL_BUN_USE_SYSTEM=true` to opt into the Bun on `PATH` instead. ## Scala.js quick start @@ -129,9 +129,18 @@ Resolution order is: 1. `bunExecutableOverride` 2. system `PATH` when `bunUseSystem` or `MILL_BUN_USE_SYSTEM=true` -3. checksum-verified managed Bun 1.3.14 +3. checksum-verified managed Bun 1.4.0 -Use `./mill app.bunDoctor` to print and validate the resolved executable, version, revision, mode, and linker. Custom mirrors or unbundled Bun versions must configure both `bunArchiveUrl` and `bunArchiveSha256`. +Use `./mill app.bunDoctor` to print and validate the resolved executable, version, revision, mode, selected release asset, and linker. + +To run a Bun version with no bundled checksum, set `bunVersion` and `bunArchiveSha256` — the download URL is derived from the version and the detected platform: + +```scala +def bunVersion = Task { "1.4.1" } +def bunArchiveSha256 = Task { Some("") } +``` + +Set `bunArchiveUrl` as well to download from a mirror; a mirror always requires `bunArchiveSha256` so the archive stays verified. ## Mixed Scala.js and TypeScript workspaces diff --git a/build.mill b/build.mill index efe4ec0..0219c36 100644 --- a/build.mill +++ b/build.mill @@ -37,9 +37,11 @@ object millbun extends ScalaModule, PublishModule { Map( "MILL_EXECUTABLE_PATH" -> millScript.toString, "COURSIER_REPOSITORIES" -> repos.mkString("|"), - // Existing fixtures exercise installed Bun behavior. Dedicated tests cover - // managed resolution and strict lockfile failures. - "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "true"), + // Default to the managed toolchain, so the suite exercises the same Bun the plugin + // pins rather than whatever the developer happens to have on PATH. The shared + // download cache means all fixtures reuse one archive. Export MILL_BUN_USE_SYSTEM=true + // to run against a local Bun instead. + "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "false"), "MILL_BUN_REQUIRE_LOCKFILE" -> Task.env.getOrElse("MILL_BUN_REQUIRE_LOCKFILE", "false") ) } diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md index 78ada32..e38fa53 100644 --- a/docs/MIGRATING-0.3.md +++ b/docs/MIGRATING-0.3.md @@ -44,7 +44,7 @@ The old names are planned for removal at 1.0. ## Toolchain behavior -The default is now checksum-verified managed Bun 1.3.14. To preserve the old PATH behavior: +The default is now checksum-verified managed Bun 1.4.0. To preserve the old PATH behavior: ```bash export MILL_BUN_USE_SYSTEM=true diff --git a/millbun/integration/resources/typescript-env/build.mill b/millbun/integration/resources/typescript-env/build.mill index b1d1228..22ed549 100644 --- a/millbun/integration/resources/typescript-env/build.mill +++ b/millbun/integration/resources/typescript-env/build.mill @@ -13,5 +13,8 @@ object app extends BunTypeScriptModule { override def npmDevDeps = Task { Seq("typescript@5.7.3") } def bunProxy = Task.Source(moduleDir / "bun-proxy") override def managedBunExecutable = Task { Some(PathRef(bunProxy().path)) } + // The proxy execs whatever Bun is on PATH, so its --version reports the developer's Bun rather + // than bunVersion. This fixture tests env propagation, not the version pin. + override def bunVerifyVersion = Task { false } override def bunEnv = Task { Map("BUN_PROXY_MARKER" -> "present") } } diff --git a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala index 8b85402..3aae15f 100644 --- a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -19,4 +19,4 @@ object BunManagedToolchainIntegrationTests extends TestSuite: assert(result.isSuccess) val executable = tester.out("app.bunExecutable").value[String] val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim - assert(version == "1.3.14") + assert(version == "1.4.0") diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index f6a42db..ae52d1a 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -107,7 +107,8 @@ object BunTypeScriptIntegrationTests extends TestSuite { val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) val devDeps = packageJson("devDependencies").obj - assert(devDeps("@types/bun").str == "1.3.11") + // Pinned, and pinned to the Bun we ship: @types/bun is published in lockstep with Bun. + assert(devDeps("@types/bun").str == BunToolchainModule.DefaultBunVersion) assert(devDeps("@types/bun").str != "latest") assert(!devDeps.contains("@types/node")) } diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 9965eaf..32b0155 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -10,6 +10,14 @@ import java.util.zip.ZipInputStream object BunToolchainModule { + /** + * The Bun release this plugin is tested against and downloads by default. + * + * Referenced by `bunVersion`, by `bunTypesVersion` (`@types/bun` is published in lockstep), and + * by the tests, so a bump is a one-line change here rather than a hunt for literals. + */ + val DefaultBunVersion = "1.4.0" + private val ModeledPackageJsonFields = Set( "dependencies", "devDependencies", @@ -26,39 +34,102 @@ object BunToolchainModule { executableName: String ) - private val Bun1314Checksums = Map( - "bun-darwin-aarch64.zip" -> "d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620", - "bun-darwin-x64.zip" -> "4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633", - "bun-linux-aarch64.zip" -> "a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b", - "bun-linux-x64.zip" -> "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f", - "bun-windows-aarch64.zip" -> "89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953", - "bun-windows-x64.zip" -> "0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922" + /** + * SHA-256 for every Bun release asset this plugin can download, keyed by version then asset. + * + * Sourced from the `SHASUMS256.txt` published with each Bun release. To add a version, append a + * complete entry — [[bundledChecksum]] deliberately has no fallback, so a partial table fails + * loudly on the missing platform rather than silently skipping verification. + */ + private val BundledChecksums: Map[String, Map[String, String]] = Map( + "1.3.14" -> Map( + "bun-darwin-aarch64.zip" -> "d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620", + "bun-darwin-x64.zip" -> "4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633", + "bun-darwin-x64-baseline.zip" -> "3e35ad6f53971a9834bf9e6786e2adf72b5f1921cc9a9c5fde073d2972944076", + "bun-linux-aarch64.zip" -> "a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b", + "bun-linux-aarch64-musl.zip" -> "b98e0ad3625c5c00d1d5b5ff55605c7adddbfae151861e68ade57b2d3b8703bb", + "bun-linux-x64.zip" -> "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f", + "bun-linux-x64-baseline.zip" -> "a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7", + "bun-linux-x64-musl.zip" -> "14bd9aedeebf1dba67e8def9531c89bc989ecfdf1de42e5bfcaf1b8cd9294719", + "bun-linux-x64-musl-baseline.zip" -> "56a7d6806cf155536c0178f0ea5fbd098e684fa509ebdb4fc0a7e19fb65382dc", + "bun-windows-aarch64.zip" -> "89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953", + "bun-windows-x64.zip" -> "0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922", + "bun-windows-x64-baseline.zip" -> "538f9c846355d9e847b2671bc00c47da4229a0befb24df3282b739770f3b475f" + ), + "1.4.0" -> Map( + "bun-darwin-aarch64.zip" -> "c669e97f6164e1c96e0701748db98dfa77492908cbd8394c7557134a735de381", + "bun-darwin-x64.zip" -> "1d0211b8f1dc991182344687ad15e72ee86f154845a5f7fa477994cd341dd9b0", + "bun-darwin-x64-baseline.zip" -> "da9b9f1b4ba766c6f299711f38dfaa98623e1ed9c40896aa53db803c52ec1fa0", + "bun-linux-aarch64.zip" -> "4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e", + "bun-linux-aarch64-musl.zip" -> "576300ce33ff16ffcd455bf178c2f095f9df845c6cc3d0284ba1c96ca0e80473", + "bun-linux-x64.zip" -> "2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452", + "bun-linux-x64-baseline.zip" -> "184fb4595f0d401a217cf7c78c1bc430ba83314dab7a8b94805babbf7fa7097f", + "bun-linux-x64-musl.zip" -> "83b5f12fd258dd8d4fdcaea65ede954366aa717dab399e20093ecab280d54e7a", + "bun-linux-x64-musl-baseline.zip" -> "618c4bc1f94b02337ee210003c0b7c066f11548a8cdc5109df10db043dc47ca2", + "bun-windows-aarch64.zip" -> "f473bfe2df73ee770548c93dd5d380aea7120c218ec2aa1afdd0bbba7bf18c47", + "bun-windows-x64.zip" -> "e6f093d39da486b20262ca8cdd5ed6a9e8bc9c2f275b78e6d3a0c5b28cc95901", + "bun-windows-x64-baseline.zip" -> "b929c54a9badb104a16dedd23aab6152c86793ae653d4e6b13983ffd0c882a66" + ) ) - private[bun] def distribution(osName: String, architecture: String): Either[String, Distribution] = { + /** Versions with a complete bundled checksum table, for diagnostics. */ + private[bun] def bundledVersions: Seq[String] = BundledChecksums.keys.toSeq.sorted + + /** + * Compose a Bun release asset name from the platform axes. + * + * Bun names assets `bun-{os}-{arch}[-musl][-baseline].zip`. The two modifiers are constrained: + * `-musl` exists only for Linux, and `-baseline` (for x64 CPUs without AVX2) only for x64. This + * returns `Left` for combinations Bun does not publish rather than constructing a 404 URL. + */ + private[bun] def distribution( + osName: String, + architecture: String, + musl: Boolean = false, + baseline: Boolean = false + ): Either[String, Distribution] = { val osPart = osName.toLowerCase match { case name if name.contains("mac") || name.contains("darwin") => Right("darwin") - case name if name.contains("linux") => Right("linux") + case name if name.contains("linux") => Right("linux") case name if name.contains("windows") => Right("windows") case other => Left(s"Unsupported operating system '$other'") } val archPart = architecture.toLowerCase match { - case "aarch64" | "arm64" => Right("aarch64") + case "aarch64" | "arm64" => Right("aarch64") case "amd64" | "x86_64" | "x64" => Right("x64") - case other => Left(s"Unsupported architecture '$other'") + case other => Left(s"Unsupported architecture '$other'") } for { os <- osPart arch <- archPart + _ <- + if (musl && os != "linux") Left(s"Bun publishes no musl build for '$os'") + else Right(()) + _ <- + if (baseline && arch != "x64") Left(s"Bun publishes no baseline build for '$arch'") + else Right(()) } yield Distribution( - assetName = s"bun-$os-$arch.zip", + assetName = s"bun-$os-$arch${if (musl) "-musl" else ""}${if (baseline) "-baseline" else ""}.zip", executableName = if (os == "windows") "bun.exe" else "bun" ) } + /** + * Detect a musl-based Linux (Alpine and friends), where the glibc build cannot run. + * + * Probing for the musl dynamic loader is more reliable than parsing `ldd --version`, which musl + * writes to stderr and glibc to stdout. + */ + private[bun] def detectMusl(root: os.Path = os.root): Boolean = + try + val libDir = root / "lib" + (os.exists(libDir) && os.list(libDir).exists(p => p.last.startsWith("ld-musl-"))) || + os.exists(root / "etc" / "alpine-release") + catch case _: Exception => false + private[bun] def bundledChecksum(version: String, assetName: String): Option[String] = - if (version == "1.3.14") Bun1314Checksums.get(assetName) else None + BundledChecksums.get(version).flatMap(_.get(assetName)) private[bun] def sha256(path: os.Path): String = { val digest = MessageDigest.getInstance("SHA-256") @@ -118,6 +189,30 @@ object BunToolchainModule { } } + /** + * Move a verified executable into the shared cache, tolerating a concurrent writer. + * + * Mill evaluates modules in parallel, so two tasks can extract the same archive at once. Both + * move into the same destination; whoever loses the race simply uses the winner's file, which is + * byte-identical because the path is keyed by checksum. + */ + private[bun] def publishToCache(staged: os.Path, cached: os.Path): os.Path = { + os.makeDir.all(cached / os.up) + try + java.nio.file.Files.move( + staged.toNIO, + cached.toNIO, + java.nio.file.StandardCopyOption.ATOMIC_MOVE + ) + cached + catch + case _: java.nio.file.FileAlreadyExistsException => cached + case _: java.nio.file.AtomicMoveNotSupportedException => + // Cache on a different filesystem than the task dest: fall back to a plain copy. + os.copy.over(staged, cached, createFolders = true) + cached + } + /** Parse package.json-style `name@specifier` declarations without slicing scoped names incorrectly. */ private[bun] def parseDependency(input: String): Either[String, NpmDependency] = { val trimmed = input.trim @@ -241,7 +336,23 @@ object BunToolchainModule { trait BunToolchainModule extends Module { /** Tested Bun release downloaded by default when no override is configured. */ - def bunVersion: T[String] = Task { "1.3.14" } + def bunVersion: T[String] = Task { BunToolchainModule.DefaultBunVersion } + + /** + * Download the `-baseline` build, for x64 CPUs without AVX2. + * + * Not auto-detected: the JVM cannot see CPU feature flags, and guessing wrong surfaces as an + * illegal-instruction crash rather than a diagnosable error. Set this when Bun aborts on startup + * with `SIGILL` on older x64 hardware. + */ + def bunUseBaseline: T[Boolean] = Task { false } + + /** + * Download the musl build, required on Alpine and other musl-based Linux distributions. + * + * Auto-detected from the presence of the musl dynamic loader; override to force either build. + */ + def bunUseMusl: T[Boolean] = Task.Input { BunToolchainModule.detectMusl() } /** Resolve Bun from PATH instead of using the managed distribution. */ def bunUseSystem: T[Boolean] = Task { @@ -261,9 +372,29 @@ trait BunToolchainModule extends Module { /** Optional managed distribution mirror. Must be paired with [[bunArchiveSha256]]. */ def bunArchiveUrl: T[Option[String]] = Task { None } - /** SHA-256 for [[bunArchiveUrl]], or for an unbundled Bun version. */ + /** + * SHA-256 of the Bun archive to download. + * + * Set this alone to run a [[bunVersion]] with no bundled checksum — the URL is derived from the + * version and the detected platform. Set it together with [[bunArchiveUrl]] to use a mirror. + */ def bunArchiveSha256: T[Option[String]] = Task { None } + /** + * Directory holding managed Bun downloads, shared by every module in the build. + * + * `downloadedBunExecutable` is a task on this trait, so without a shared cache a build with N + * Bun modules downloads the ~35 MB archive N times into N separate `Task.dest` directories. + * Entries are keyed by the archive's verified SHA-256, so a partial or tampered download can + * never be reused and two modules on the same pin share one file. + * + * Lives outside the workspace, so Mill's filesystem checker does not restrict it. + */ + def bunDownloadCacheDir: T[os.Path] = Task.Input { + Task.env.get("MILL_BUN_CACHE_DIR").filter(_.nonEmpty).map(os.Path(_)) + .getOrElse(os.home / ".cache" / "mill-bun") + } + /** Verify that an override/system executable matches [[bunVersion]]. */ def bunVerifyVersion: T[Boolean] = Task { true } @@ -369,15 +500,17 @@ trait BunToolchainModule extends Module { val version = bunVersion() val dist = BunToolchainModule.distribution( System.getProperty("os.name", "unknown"), - System.getProperty("os.arch", "unknown") + System.getProperty("os.arch", "unknown"), + musl = bunUseMusl(), + baseline = bunUseBaseline() ).fold( message => Task.fail(s"$message. Set bunExecutableOverride or bunUseSystem."), identity ) val customUrl = bunArchiveUrl() val customChecksum = bunArchiveSha256() - if (customUrl.isDefined != customChecksum.isDefined) { - Task.fail("bunArchiveUrl and bunArchiveSha256 must be configured together.") + if (customUrl.isDefined && customChecksum.isEmpty) { + Task.fail("bunArchiveUrl requires bunArchiveSha256 so the mirrored archive stays verified.") } val url = customUrl.getOrElse( s"https://github.com/oven-sh/bun/releases/download/bun-v$version/${dist.assetName}" @@ -385,19 +518,30 @@ trait BunToolchainModule extends Module { val checksum = customChecksum .orElse(BunToolchainModule.bundledChecksum(version, dist.assetName)) .getOrElse(Task.fail( - s"No bundled checksum for Bun $version (${dist.assetName}). Set bunArchiveUrl and bunArchiveSha256." + s"No bundled checksum for Bun $version (${dist.assetName}). " + + s"Bundled versions are ${BunToolchainModule.bundledVersions.mkString(", ")}. " + + "Set bunArchiveSha256 to the archive's SHA-256 to use this version anyway." )) .toLowerCase - val archive = Task.dest / dist.assetName - val executable = Task.dest / dist.executableName - BunToolchainModule.download(url, archive) - val actual = BunToolchainModule.sha256(archive) - if (actual != checksum) { - Task.fail(s"Bun archive checksum mismatch for $url: expected $checksum, received $actual") + if (!checksum.matches("[0-9a-f]{64}")) { + Task.fail(s"bunArchiveSha256 must be 64 hexadecimal characters, received '$checksum'.") + } + + // Keyed by the verified checksum, so a cache hit is proof of the right bytes. + val cached = bunDownloadCacheDir() / checksum / dist.executableName + if (os.exists(cached)) PathRef(cached) + else { + val archive = Task.dest / dist.assetName + val staged = Task.dest / dist.executableName + BunToolchainModule.download(url, archive) + val actual = BunToolchainModule.sha256(archive) + if (actual != checksum) { + Task.fail(s"Bun archive checksum mismatch for $url: expected $checksum, received $actual") + } + BunToolchainModule.extractExecutable(archive, dist.executableName, staged) + PathRef(BunToolchainModule.publishToCache(staged, cached)) } - BunToolchainModule.extractExecutable(archive, dist.executableName, executable) - PathRef(executable) } private def verifyBunVersion(executable: String, expected: String, verify: Boolean): Unit = { @@ -451,6 +595,18 @@ trait BunToolchainModule extends Module { println(s"Bun version: ${bunVersion()}") if (revision.nonEmpty) println(s"Bun revision: $revision") println(s"Bun linker: ${bunLinker()}") + if (mode == "managed") { + val dist = BunToolchainModule.distribution( + System.getProperty("os.name", "unknown"), + System.getProperty("os.arch", "unknown"), + musl = bunUseMusl(), + baseline = bunUseBaseline() + ) + println(s"Bun asset: ${dist.fold(identity, _.assetName)}") + println(s"Bun libc: ${if (bunUseMusl()) "musl" else "glibc"}") + println(s"Bun baseline: ${bunUseBaseline()}") + } + println(s"Bun lockfile: ${bunLockfile().fold("none")(_.path.toString)}") } /** Run a Bun command. All task values must be resolved before calling this. */ diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 33da9e9..170e922 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -61,8 +61,12 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Node ambient types used for node-targeted Bun builds. */ def nodeTypesVersion: T[String] = Task { "22.10.9" } - /** Bun ambient types used for bun-targeted Bun builds. */ - def bunTypesVersion: T[String] = Task { "1.3.11" } + /** Bun ambient types used for bun-targeted Bun builds. + * + * `@types/bun` is published in lockstep with Bun itself, so this tracks [[bunVersion]] by + * default and the two cannot drift. + */ + def bunTypesVersion: T[String] = Task { bunVersion() } /** Ambient runtime types aligned to the configured Bun target. */ protected def ambientTypeDeps: T[Seq[String]] = Task { diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index 9774c7e..d1ac89c 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -27,19 +27,104 @@ object BunToolchainTests extends TestSuite: assert(BunToolchainModule.distribution("Plan 9", "x64").isLeft) assert(BunToolchainModule.distribution("Linux", "riscv64").isLeft) - test("bundles checksums for every supported Bun 1.3.14 asset"): - val assets = Seq( - "bun-darwin-aarch64.zip", - "bun-darwin-x64.zip", - "bun-linux-aarch64.zip", - "bun-linux-x64.zip", - "bun-windows-aarch64.zip", - "bun-windows-x64.zip" + test("composes musl and baseline asset names"): + assert( + BunToolchainModule.distribution("Linux", "x64", musl = true) == Right( + BunToolchainModule.Distribution("bun-linux-x64-musl.zip", "bun") + ) ) - assets.foreach: asset => - val checksum = BunToolchainModule.bundledChecksum("1.3.14", asset) + assert( + BunToolchainModule.distribution("Linux", "x64", baseline = true) == Right( + BunToolchainModule.Distribution("bun-linux-x64-baseline.zip", "bun") + ) + ) + assert( + BunToolchainModule.distribution("Linux", "x64", musl = true, baseline = true) == Right( + BunToolchainModule.Distribution("bun-linux-x64-musl-baseline.zip", "bun") + ) + ) + assert( + BunToolchainModule.distribution("Windows 11", "x64", baseline = true) == Right( + BunToolchainModule.Distribution("bun-windows-x64-baseline.zip", "bun.exe") + ) + ) + + test("rejects modifier combinations Bun does not publish"): + // musl is Linux-only, baseline is x64-only. + assert(BunToolchainModule.distribution("Mac OS X", "x64", musl = true).isLeft) + assert(BunToolchainModule.distribution("Windows 11", "x64", musl = true).isLeft) + assert(BunToolchainModule.distribution("Linux", "aarch64", baseline = true).isLeft) + assert(BunToolchainModule.distribution("Mac OS X", "aarch64", baseline = true).isLeft) + + test("bundles a checksum for every asset of every pinned version"): + // Every combination distribution() can produce must be downloadable, or the managed + // toolchain fails on a platform we claim to support. + val platforms = Seq( + ("Mac OS X", "aarch64"), + ("Mac OS X", "x64"), + ("Linux", "aarch64"), + ("Linux", "x64"), + ("Windows 11", "aarch64"), + ("Windows 11", "x64") + ) + val assets = + for + (os, arch) <- platforms + musl <- Seq(false, true) + baseline <- Seq(false, true) + dist <- BunToolchainModule.distribution(os, arch, musl, baseline).toOption + yield dist.assetName + + assert(assets.distinct.size == 12) + for + version <- BunToolchainModule.bundledVersions + asset <- assets.distinct + do + val checksum = BunToolchainModule.bundledChecksum(version, asset) assert(checksum.exists(_.matches("[0-9a-f]{64}"))) - assert(BunToolchainModule.bundledChecksum("1.3.15", assets.head).isEmpty) + + test("pins the versions the docs and CI claim"): + assert(BunToolchainModule.bundledVersions == Seq("1.3.14", "1.4.0")) + // The default must be one we ship checksums for, or the managed path cannot work offline. + assert(BunToolchainModule.bundledVersions.contains(BunToolchainModule.DefaultBunVersion)) + + test("unknown versions have no bundled checksum"): + assert(BunToolchainModule.bundledChecksum("1.3.15", "bun-linux-x64.zip").isEmpty) + + test("musl detection probes the loader and the alpine marker"): + val glibc = os.temp.dir() + os.makeDir.all(glibc / "lib") + os.write(glibc / "lib" / "ld-linux-x86-64.so.2", "") + assert(!BunToolchainModule.detectMusl(glibc)) + + val musl = os.temp.dir() + os.makeDir.all(musl / "lib") + os.write(musl / "lib" / "ld-musl-x86_64.so.1", "") + assert(BunToolchainModule.detectMusl(musl)) + + val alpine = os.temp.dir() + os.makeDir.all(alpine / "etc") + os.write(alpine / "etc" / "alpine-release", "3.20.0") + assert(BunToolchainModule.detectMusl(alpine)) + + assert(!BunToolchainModule.detectMusl(os.temp.dir())) + + test("publishing to the download cache is idempotent under a race"): + val root = os.temp.dir() + val cached = root / "cache" / "abc123" / "bun" + + val first = root / "first" / "bun" + os.write(first, "bun-binary", createFolders = true) + assert(BunToolchainModule.publishToCache(first, cached) == cached) + assert(os.read(cached) == "bun-binary") + assert(!os.exists(first)) + + // A second module extracting the same checksum concurrently must not fail, and must not + // clobber the entry another task may already be executing. + val second = root / "second" / "bun" + os.write(second, "bun-binary", createFolders = true) + assert(BunToolchainModule.publishToCache(second, cached) == cached) + assert(os.read(cached) == "bun-binary") test("computes SHA-256"): val file = os.temp(contents = "hello") From dfe050cd8fe1e6e67891baa19f7394ee6a52920e Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 14:44:38 -0400 Subject: [PATCH 03/18] Preserve symlinks when staging, and link node_modules into web builds `BunScalaJSWebModule` could not resolve any npm dependency. Every tree copy in the plugin walked with `os.walk` (followLinks=false) but branched on `os.isDir`/`os.copy` (followLinks=true), so the `node_modules` symlink that `ensureLinkedWorkspace` plants in the link report was misclassified: - `BunWebSupport.copyContents` turned it into an empty directory, so `bun build index.html` had nothing to resolve imports against. - `BunToolchainModule.copyWorkspace` deep-copied the entire resolved tree -- O(node_modules) on every compileExecutable, bundle, and test workspace -- and threw outright on any broken link, routine for the .bin shims of skipped optional dependencies. Add `BunToolchainModule.copyTree`, which recreates links instead of resolving them, and route copyWorkspace, copyPathRefs, and both BunWebSupport copiers through it. `BunScalaJSWebModule` now stages `node_modules` explicitly, mirroring `BunTypeScriptWebModule.prepareWebStage`, rather than hoping the link survives the copy. Staging tasks that consume the linked tree now declare the install they symlink into. Mill's filesystem checker rejects reading a dest the task does not depend on, and the previous deep-copy only slipped past it because `os.copy` checks the source path rather than the resolved target -- the dependency was always real, just undeclared. Split `htmlEntries` into a pure resolver and `materializeHtmlEntries`, so `dev()` and `bundle` stop writing `index.html` into a staging task's already-cached output. Collapse `BunTypeScriptWebModule`'s two byte-identical staging tasks into one `webStage`. The scalajs-web fixture had no npm dependencies, which is exactly why CI was blind to this. It now imports lodash through @JSImport; on the parent commit that fixture fails with a bun build error, and both web tests fail. Unit 67 passing (was 57), integration 37 passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../resources/scalajs-web/build.mill | 1 + .../resources/scalajs-web/src/Main.scala | 10 ++- .../mill/bun/BunScalaJSIntegrationTests.scala | 21 ++++++ millbun/src/mill/bun/BunToolchainModule.scala | 47 +++++++++--- millbun/src/mill/bun/BunWebSupport.scala | 50 +++++++------ millbun/src/mill/bun/BunWorkersModule.scala | 3 + .../bun/BunTypeScriptModule.scala | 9 +++ .../bun/BunTypeScriptWebModule.scala | 33 ++++----- .../scalajslib/bun/BunScalaJSModule.scala | 4 ++ .../scalajslib/bun/BunScalaJSWebModule.scala | 68 ++++++++++++------ .../src/mill/bun/BunWebSupportTests.scala | 47 ++++++++++++ millbun/test/src/mill/bun/CopyTreeTests.scala | 72 +++++++++++++++++++ 12 files changed, 289 insertions(+), 76 deletions(-) create mode 100644 millbun/test/src/mill/bun/BunWebSupportTests.scala create mode 100644 millbun/test/src/mill/bun/CopyTreeTests.scala diff --git a/millbun/integration/resources/scalajs-web/build.mill b/millbun/integration/resources/scalajs-web/build.mill index cbb8d5f..853e71b 100644 --- a/millbun/integration/resources/scalajs-web/build.mill +++ b/millbun/integration/resources/scalajs-web/build.mill @@ -16,6 +16,7 @@ object app extends BunScalaJSWebModule { def scalaVersion = "3.8.2" def scalaJSVersion = "1.22.0" def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") + override def npmDeps = Task { Seq("lodash@4.17.21") } override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } } diff --git a/millbun/integration/resources/scalajs-web/src/Main.scala b/millbun/integration/resources/scalajs-web/src/Main.scala index 8b563d3..4654008 100644 --- a/millbun/integration/resources/scalajs-web/src/Main.scala +++ b/millbun/integration/resources/scalajs-web/src/Main.scala @@ -1,5 +1,13 @@ import org.scalajs.dom.document +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +/** Forces the linker to emit a real npm import, so the staged web build must resolve it. */ +@js.native +@JSImport("lodash", JSImport.Namespace) +object Lodash extends js.Object: + def capitalize(value: String): String = js.native object Main: def main(args: Array[String]): Unit = - document.getElementById("app").textContent = "Hello from Scala.js web" + document.getElementById("app").textContent = Lodash.capitalize("hello from scala.js web") diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index ec7bd04..da59704 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -83,6 +83,27 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(files.exists(_.ext == "js")) } + test("web stage resolves npm dependencies") { + // The fixture imports lodash via @JSImport, so the linked output carries a real npm + // import that `bun build` has to resolve out of the staged directory. Staging used to + // flatten the install's node_modules symlink into an empty directory, which made every + // npm import unresolvable. + val tester = this.tester("scalajs-web") + assert(tester.eval("app.bundle").isSuccess) + + val stage = tester.workspacePath / "out" / "app" / "webProductionStage.dest" + assert(os.isLink(stage / "node_modules")) + assert(os.exists(stage / "node_modules" / "lodash" / "package.json")) + + // lodash must be inlined into the bundle, not left as a bare import. + val bundled = os.walk(outputPath(tester, "app.bundle")) + .filter(p => os.isFile(p) && p.ext == "js") + .map(os.read) + .mkString + assert(!bundled.contains("""from"lodash"""")) + assert(!bundled.contains("""from "lodash"""")) + } + test("compileExecutable") { val tester = this.tester("scalajs-bundle") val res = tester.eval("app.compileExecutable") diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 32b0155..966abeb 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -299,12 +299,42 @@ object BunToolchainModule { .find(path => os.isFile(path) && java.nio.file.Files.isExecutable(path.toNIO)) } - /** Copy a generated workspace into a fresh task destination, preserving layout. */ - def copyWorkspace(source: os.Path, dest: os.Path): Unit = { - os.walk(source) - .foreach(path => os.copy.over(path, dest / path.relativeTo(source), createFolders = true)) + /** + * Copy a directory tree, recreating symlinks instead of resolving them. + * + * `os.walk` does not follow links, but `os.isDir` and `os.copy` do. Branching on one while + * copying with the other means a `node_modules` symlink is either deep-copied (turning an O(1) + * link into an O(node_modules) copy) or flattened into an empty directory, and a broken link + * anywhere in the tree — routine for the `.bin` shims of skipped optional dependencies — aborts + * the whole copy. Recreating the link is both correct and cheap. + * + * @param exclude top-level entry names to skip entirely + */ + def copyTree(source: os.Path, dest: os.Path, exclude: Set[String] = Set.empty): Unit = { + if (!os.exists(source, followLinks = false)) return + + val skip = (path: os.Path) => { + val relative = path.relativeTo(source) + relative.segments.headOption.exists(exclude.contains) + } + + os.walk(source, skip = skip, followLinks = false).foreach { path => + val target = dest / path.relativeTo(source) + if (os.isLink(path)) { + os.makeDir.all(target / os.up) + if (os.exists(target, followLinks = false)) os.remove(target) + os.symlink(target, os.readLink.absolute(path)) + } else if (os.isDir(path, followLinks = false)) { + os.makeDir.all(target) + } else { + os.copy.over(path, target, createFolders = true) + } + } } + /** Copy a generated workspace into a fresh task destination, preserving layout and symlinks. */ + def copyWorkspace(source: os.Path, dest: os.Path): Unit = copyTree(source, dest) + /** * Copy files or directories into a Bun workspace while preserving their relative path * beneath the nearest matching source root when possible. @@ -322,13 +352,8 @@ object BunToolchainModule { .map(root => destRoot / source.relativeTo(root)) .getOrElse(destRoot / source.last) - if (os.isDir(source)) { - os.walk(source).foreach { path => - os.copy.over(path, target / path.relativeTo(source), createFolders = true) - } - } else { - os.copy.over(source, target, createFolders = true) - } + if (os.isDir(source)) copyTree(source, target) + else os.copy.over(source, target, createFolders = true) } } } diff --git a/millbun/src/mill/bun/BunWebSupport.scala b/millbun/src/mill/bun/BunWebSupport.scala index a64af40..0b779c1 100644 --- a/millbun/src/mill/bun/BunWebSupport.scala +++ b/millbun/src/mill/bun/BunWebSupport.scala @@ -13,32 +13,29 @@ private[mill] object BunWebSupport: copyPath(source, target) } - def copyContents(source: os.Path, destination: os.Path): Unit = + def copyContents( + source: os.Path, + destination: os.Path, + exclude: Set[String] = Set.empty + ): Unit = if os.exists(source) then - if os.isDir(source) then - os.walk(source).foreach { path => - val relative = path.relativeTo(source) - if relative.segments.nonEmpty then - val target = destination / relative - if os.isDir(path) then os.makeDir.all(target) - else os.copy.over(path, target, createFolders = true) - } + if os.isDir(source) then BunToolchainModule.copyTree(source, destination, exclude) else copyPath(source, destination / source.last) private def copyPath(source: os.Path, target: os.Path): Unit = - if os.isDir(source) then - os.walk(source).foreach { path => - val destination = target / path.relativeTo(source) - if os.isDir(path) then os.makeDir.all(destination) - else os.copy.over(path, destination, createFolders = true) - } + if os.isDir(source) then BunToolchainModule.copyTree(source, target) else os.copy.over(source, target, createFolders = true) + /** + * Resolve the HTML entrypoints a staged web build will use. Pure — writes nothing. + * + * Kept separate from [[materializeHtmlEntries]] so `dev()` and `bundle` can ask which entries + * exist without writing into the staging task's already-cached output directory. + */ def htmlEntries( configured: Seq[PathRef], moduleDir: os.Path, - destination: os.Path, - generatedScript: String + destination: os.Path ): Seq[os.Path] = val copied = configured .map(_.path) @@ -48,11 +45,20 @@ private[mill] object BunWebSupport: else destination / path.last ) - if copied.nonEmpty then copied - else - val index = destination / "index.html" + if copied.nonEmpty then copied else Seq(destination / "index.html") + + /** Resolve entrypoints, generating a minimal `index.html` when the module supplies none. */ + def materializeHtmlEntries( + configured: Seq[PathRef], + moduleDir: os.Path, + destination: os.Path, + generatedScript: String + ): Seq[os.Path] = + val entries = htmlEntries(configured, moduleDir, destination) + val hasConfigured = configured.exists(ref => os.exists(ref.path) && os.isFile(ref.path)) + if !hasConfigured then os.write.over( - index, + entries.head, s""" | | @@ -61,7 +67,7 @@ private[mill] object BunWebSupport: |""".stripMargin, createFolders = true ) - Seq(index) + entries def syncRoots(roots: Seq[(os.Path, os.Path)]): Unit = roots.foreach { case (source, target) => diff --git a/millbun/src/mill/bun/BunWorkersModule.scala b/millbun/src/mill/bun/BunWorkersModule.scala index 8e1934d..cb50d8a 100644 --- a/millbun/src/mill/bun/BunWorkersModule.scala +++ b/millbun/src/mill/bun/BunWorkersModule.scala @@ -59,6 +59,9 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => def bundleWorkers: T[PathRef] = Task { val workspace = Task.dest / "workspace" val outDir = Task.dest / "workers" + // Declared explicitly: the staged tree carries a node_modules symlink into this install, + // and Mill's filesystem checker only permits reading a dest we depend on. + npmInstall() BunToolchainModule.copyWorkspace(compile().path, workspace) os.makeDir.all(outDir) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 170e922..1141d92 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -284,6 +284,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with if (bunCompileExecutable()) Task.dest / bunBinaryName() else Task.dest / s"$moduleName.js" + // Declared explicitly: the staged tree carries a node_modules symlink into this + // install, and Mill's filesystem checker only permits reading a dest we depend on. + npmInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -319,6 +322,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString val outFile = Task.dest / bunBinaryName() + // Declared explicitly: the staged tree carries a node_modules symlink into this + // install, and Mill's filesystem checker only permits reading a dest we depend on. + npmInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -356,6 +362,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val compileDir = compile().path val buildDir = Task.dest / "workspace" val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString + // Declared explicitly: the staged tree carries a node_modules symlink into this + // install, and Mill's filesystem checker only permits reading a dest we depend on. + npmInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala index fe0fc59..b66d9e9 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -50,23 +50,16 @@ trait BunTypeScriptWebModule extends BunTypeScriptModule: configs.foreach(ref => os.copy.over(ref.path, destination / ref.path.last, createFolders = true)) val script = "./" + scriptEntryPoint.relativeTo(moduleDir).toString.replace('\\', '/') - BunWebSupport.htmlEntries(htmlRefs, moduleDir, destination, script) + BunWebSupport.materializeHtmlEntries(htmlRefs, moduleDir, destination, script) } - private def webDevelopmentStage: T[PathRef] = Task { - prepareWebStage( - Task.dest, - sources() ++ generatedSources() ++ resources(), - webEntryPoints(), - webPublicSources(), - npmInstall().path, - bunfigFiles(), - webScriptEntryPoint().path - ) - PathRef(Task.dest) - } - - private def webProductionStage: T[PathRef] = Task { + /** + * Staged sources, HTML, static files, and `node_modules` that both `dev` and `bundle` build from. + * + * A single task: development and production stage identically, and differ only in the flags + * `bundle` passes to `bun build`. + */ + private def webStage: T[PathRef] = Task { prepareWebStage( Task.dest, sources() ++ generatedSources() ++ resources(), @@ -81,9 +74,8 @@ trait BunTypeScriptWebModule extends BunTypeScriptModule: /** Start Bun's HTML development server with source mirroring for native HMR. */ def dev(): Command[Unit] = Task.Command { - val stage = webDevelopmentStage().path - val script = "./" + webScriptEntryPoint().path.relativeTo(moduleDir).toString.replace('\\', '/') - val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, script) + val stage = webStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage) val syncRoots = (sources() ++ generatedSources() ++ resources() ++ webEntryPoints() ++ webPublicSources()) .filter(ref => os.exists(ref.path) && ref.path.startsWith(moduleDir)) .map(ref => ref.path -> (stage / ref.path.relativeTo(moduleDir))) @@ -100,9 +92,8 @@ trait BunTypeScriptWebModule extends BunTypeScriptModule: /** Build complete optimized HTML/CSS/JavaScript assets under `dist`. */ override def bundle: T[PathRef] = Task { - val stage = webProductionStage().path - val script = "./" + webScriptEntryPoint().path.relativeTo(moduleDir).toString.replace('\\', '/') - val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, script) + val stage = webStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage) val destination = Task.dest / "dist" runBun( bunExecutable(), diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 51880e1..1b86361 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -474,6 +474,9 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac /** Build a server-side Scala.js entrypoint as a standalone executable. */ def compileExecutable: T[PathRef] = Task { val linked = fullLinkJS() + // Declared explicitly: the staged workspace carries a node_modules symlink into this + // install, and Mill's filesystem checker only permits reading a dest we depend on. + bunInstall() val buildDir = Task.dest / "workspace" BunToolchainModule.copyWorkspace(linked.dest.path, buildDir) resolvedBunConfigs().foreach(cfg => os.copy.over(cfg.path, buildDir / cfg.path.last, createFolders = true)) @@ -511,6 +514,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") val linked = fullLinkJS() + bunInstall() val buildDir = Task.dest / "workspace" BunToolchainModule.copyWorkspace(linked.dest.path, buildDir) resolvedBunConfigs().foreach(cfg => os.copy.over(cfg.path, buildDir / cfg.path.last, createFolders = true)) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala index 111d336..68a2f34 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala @@ -3,6 +3,7 @@ package bun import mill.* import mill.bun.{BunToolchainModule, BunWebSupport} +import mill.scalajslib.api.Report /** Scala.js web application served and bundled through Bun's HTML pipeline. */ trait BunScalaJSWebModule extends BunScalaJSModule: @@ -17,38 +18,63 @@ trait BunScalaJSWebModule extends BunScalaJSModule: def webDevArgs: T[Seq[String]] = Task { Seq.empty } - private def webDevelopmentStage: T[PathRef] = Task { - val linked = fastLinkJS() - val destination = Task.dest - BunWebSupport.copyContents(linked.dest.path, destination) - BunWebSupport.copyPreservingModuleDir(webEntryPoints(), moduleDir, destination) - BunWebSupport.copyPreservingModuleDir(webPublicSources(), moduleDir, destination) + /** + * Stage linked output, HTML, and static files into a directory Bun can build from. + * + * `node_modules` is linked explicitly rather than carried over from the link report, mirroring + * [[mill.javascriptlib.bun.BunTypeScriptWebModule]]: the staged tree is where `bun build` + * resolves npm imports emitted by `@JSImport`, so it has to reach the install. + */ + private def prepareWebStage( + linked: Report, + destination: os.Path, + install: os.Path, + htmlRefs: Seq[PathRef], + publicRefs: Seq[PathRef], + configs: Seq[PathRef] + ): Unit = + BunWebSupport.copyContents(linked.dest.path, destination, exclude = Set("node_modules")) + if os.exists(install / "node_modules") then + os.symlink(destination / "node_modules", install / "node_modules") + os.copy.over(install / "package.json", destination / "package.json", createFolders = true) + configs.foreach(cfg => os.copy.over(cfg.path, destination / cfg.path.last, createFolders = true)) + + BunWebSupport.copyPreservingModuleDir(htmlRefs, moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(publicRefs, moduleDir, destination) val entrypoint = primaryEntrypoint(linked) val stableEntrypoint = destination / "main.js" if entrypoint != stableEntrypoint then os.copy.over(entrypoint, stableEntrypoint, createFolders = true) - BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, destination, "./main.js") - PathRef(destination) + BunWebSupport.materializeHtmlEntries(htmlRefs, moduleDir, destination, "./main.js") + + private def webDevelopmentStage: T[PathRef] = Task { + prepareWebStage( + fastLinkJS(), + Task.dest, + bunInstall().path, + webEntryPoints(), + webPublicSources(), + bunfigFiles() + ) + PathRef(Task.dest) } private def webProductionStage: T[PathRef] = Task { - val linked = fullLinkJS() - val destination = Task.dest - BunWebSupport.copyContents(linked.dest.path, destination) - BunWebSupport.copyPreservingModuleDir(webEntryPoints(), moduleDir, destination) - BunWebSupport.copyPreservingModuleDir(webPublicSources(), moduleDir, destination) - - val entrypoint = primaryEntrypoint(linked) - val stableEntrypoint = destination / "main.js" - if entrypoint != stableEntrypoint then os.copy.over(entrypoint, stableEntrypoint, createFolders = true) - BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, destination, "./main.js") - PathRef(destination) + prepareWebStage( + fullLinkJS(), + Task.dest, + bunInstall().path, + webEntryPoints(), + webPublicSources(), + bunfigFiles() + ) + PathRef(Task.dest) } /** Start Bun's HTML development server. Use `mill --watch app.dev` for Scala relinking. */ def dev(): Command[Unit] = Task.Command { val stage = webDevelopmentStage().path - val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, "./main.js") + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage) BunWebSupport.runDevelopmentServer( bunExecutable(), entries, @@ -62,7 +88,7 @@ trait BunScalaJSWebModule extends BunScalaJSModule: /** Build complete optimized HTML/CSS/JavaScript assets under `dist`. */ override def bundle: T[PathRef] = Task { val stage = webProductionStage().path - val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, "./main.js") + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage) val destination = Task.dest / "dist" runBun( bunExecutable(), diff --git a/millbun/test/src/mill/bun/BunWebSupportTests.scala b/millbun/test/src/mill/bun/BunWebSupportTests.scala new file mode 100644 index 0000000..f6df0f7 --- /dev/null +++ b/millbun/test/src/mill/bun/BunWebSupportTests.scala @@ -0,0 +1,47 @@ +package mill.bun + +import mill.api.PathRef +import utest.* + +object BunWebSupportTests extends TestSuite: + def tests: Tests = Tests: + test("htmlEntries resolves without writing"): + // dev() and bundle() call this against a staging task's already-cached dest, so it must + // not touch the filesystem. + val moduleDir = os.temp.dir() + val dest = os.temp.dir() + val entries = BunWebSupport.htmlEntries(Seq.empty, moduleDir, dest) + assert(entries == Seq(dest / "index.html")) + assert(!os.exists(dest / "index.html")) + + test("materializeHtmlEntries generates index.html only when none is configured"): + val moduleDir = os.temp.dir() + val dest = os.temp.dir() + val entries = + BunWebSupport.materializeHtmlEntries(Seq.empty, moduleDir, dest, "./main.js") + assert(entries == Seq(dest / "index.html")) + val generated = os.read(dest / "index.html") + assert(generated.contains("""src="./main.js"""")) + + test("materializing twice leaves the generated file untouched"): + val moduleDir = os.temp.dir() + val dest = os.temp.dir() + BunWebSupport.materializeHtmlEntries(Seq.empty, moduleDir, dest, "./main.js") + val before = os.read(dest / "index.html") + + // The pure form must not rewrite it — that write would land in a cached task dest. + BunWebSupport.htmlEntries(Seq.empty, moduleDir, dest) + assert(os.read(dest / "index.html") == before) + + test("configured entries map under moduleDir and are not overwritten"): + val moduleDir = os.temp.dir() + os.write(moduleDir / "pages" / "app.html", "", createFolders = true) + val dest = os.temp.dir() + os.write(dest / "pages" / "app.html", "", createFolders = true) + + val configured = Seq(PathRef(moduleDir / "pages" / "app.html")) + val entries = + BunWebSupport.materializeHtmlEntries(configured, moduleDir, dest, "./main.js") + assert(entries == Seq(dest / "pages" / "app.html")) + assert(!os.exists(dest / "index.html")) + assert(os.read(dest / "pages" / "app.html") == "") diff --git a/millbun/test/src/mill/bun/CopyTreeTests.scala b/millbun/test/src/mill/bun/CopyTreeTests.scala new file mode 100644 index 0000000..c36807b --- /dev/null +++ b/millbun/test/src/mill/bun/CopyTreeTests.scala @@ -0,0 +1,72 @@ +package mill.bun + +import utest.* + +object CopyTreeTests extends TestSuite: + + /** `linked/` as the Scala.js linker leaves it: output files plus a node_modules symlink. */ + private def stagedLink(): (os.Path, os.Path) = + val root = os.temp.dir() + val installed = root / "installed" + os.write(installed / "node_modules" / "lodash" / "index.js", "lodash", createFolders = true) + val linked = root / "linked" + os.write(linked / "main.js", "app", createFolders = true) + os.symlink(linked / "node_modules", installed / "node_modules") + (linked, installed) + + def tests: Tests = Tests: + test("symlinked directories are recreated, not flattened or deep-copied"): + val (linked, installed) = stagedLink() + val dest = os.temp.dir() + BunToolchainModule.copyTree(linked, dest) + + // The whole point: a link stays a link pointing at the install, so `bun build` in the + // staged directory resolves npm imports without copying node_modules. + assert(os.isLink(dest / "node_modules")) + assert(os.readLink.absolute(dest / "node_modules") == installed / "node_modules") + assert(os.exists(dest / "node_modules" / "lodash" / "index.js")) + assert(os.read(dest / "main.js") == "app") + + test("broken symlinks do not abort the copy"): + // Routine inside node_modules: .bin shims for skipped optional dependencies. + val source = os.temp.dir() + os.write(source / "main.js", "app") + os.symlink(source / "dangling", source / "does-not-exist") + + val dest = os.temp.dir() + BunToolchainModule.copyTree(source, dest) + assert(os.isLink(dest / "dangling")) + assert(os.read(dest / "main.js") == "app") + + test("excluded top-level entries are skipped entirely"): + val (linked, _) = stagedLink() + val dest = os.temp.dir() + BunToolchainModule.copyTree(linked, dest, exclude = Set("node_modules")) + assert(!os.exists(dest / "node_modules", followLinks = false)) + assert(os.exists(dest / "main.js")) + + test("symlinked files are recreated as links"): + val source = os.temp.dir() + val target = os.temp.dir() / "real.js" + os.write(target, "real") + os.symlink(source / "alias.js", target) + + val dest = os.temp.dir() + BunToolchainModule.copyTree(source, dest) + assert(os.isLink(dest / "alias.js")) + assert(os.read(dest / "alias.js") == "real") + + test("nested directories and empty directories are preserved"): + val source = os.temp.dir() + os.makeDir.all(source / "empty") + os.write(source / "a" / "b" / "c.txt", "deep", createFolders = true) + + val dest = os.temp.dir() + BunToolchainModule.copyTree(source, dest) + assert(os.isDir(dest / "empty")) + assert(os.read(dest / "a" / "b" / "c.txt") == "deep") + + test("copying a missing source is a no-op"): + val dest = os.temp.dir() + BunToolchainModule.copyTree(os.temp.dir() / "absent", dest) + assert(os.list(dest).isEmpty) From 7d551f1bbce8f5edf6548c5c4236dfb180322508 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 14:55:27 -0400 Subject: [PATCH 04/18] Give TypeScript test modules their own lockfile identity `BunTypeScriptTests.npmInstall` builds a package.json that is a strict superset of the outer module's, but `bunLockfile` is not a member of Mill's `TypeScriptTests`, so the unqualified reference resolved through Scala's outer scope to the *enclosing* module's bun.lock. Installing a superset package.json against that lock under --frozen-lockfile fails with: error: lockfile had changes, but lockfile is frozen and there was no `test.bunLock` to generate a matching one. Any TypeScript test module declaring a dependency the outer module lacks was broken under the shipped default. - `bunTestPackageJson` is now one task shared by `npmInstall` and `bunLock`, so the install and the lockfile cannot describe different dependency sets. - The test trait declares its own `bunLockfile` at `/bun.lock` and its own `bunLock` command. Defined directly rather than by mixing in BunToolchainModule, which would fork bunVersion/bunExecutable into separate task instances and re-download managed Bun per test module. - A test module that adds nothing now delegates to the outer install instead of demanding a second lockfile, so bare test modules are unaffected. - Every Bun member in the test trait is qualified `outer.` or `this.`. The outer-vs-inherited split is what caused this, and only `bunLockfile` was ever wrong -- `transitiveUnmanagedDeps` and friends are declared by Mill's TypeScriptTests and correctly bind to the test module. - `requireBunLockfile` takes the expected path, so the error names the test module's lockfile rather than the outer one. The typescript-test-deps fixture (outer is-even, test is-odd) now pins bunRequireLockfile itself, following the typescript-lock precedent, so the strict path is exercised regardless of the suite's env. On the parent commit both new tests fail. Unit 67 passing, integration 39 passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../resources/typescript-test-deps/build.mill | 7 +- .../bun/BunTypeScriptIntegrationTests.scala | 44 +++++++ millbun/src/mill/bun/BunToolchainModule.scala | 9 +- .../bun/BunTypeScriptModule.scala | 124 ++++++++++++++---- 4 files changed, 156 insertions(+), 28 deletions(-) diff --git a/millbun/integration/resources/typescript-test-deps/build.mill b/millbun/integration/resources/typescript-test-deps/build.mill index cd619a0..a315dd8 100644 --- a/millbun/integration/resources/typescript-test-deps/build.mill +++ b/millbun/integration/resources/typescript-test-deps/build.mill @@ -11,11 +11,16 @@ import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { override def moduleDir = build.moduleDir + // Pinned in the fixture rather than relying on the suite's env default, so the strict + // lockfile path is exercised regardless of how the suite is invoked. + override def bunRequireLockfile = Task { true } + // Production dependency override def npmDeps = Task { Seq("is-even@1.0.0") } object test extends BunTypeScriptTests { - // Test-only dependency — should land in devDependencies, not dependencies + // Test-only dependency — should land in devDependencies, not dependencies. Because it is a + // dependency the outer module does not have, this test module needs its own bun.lock. override def npmDeps = Task { Seq("is-odd@3.0.1") } } } diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index ae52d1a..0aa008e 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -178,6 +178,9 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("test deps are devDependencies") { val tester = this.tester("typescript-test-deps") + // The fixture sets bunRequireLockfile, so both modules need a lock before installing. + assert(tester.eval("app.bunLock").isSuccess) + assert(tester.eval("app.test.bunLock").isSuccess) // Outer module should have is-even in dependencies val outerRes = tester.eval("app.npmInstall") @@ -201,6 +204,47 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(runRes.isSuccess) } + test("test modules with extra deps own their lockfile") { + // A test module installs a strict superset of the outer package.json. Reusing the outer + // module's lock under --frozen-lockfile fails with "lockfile had changes, but lockfile is + // frozen", so the test module needs its own lock and its own bunLock command. + val tester = this.tester("typescript-test-deps") + + // Without any lock, the install refuses and names the test module's own path. + val unlocked = tester.eval("app.test.npmInstall") + assert(!unlocked.isSuccess) + + assert(tester.eval("app.bunLock").isSuccess) + assert(tester.eval("app.test.bunLock").isSuccess) + + val outerLock = tester.workspacePath / "bun.lock" + val testLock = tester.workspacePath / "test" / "bun.lock" + assert(os.exists(outerLock)) + assert(os.exists(testLock)) + assert(os.read(outerLock) != os.read(testLock)) + + // Compare the `workspaces` block, which records the root package's *declared* deps. + // is-odd also arrives transitively through is-even, so a whole-file substring match + // would not distinguish the two locks. + def declaredDeps(lock: os.Path): String = + val text = os.read(lock) + text.slice(text.indexOf("\"workspaces\""), text.indexOf("\"packages\"")) + + assert(!declaredDeps(outerLock).contains("is-odd")) + assert(declaredDeps(testLock).contains("is-odd")) + + // The frozen install now succeeds against the test module's own lock. + assert(tester.eval("app.test.npmInstall").isSuccess) + assert(tester.eval("app.test.test").isSuccess) + } + + test("test modules adding nothing reuse the outer install") { + // A bare test module must not demand a second lockfile. + val tester = this.tester("typescript-tests") + assert(tester.eval("app.test.npmInstall").isSuccess) + assert(!os.exists(tester.workspacePath / "test" / "bun.lock")) + } + test("bunEnv") { val tester = this.tester("typescript-env") val res = tester.eval("app.bundle") diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 966abeb..af89292 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -480,14 +480,19 @@ trait BunToolchainModule extends Module { else Seq.empty) } + /** + * @param lockfilePath where the missing lockfile is expected. Nested modules must pass their own + * path, or the error points a user at the wrong file. + */ protected def requireBunLockfile( hasInstallInputs: Boolean, lockfile: Option[PathRef], - required: Boolean + required: Boolean, + lockfilePath: os.Path = moduleDir / "bun.lock" ): Unit = { if (hasInstallInputs && required && lockfile.isEmpty) { throw new RuntimeException( - s"Missing ${moduleDir / "bun.lock"}. Run this module's bunLock command and commit the generated lockfile." + s"Missing $lockfilePath. Run this module's bunLock command and commit the generated lockfile." ) } } diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 1141d92..44644f0 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -418,23 +418,26 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Coverage reporter formats. */ def bunCoverageReporters: T[Seq[String]] = Task { Seq("text", "lcov") } - override def npmInstall: T[PathRef] = Task { - val dest = Task.dest - os.makeDir.all(dest) - - // Merge outer + test-side deps into a single package.json. - // Upstream Mill's test npmInstall runs `npm install --save-dev` with the - // test module's transitive deps; we achieve the same by building one - // merged package.json before `bun install`. + /** + * Merged outer + test-side package.json, shared by [[npmInstall]] and [[bunLock]]. + * + * One task so the install and the lockfile can never describe different dependency sets — + * that divergence is what made frozen installs of test modules fail. + * + * Upstream Mill's test `npmInstall` runs `npm install --save-dev` with the test module's + * transitive deps; building one merged package.json achieves the same for Bun. + */ + def bunTestPackageJson: T[ujson.Obj] = Task { val user = outer.packageJson() val overrides = outer.npmOverrides() val outerDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides) - val outerDevDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDevDeps() ++ outer.tsDeps(), overrides) + val outerDevDeps = + BunToolchainModule.dependencyPairs(outer.transitiveNpmDevDeps() ++ outer.tsDeps(), overrides) val outerPackageNames = (outerDeps.iterator ++ outerDevDeps.iterator).map(_._1).toSet // Test-only deps are dev dependencies — they should not appear in the // production dependencies field, matching Bun/npm convention. val testDevDeps = BunToolchainModule - .dependencyPairs(transitiveNpmDeps() ++ this.npmDevDeps() ++ this.tsDeps(), overrides) + .dependencyPairs(this.transitiveNpmDeps() ++ this.npmDevDeps() ++ this.tsDeps(), overrides) .filterNot { case (name, _) => outerPackageNames.contains(name) } val resolved = ujson.Obj.from( @@ -456,28 +459,99 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value)) ) - val merged = BunToolchainModule.mergePackageJson(resolved, outer.bunPackageJsonExtras()) - os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + BunToolchainModule.mergePackageJson(resolved, outer.bunPackageJsonExtras()) + } - outer.copyBunWorkspaceConfigs() + /** + * Source-controlled lockfile for this test module, at `/bun.lock`. + * + * Declared here rather than inherited from the enclosing module: a test module that adds + * dependencies installs a strict superset of the outer package.json, which the outer module's + * lockfile cannot satisfy under `--frozen-lockfile`. + */ + def bunLockfile: T[Option[PathRef]] = Task.Input { + val path = moduleDir / "bun.lock" + if (os.exists(path)) Some(PathRef(path)) else None + } - val lockfile = bunLockfile() - requireBunLockfile(true, lockfile, bunRequireLockfile()) - copyBunLockfile(lockfile, dest) + /** True when the test module adds nothing the outer install does not already provide. */ + private def reusesOuterInstall: Task[Boolean] = Task.Anon { + bunTestPackageJson().render() == outer.bunWorkspacePackageJson().render() + } - runBun( - bunExecutable(), - Seq("install") ++ resolvedBunInstallArgs( - bunInstallArgs(), - bunInstallExtraArgs(), - lockfile.nonEmpty, - updateLockfile = false - ) ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), + override def npmInstall: T[PathRef] = Task { + if (reusesOuterInstall()) outer.npmInstall() + else { + val dest = Task.dest + os.makeDir.all(dest) + os.write.over( + dest / "package.json", + bunTestPackageJson().render(indent = 2), + createFolders = true + ) + outer.copyBunWorkspaceConfigs() + + val lockfile = this.bunLockfile() + outer.requireBunLockfile( + hasInstallInputs = true, + lockfile = lockfile, + required = outer.bunRequireLockfile(), + lockfilePath = moduleDir / "bun.lock" + ) + outer.copyBunLockfile(lockfile, dest) + + outer.runBun( + outer.bunExecutable(), + Seq("install") ++ outer.resolvedBunInstallArgs( + outer.bunInstallArgs(), + outer.bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()) + .distinct.map(_.path.toString), + cwd = dest, + env = outer.bunToolEnv() + ) + + PathRef(dest) + } + } + + /** + * Resolve this test module's dependencies and update its own `bun.lock`. + * + * Unlike the outer module's, this does not refuse for workspace members: a test module with + * extra dependencies genuinely needs its own install and its own lock. + */ + def bunLock(): Command[PathRef] = Task.Command { + val dest = Task.dest + os.makeDir.all(dest) + os.write.over( + dest / "package.json", + bunTestPackageJson().render(indent = 2), + createFolders = true + ) + outer.copyBunWorkspaceConfigs() + outer.copyBunLockfile(this.bunLockfile(), dest) + + outer.runBun( + outer.bunExecutable(), + Seq("install") ++ outer.resolvedBunInstallArgs( + outer.bunInstallArgs(), + outer.bunInstallExtraArgs(), + this.bunLockfile().nonEmpty, + updateLockfile = true + ) ++ (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()) + .distinct.map(_.path.toString), cwd = dest, env = outer.bunToolEnv() ) - PathRef(dest) + val generated = dest / "bun.lock" + if (!os.exists(generated)) Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) } protected def preparedTestWorkspace: T[PathRef] = Task { From 65944413214794a2aebb0d310536e5dbc7bb8896 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 15:03:12 -0400 Subject: [PATCH 05/18] Make the bun"..." interpolator use the one npm-spec parser `validateLiteral` hand-rolled a second, weaker parser that only inspected the package-name half. So `bun"react@"` compiled cleanly and then threw from `BunToolchainModule.parseDependency` during the install -- defeating the entire purpose of a compile-time validator. - `validateLiteral` now delegates to `parseDependency`, so the compile-time and task-time rules cannot diverge again. This also removes a dead `val name` binding whose two branches both evaluated to `dep` and whose result was discarded. - Interpolated forms like `bun"react@$version"` are not knowable at compile time; the macro previously skipped validation entirely for them. They now route through `BunDep.validate`, which fails when the build evaluates the dependency list rather than inside `bun install`. New `invalid-bun-specifier` fixture covers the literal case; on the parent commit it compiles and `app.bunDeps` succeeds, returning Seq("react@"). Unit 69 passing, integration 41 passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../invalid-bun-specifier/build.mill | 24 +++++++++ .../invalid-bun-specifier/src/App.scala | 1 + ...unDependencyManifestIntegrationTests.scala | 8 +++ millbun/src/mill/bun/BunDep.scala | 52 +++++++++---------- millbun/test/src/mill/bun/BunDepTests.scala | 27 +++++++++- 5 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 millbun/integration/resources/invalid-bun-specifier/build.mill create mode 100644 millbun/integration/resources/invalid-bun-specifier/src/App.scala diff --git a/millbun/integration/resources/invalid-bun-specifier/build.mill b/millbun/integration/resources/invalid-bun-specifier/build.mill new file mode 100644 index 0000000..3699c20 --- /dev/null +++ b/millbun/integration/resources/invalid-bun-specifier/build.mill @@ -0,0 +1,24 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.bun.bun +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object app extends BunScalaJSModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + + override def moduleKind = Task { ModuleKind.CommonJSModule } + override def sources = Task.Sources(moduleDir / "src") + // Empty *specifier*: the name half is valid, so the macro used to accept this and let + // parseDependency throw at task time instead. + override def bunDeps = Task { Seq(bun"react@") } +} diff --git a/millbun/integration/resources/invalid-bun-specifier/src/App.scala b/millbun/integration/resources/invalid-bun-specifier/src/App.scala new file mode 100644 index 0000000..047460e --- /dev/null +++ b/millbun/integration/resources/invalid-bun-specifier/src/App.scala @@ -0,0 +1 @@ +object App diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 532cd82..5d72c45 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -27,6 +27,14 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(!res.isSuccess) } + test("invalid bun specifier fails in build definitions") { + // The macro only checked the package-name half, so bun"react@" compiled cleanly and then + // threw from parseDependency during the install — defeating the interpolator's purpose. + val tester = this.tester("invalid-bun-specifier") + val res = tester.eval("app.bunDeps") + assert(!res.isSuccess) + } + test("published dev-only modules do not emit runtime manifests") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedDevOnlyLib.jar") diff --git a/millbun/src/mill/bun/BunDep.scala b/millbun/src/mill/bun/BunDep.scala index e4a62b7..450e194 100644 --- a/millbun/src/mill/bun/BunDep.scala +++ b/millbun/src/mill/bun/BunDep.scala @@ -20,6 +20,18 @@ extension (sc: StringContext) inline def bun(inline args: Any*): String = ${ BunDepMacro.validateImpl('sc, 'args) } +object BunDep: + /** + * Validate a dependency the macro could not check at compile time, returning it unchanged. + * + * Used for interpolated forms like `bun"react@$version"`, where the value only exists once the + * build evaluates. Failing here still beats failing inside `bun install`. + */ + def validate(dep: String): String = + BunToolchainModule.parseDependency(dep) match + case Right(_) => dep + case Left(message) => throw new IllegalArgumentException(s"Invalid bun dependency: $message") + private object BunDepMacro: import scala.quoted.* @@ -30,8 +42,9 @@ private object BunDepMacro: Expr(literal) case _ => // Has interpolated parts, a non-literal StringContext, or runs inside another - // macro-generated context — build at runtime and skip compile-time validation. - '{ $sc.s($args*) } + // macro-generated context, so the value is not known at compile time. Validate when + // the build evaluates it instead, which still fails before any install runs. + '{ BunDep.validate($sc.s($args*)) } private def literalParts(sc: Expr[StringContext])(using Quotes): Option[Seq[String]] = import quotes.reflect.* @@ -74,30 +87,15 @@ private object BunDepMacro: extractRepeatedArgs(args.asTerm.underlyingArgument).contains(Nil) + /** + * Validate against the same parser the build uses at task time. + * + * This deliberately delegates rather than re-deriving the rules: a second, weaker parser here + * meant `bun"react@"` compiled cleanly and then threw from `parseDependency` during the install, + * which is exactly what the interpolator exists to prevent. + */ private def validateLiteral(dep: String)(using Quotes): Unit = import quotes.reflect.* - if dep.isEmpty then - report.errorAndAbort("bun dependency cannot be empty. Use bun\"package@version\" format.") - // Validate package name format - val name = if dep.startsWith("@") then - // Scoped: @scope/name or @scope/name@version - val afterScope = dep.drop(1) - if !afterScope.contains('/') then - report.errorAndAbort( - s"Invalid scoped package: '$dep'. Expected @scope/name or @scope/name@version" - ) - val slashIdx = afterScope.indexOf('/') - val scopeName = afterScope.take(slashIdx) - if scopeName.isEmpty then - report.errorAndAbort(s"Invalid scoped package: '$dep'. Scope name is empty.") - val afterSlash = afterScope.drop(slashIdx + 1) - val nameOnly = if afterSlash.contains('@') then afterSlash.take(afterSlash.indexOf('@')) else afterSlash - if nameOnly.isEmpty then - report.errorAndAbort(s"Invalid scoped package: '$dep'. Package name is empty after scope.") - dep - else - // Unscoped: name or name@version - val nameOnly = if dep.contains('@') then dep.take(dep.indexOf('@')) else dep - if nameOnly.isEmpty then - report.errorAndAbort(s"Invalid package: '$dep'. Package name cannot be empty.") - dep + BunToolchainModule.parseDependency(dep).left.foreach { message => + report.errorAndAbort(s"Invalid bun dependency: $message") + } diff --git a/millbun/test/src/mill/bun/BunDepTests.scala b/millbun/test/src/mill/bun/BunDepTests.scala index 1ecd98f..7233b97 100644 --- a/millbun/test/src/mill/bun/BunDepTests.scala +++ b/millbun/test/src/mill/bun/BunDepTests.scala @@ -50,7 +50,32 @@ object BunDepTests extends TestSuite { assert(deps.head.startsWith("@anthropic-ai")) } - // Invalid literal coverage lives in integration tests so the interpolator + test("the interpolator and the task-time parser accept the same inputs") { + // The macro used to run its own weaker parser, so bun"react@" compiled and then threw + // during the install. Both must now agree, in both directions. + Seq("react@^19.0.0", "@types/node", "zod", "lodash@~4.17.0", "@scope/pkg@1.0.0").foreach { + valid => + assert(BunDep.validate(valid) == valid) + assert(BunToolchainModule.parseDependency(valid).isRight) + } + + Seq("", "react@", "@types", "@types/bun@", "@/pkg").foreach { invalid => + val error = intercept[IllegalArgumentException](BunDep.validate(invalid)) + assert(error.getMessage.contains("Invalid bun dependency")) + assert(BunToolchainModule.parseDependency(invalid).isLeft) + } + } + + test("interpolated forms are validated when the build evaluates them") { + // Not knowable at compile time, so this is the runtime backstop. + val version = "^19.0.0" + assert(bun"react@$version" == "react@^19.0.0") + + val empty = "" + intercept[IllegalArgumentException](bun"react@$empty") + } + + // Invalid *literal* coverage lives in integration tests so the interpolator // is compiled in a normal build.mill context rather than inside another macro. } } From 98109491cb369bdd553333f07b2b4f4d95fc6459 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 15:07:01 -0400 Subject: [PATCH 06/18] Stop vendored runtimes leaking into a workspace's shared node_modules `BunScalaJSModule.bunInstall` merged vendored node_modules into `dest/node_modules` unconditionally -- including after the workspace branch had symlinked that path to the shared workspace install's Task.dest. The merge therefore wrote into another task's output, shared by every member of the workspace. - `mergeFromClasspathEntry` refuses a symlinked destination root and names the task the directory belongs to. On its own this turns a silent cross-task write into a loud failure. - The merge moved into the non-workspace branch, and workspace members with a vendored-runtime dependency now fail with an actionable message instead. Sharing one node_modules across members is the point of a workspace, so merging per-member was never going to be right; hoisting vendored trees to the workspace install is a feature, not a bug fix, and is left for later. Only reachable with a published vendored-runtime dependency -- mergeFrom- ClasspathEntry no-ops unless the entry carries META-INF/bun/node_modules -- which is why no fixture caught it. Unit 70 passing, integration 41 passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/mill/bun/BunVendoredNodeModules.scala | 14 ++++++++++++-- .../scalajslib/bun/BunScalaJSModule.scala | 17 ++++++++++++++--- .../bun/BunVendoredNodeModulesTests.scala | 19 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/millbun/src/mill/bun/BunVendoredNodeModules.scala b/millbun/src/mill/bun/BunVendoredNodeModules.scala index 623d487..23050c0 100644 --- a/millbun/src/mill/bun/BunVendoredNodeModules.scala +++ b/millbun/src/mill/bun/BunVendoredNodeModules.scala @@ -44,9 +44,19 @@ object BunVendoredNodeModules: FileVisitResult.CONTINUE ) - /** Merge vendored node_modules from a classpath entry into a destination. */ + /** Merge vendored node_modules from a classpath entry into a destination. + * + * Refuses a symlinked destination: in workspace mode `node_modules` is a link into the shared + * workspace install's `Task.dest`, and merging through it would silently mutate another task's + * output — shared by every other member of the workspace. + */ def mergeFromClasspathEntry(entry: os.Path, destNodeModules: os.Path): Boolean = - if os.isDir(entry) then mergeFromDir(entry, destNodeModules, entry.toString) + if os.isLink(destNodeModules) then + throw new RuntimeException( + s"Refusing to merge vendored Bun runtime into $destNodeModules: it is a symlink to " + + s"${os.readLink.absolute(destNodeModules)}, which belongs to another task." + ) + else if os.isDir(entry) then mergeFromDir(entry, destNodeModules, entry.toString) else if os.exists(entry) && entry.ext == "jar" then mergeFromJar(entry, destNodeModules) else false diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 1b86361..fbb8800 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -254,8 +254,21 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac transitiveUnmanagedDeps().nonEmpty || bunPackageJsonExtras().value.nonEmpty + val ownResourceRoots = resources().map(_.path).toSet + val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) + bunWorkspaceInstall() match case Some(workspaceInstall) => + // Vendored trees must not be merged here: node_modules is a link into the workspace + // install's dest, so merging would mutate a directory shared by every workspace member. + val vendored = vendoredEntries.filter(BunVendoredNodeModules.hasVendoredNodeModules) + if vendored.nonEmpty then + Task.fail( + s"Bun workspace members cannot consume vendored runtime dependencies: " + + s"${vendored.map(_.last).mkString(", ")}. Depend on the manifest-only artifact, or " + + "install this module outside the workspace." + ) + val installed = workspaceInstall.path if os.exists(installed / "node_modules") then os.symlink(dest / "node_modules", installed / "node_modules") @@ -281,9 +294,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac env = bunEnv() ) - val ownResourceRoots = resources().map(_.path).toSet - val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) - mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") + mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") PathRef(dest) } diff --git a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala index 54caa89..ac34238 100644 --- a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -100,6 +100,25 @@ object BunVendoredNodeModulesTests extends TestSuite { assert(err.getMessage.contains("Vendored Bun bundle conflict")) } + + test("merging through a symlinked destination is refused") { + // In workspace mode node_modules links into the shared install's Task.dest. Merging + // through it would silently mutate a directory every workspace member depends on. + val source = os.temp.dir() + writeVendoredPackage(source, "react", "19.1.1") + + val shared = os.temp.dir() / "shared-node-modules" + os.makeDir.all(shared) + val member = os.temp.dir() + os.symlink(member / "node_modules", shared) + + val err = intercept[RuntimeException] { + BunVendoredNodeModules.mergeFromClasspathEntry(source, member / "node_modules") + } + assert(err.getMessage.contains("belongs to another task")) + // The critical assertion: nothing leaked into the shared directory. + assert(os.list(shared).isEmpty) + } } private def writeVendoredPackage(root: os.Path, name: String, version: String): Unit = { From 5967ae81dbbd8b09eb557d2cc8bfed479a7eaa00 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 17:45:17 -0400 Subject: [PATCH 07/18] Harden the shared Bun download cache against eviction and torn publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An evicted cache entry was never repopulated: downloadedBunExecutable returned a Revalidate.Never PathRef to a file outside every Task.dest, so deleting ~/.cache/mill-bun left every build trusting a path that no longer exists until the user guessed the right `clean`. Cache refs now carry quick (mtime+size) signatures with Revalidate.Always — recovery costs one stat per evaluation. The cross-filesystem publish fallback copied straight to the checksum-keyed final path, so a concurrent reader could execute a partially written binary and a build killed mid-copy poisoned the cache permanently. The fallback now stages next to the final path and renames — the publish is always a same-filesystem atomic move — and preserves the executable bit the old os.copy.over silently dropped. Losing a publish race is judged by the outcome (cached exists), not the exception type, since Windows reports a sharing violation rather than FileAlreadyExistsException. Also: copyTree preserves relative symlink targets verbatim instead of absolutizing them into the source tree; a relative MILL_BUN_CACHE_DIR resolves against the workspace root instead of crashing; the CI build job pins MILL_BUN_USE_SYSTEM=true so the system-Bun path keeps coverage now that the suite defaults to managed; and pull_request CI is no longer restricted to PRs targeting main, so stacked PRs get checks. Every fix was confirmed to fail on the parent commit before trusting its pass: the eviction integration test (forked evals with a private MILL_BUN_CACHE_DIR) fails there with a successful eval pointing at a missing file, and the relative-symlink test fails with an absolutized target. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++- .../BunManagedToolchainIntegrationTests.scala | 26 +++++++++ millbun/src/mill/bun/BunToolchainModule.scala | 53 ++++++++++++++++--- .../test/src/mill/bun/BunToolchainTests.scala | 25 +++++++++ millbun/test/src/mill/bun/CopyTreeTests.scala | 15 ++++++ 5 files changed, 116 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e36b0e8..1694ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: build: @@ -29,7 +28,12 @@ jobs: - name: Unit Tests run: ./mill --no-server millbun.test + # The suite defaults to the managed toolchain; this job pins system Bun to the same + # version so `findOnPath` resolution and version verification keep CI coverage, while + # the managed-bun job covers the download/cache path. - name: Integration Tests + env: + MILL_BUN_USE_SYSTEM: 'true' run: ./mill --no-server millbun.integration managed-bun: diff --git a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala index 3aae15f..366c1c5 100644 --- a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -20,3 +20,29 @@ object BunManagedToolchainIntegrationTests extends TestSuite: val executable = tester.out("app.bunExecutable").value[String] val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim assert(version == "1.4.0") + + test("an evicted download cache is repopulated, not trusted"): + val cacheDir = os.temp.dir() + val env = Map("MILL_BUN_CACHE_DIR" -> cacheDir.toString) + // Forked evals, not in-memory: `Task.env` reads the Mill process's own environment, so a + // per-eval env override only reaches a subprocess. + val tester = new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / "managed-bun", + millExecutable = millExe, + useInMemory = false + ) + assert(tester.eval("app.bunExecutable", env = env).isSuccess) + // Proves MILL_BUN_CACHE_DIR reached the build before the eviction step relies on it. + assert(os.walk(cacheDir).exists(p => os.isFile(p))) + + // Users legitimately evict the cache directory; the build must recover on its own + // rather than trust a stale task result pointing at a file that no longer exists. + os.remove.all(cacheDir) + os.makeDir.all(cacheDir) + + assert(tester.eval("app.bunExecutable", env = env).isSuccess) + val executable = tester.out("app.bunExecutable").value[String] + assert(os.isFile(os.Path(executable))) + val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim + assert(version == "1.4.0") diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index af89292..980d333 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -206,13 +206,46 @@ object BunToolchainModule { ) cached catch - case _: java.nio.file.FileAlreadyExistsException => cached - case _: java.nio.file.AtomicMoveNotSupportedException => - // Cache on a different filesystem than the task dest: fall back to a plain copy. - os.copy.over(staged, cached, createFolders = true) + case _: java.nio.file.AtomicMoveNotSupportedException => publishViaCopy(staged, cached) + case scala.util.control.NonFatal(_) if os.exists(cached) => + // Lost the publish race. The path is keyed by the verified checksum, so the winner's + // bytes are the right bytes. Windows reports this as a sharing violation rather than + // FileAlreadyExistsException, so match on the outcome, not the exception type. cached } + /** + * Publish across filesystems by staging a copy next to the final path, so the last hop is a + * same-filesystem atomic rename. Copying straight to the published name would let a concurrent + * reader execute a partially written binary — and a build killed mid-copy would leave a + * truncated file that every later build trusts, because the path is the checksum. + */ + private[bun] def publishViaCopy(staged: os.Path, cached: os.Path): os.Path = { + val tmp = cached / os.up / + s"${cached.last}.tmp-${ProcessHandle.current().pid()}-${System.nanoTime()}" + os.copy(staged, tmp, copyAttributes = true, createFolders = true) + try + java.nio.file.Files.move( + tmp.toNIO, + cached.toNIO, + java.nio.file.StandardCopyOption.ATOMIC_MOVE + ) + catch + case scala.util.control.NonFatal(_) if os.exists(cached) => os.remove.all(tmp) + cached + } + + /** + * A `PathRef` into the shared download cache. + * + * The file lives outside every `Task.dest`, so Mill must re-check it each evaluation: entries + * are immutable but evictable, and without revalidation an evicted executable is never + * re-downloaded — every bun invocation fails until the user guesses the right `clean`. Quick + * (mtime + size) signatures keep the per-evaluation cost to a stat. + */ + private[bun] def cachedExecutableRef(cached: os.Path): PathRef = + PathRef(cached, quick = true).withRevalidate(PathRef.Revalidate.Always) + /** Parse package.json-style `name@specifier` declarations without slicing scoped names incorrectly. */ private[bun] def parseDependency(input: String): Either[String, NpmDependency] = { val trimmed = input.trim @@ -323,7 +356,10 @@ object BunToolchainModule { if (os.isLink(path)) { os.makeDir.all(target / os.up) if (os.exists(target, followLinks = false)) os.remove(target) - os.symlink(target, os.readLink.absolute(path)) + // Preserve the raw target: absolutizing a tree-internal relative link (bun's + // node_modules/.bin entries) would point the copy back into the source tree, so the + // copy stops being self-contained the moment the source is cleaned or relocated. + os.symlink(target, os.readLink(path)) } else if (os.isDir(path, followLinks = false)) { os.makeDir.all(target) } else { @@ -416,7 +452,8 @@ trait BunToolchainModule extends Module { * Lives outside the workspace, so Mill's filesystem checker does not restrict it. */ def bunDownloadCacheDir: T[os.Path] = Task.Input { - Task.env.get("MILL_BUN_CACHE_DIR").filter(_.nonEmpty).map(os.Path(_)) + Task.env.get("MILL_BUN_CACHE_DIR").filter(_.nonEmpty) + .map(os.Path(_, BuildCtx.workspaceRoot)) .getOrElse(os.home / ".cache" / "mill-bun") } @@ -560,7 +597,7 @@ trait BunToolchainModule extends Module { // Keyed by the verified checksum, so a cache hit is proof of the right bytes. val cached = bunDownloadCacheDir() / checksum / dist.executableName - if (os.exists(cached)) PathRef(cached) + if (os.exists(cached)) BunToolchainModule.cachedExecutableRef(cached) else { val archive = Task.dest / dist.assetName val staged = Task.dest / dist.executableName @@ -570,7 +607,7 @@ trait BunToolchainModule extends Module { Task.fail(s"Bun archive checksum mismatch for $url: expected $checksum, received $actual") } BunToolchainModule.extractExecutable(archive, dist.executableName, staged) - PathRef(BunToolchainModule.publishToCache(staged, cached)) + BunToolchainModule.cachedExecutableRef(BunToolchainModule.publishToCache(staged, cached)) } } diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index d1ac89c..98bddf3 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -126,6 +126,31 @@ object BunToolchainTests extends TestSuite: assert(BunToolchainModule.publishToCache(second, cached) == cached) assert(os.read(cached) == "bun-binary") + test("cross-filesystem publish preserves permissions and leaves no temp debris"): + val root = os.temp.dir() + val cached = root / "cache" / "def456" / "bun" + val staged = root / "staged" / "bun" + os.write(staged, "bun-binary", createFolders = true) + os.perms.set(staged, "rwxr-xr-x") + + assert(BunToolchainModule.publishViaCopy(staged, cached) == cached) + assert(os.read(cached) == "bun-binary") + assert(os.perms(cached).toString == "rwxr-xr-x") + // The bytes must travel under a temp name and arrive by rename: a crash mid-publish can + // never leave a partial file at the published path, and success leaves nothing behind. + assert(os.list(cached / os.up) == Seq(cached)) + + test("cross-filesystem publish tolerates losing the race"): + val root = os.temp.dir() + val cached = root / "cache" / "0123" / "bun" + os.write(cached, "bun-binary", createFolders = true) + val staged = root / "staged" / "bun" + os.write(staged, "bun-binary", createFolders = true) + + assert(BunToolchainModule.publishViaCopy(staged, cached) == cached) + assert(os.read(cached) == "bun-binary") + assert(os.list(cached / os.up) == Seq(cached)) + test("computes SHA-256"): val file = os.temp(contents = "hello") assert( diff --git a/millbun/test/src/mill/bun/CopyTreeTests.scala b/millbun/test/src/mill/bun/CopyTreeTests.scala index c36807b..db629e7 100644 --- a/millbun/test/src/mill/bun/CopyTreeTests.scala +++ b/millbun/test/src/mill/bun/CopyTreeTests.scala @@ -56,6 +56,21 @@ object CopyTreeTests extends TestSuite: assert(os.isLink(dest / "alias.js")) assert(os.read(dest / "alias.js") == "real") + test("relative symlink targets are preserved verbatim"): + // bun's node_modules/.bin entries are relative links; absolutizing them would point the + // copy back into the source tree, dangling as soon as the source task is cleaned. + val source = os.temp.dir() + os.write(source / "esbuild" / "bin" / "esbuild", "#!/usr/bin/env node", createFolders = true) + os.makeDir.all(source / ".bin") + os.symlink(source / ".bin" / "esbuild", os.RelPath("../esbuild/bin/esbuild")) + + val dest = os.temp.dir() + BunToolchainModule.copyTree(source, dest) + assert(os.readLink(dest / ".bin" / "esbuild") == os.RelPath("../esbuild/bin/esbuild")) + // Self-contained: the copied link resolves inside the copy even after the source vanishes. + os.remove.all(source) + assert(os.read(dest / ".bin" / "esbuild") == "#!/usr/bin/env node") + test("nested directories and empty directories are preserved"): val source = os.temp.dir() os.makeDir.all(source / "empty") From 9282ea459d0e9e1a6c35aa9c9a1abe7d92f8c226 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 17:52:23 -0400 Subject: [PATCH 08/18] Make bare test modules actually reuse the outer install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BunTypeScriptTests extends upstream Mill's TypeScriptTests, so the unqualified this.tsDeps() inside bunTestPackageJson resolved to the Node toolchain defaults (ts-node, tsconfig-paths, @types/node) that the outer trait deliberately replaced. Those names always survived the outer-name filter, so a bare test module's package.json never equaled the outer's and the reusesOuterInstall branch was unreachable — with bunRequireLockfile on, every bare test module demanded its own lockfile, contradicting the documented contract. The trait now pins tsDeps to the outer module's. The guard test passed anyway because both of its assertions also hold in the broken standalone branch (the suite relaxes the lockfile requirement, and installs never write into the source tree). It now also asserts the returned install path is the outer module's npmInstall.dest — confirmed failing before this fix and passing after. Also: dev() on BunTypeScriptWebModule served straight out of the cached webStage output while its sync thread mirrored live edits (but never deletions) into it, and bundle builds from that same stage — a file created and deleted during a dev session would ship in the next production bundle. dev() now serves from a private copy in its own command dest; copyTree preserves the node_modules symlink, so the copy is cheap. Co-Authored-By: Claude Fable 5 --- .../src/mill/bun/BunTypeScriptIntegrationTests.scala | 4 ++++ .../mill/javascriptlib/bun/BunTypeScriptModule.scala | 12 ++++++++++++ .../javascriptlib/bun/BunTypeScriptWebModule.scala | 6 +++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 0aa008e..45eb959 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -243,6 +243,10 @@ object BunTypeScriptIntegrationTests extends TestSuite { val tester = this.tester("typescript-tests") assert(tester.eval("app.test.npmInstall").isSuccess) assert(!os.exists(tester.workspacePath / "test" / "bun.lock")) + // And must actually reuse the outer install — not run a second one that merely succeeds + // because the lockfile requirement happens to be off in this suite. + val installPath = outputPath(tester, "app.test.npmInstall") + assert(installPath == tester.workspacePath / "out" / "app" / "npmInstall.dest") } test("bunEnv") { diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 44644f0..6ff9dbc 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -409,6 +409,18 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with */ trait BunTypeScriptTests extends TypeScriptTests { + /** + * The outer module's Bun-specific TS toolchain, not upstream Mill's ts-node defaults. + * + * This trait extends upstream `TypeScriptTests`, so an unqualified `tsDeps()` resolves to + * the Node toolchain (`ts-node`, `tsconfig-paths`, `@types/node`) that the outer trait + * deliberately replaced. Those names always survived the outer-name filter in + * [[bunTestPackageJson]], so a bare test module's package.json never matched the outer's + * and the install-reuse path in [[npmInstall]] was unreachable — with `bunRequireLockfile` + * on, every bare test module demanded its own lockfile. + */ + override def tsDeps: T[Seq[String]] = Task { outer.tsDeps() } + /** Test timeout in milliseconds. 0 means no timeout. */ def bunTestTimeout: T[Int] = Task { 0 } diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala index b66d9e9..22c9ac6 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -74,7 +74,11 @@ trait BunTypeScriptWebModule extends BunTypeScriptModule: /** Start Bun's HTML development server with source mirroring for native HMR. */ def dev(): Command[Unit] = Task.Command { - val stage = webStage().path + // Serve from a private copy: the sync thread mirrors live edits (but never deletions) into + // the serving root, and `bundle` builds from the same cached stage — mutating it in place + // would let a file created and deleted during a dev session ship in the production bundle. + val stage = Task.dest / "stage" + mill.bun.BunToolchainModule.copyTree(webStage().path, stage) val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage) val syncRoots = (sources() ++ generatedSources() ++ resources() ++ webEntryPoints() ++ webPublicSources()) .filter(ref => os.exists(ref.path) && ref.path.startsWith(moduleDir)) From 1270081cd98277e708e6121519a98c11893447ca Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:07:20 -0400 Subject: [PATCH 09/18] Deliver unmanagedDeps through package.json, not positional install args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local paths were passed as positional arguments to bun install, which turns the invocation into bun add — unconditionally rejected by --frozen-lockfile. unmanagedDeps therefore never worked against a lockfile, in any module type, and a lock produced by bunLock recorded the absolute checkout path. Each entry is now staged into vendor/ beside the generated package.json and declared as file:./vendor/, so the lock records the checkout-independent file:vendor/. Validated against real Bun 1.4.0: the lock survives relocating the repository and frozen installs resolve the package. The staging excludes the local package's own node_modules; bun resolves its declared dependencies through the lockfile instead. Applied uniformly: Scala.js and TypeScript installs and bunLock, the TypeScript test trait (union of outer and test-side deps, preserving the bare-module reuse equality), the publish module's vendored runtime install, and Bun workspaces — where vendor trees are staged in the layout beside each member's package.json. A name declared both as an npm dependency and an unmanaged dependency is rejected, as are two unmanaged directories claiming the same package name. Published manifests are unaffected: they are built from the npm dependency lists, so file: specifiers cannot leak to consumers. This is the first coverage unmanagedDeps has ever had. The new typescript-unmanaged fixture locks, frozen-installs, bundles, and runs against a local package with bunRequireLockfile enabled; the mixed-workspace fixture asserts the member manifest and staged vendor tree and that no positional args reach the stubbed install. Both fail on the parent commit — the TypeScript lock never contains file:vendor/local-lib, and the workspace member manifest has no shared-local entry. Co-Authored-By: Claude Fable 5 --- .../resources/mixed-workspace/build.mill | 1 + .../scalaApp/shared-local/index.js | 1 + .../scalaApp/shared-local/package.json | 5 ++ .../resources/typescript-unmanaged/build.mill | 16 +++++ .../typescript-unmanaged/local-lib/index.d.ts | 1 + .../typescript-unmanaged/local-lib/index.js | 1 + .../local-lib/package.json | 6 ++ .../typescript-unmanaged/src/main.ts | 3 + .../bun/BunTypeScriptIntegrationTests.scala | 20 ++++++ .../bun/BunWorkspaceIntegrationTests.scala | 8 +++ millbun/src/mill/bun/BunToolchainModule.scala | 72 +++++++++++++++++++ millbun/src/mill/bun/BunWorkspaceModule.scala | 10 +-- .../bun/BunTypeScriptModule.scala | 30 +++++--- .../scalajslib/bun/BunPublishModule.scala | 8 ++- .../scalajslib/bun/BunScalaJSModule.scala | 19 +++-- .../test/src/mill/bun/BunToolchainTests.scala | 41 +++++++++++ 16 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js create mode 100644 millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json create mode 100644 millbun/integration/resources/typescript-unmanaged/build.mill create mode 100644 millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts create mode 100644 millbun/integration/resources/typescript-unmanaged/local-lib/index.js create mode 100644 millbun/integration/resources/typescript-unmanaged/local-lib/package.json create mode 100644 millbun/integration/resources/typescript-unmanaged/src/main.ts diff --git a/millbun/integration/resources/mixed-workspace/build.mill b/millbun/integration/resources/mixed-workspace/build.mill index d066eab..d158b78 100644 --- a/millbun/integration/resources/mixed-workspace/build.mill +++ b/millbun/integration/resources/mixed-workspace/build.mill @@ -16,6 +16,7 @@ object scalaApp extends BunScalaJSModule { def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } override def npmDeps = Task { Seq("is-even@1.0.0") } + override def unmanagedDeps = Task.Sources(moduleDir / "shared-local") override def classpathBunDeps = Task { Seq.empty } override def classpathBunOptionalDeps = Task { Seq.empty } override def classpathBunPeerDeps = Task { Seq.empty } diff --git a/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js new file mode 100644 index 0000000..db04e4a --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js @@ -0,0 +1 @@ +module.exports = { shared: true }; diff --git a/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json new file mode 100644 index 0000000..9946b8c --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json @@ -0,0 +1,5 @@ +{ + "name": "shared-local", + "version": "1.0.0", + "main": "index.js" +} diff --git a/millbun/integration/resources/typescript-unmanaged/build.mill b/millbun/integration/resources/typescript-unmanaged/build.mill new file mode 100644 index 0000000..1333f47 --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/build.mill @@ -0,0 +1,16 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def unmanagedDeps = Task.Sources(moduleDir / "local-lib") + // The regression this fixture guards: local packages must install against a frozen lockfile. + override def bunRequireLockfile = Task { true } +} diff --git a/millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts b/millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts new file mode 100644 index 0000000..a5e4638 --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts @@ -0,0 +1 @@ +export declare function greet(): string; diff --git a/millbun/integration/resources/typescript-unmanaged/local-lib/index.js b/millbun/integration/resources/typescript-unmanaged/local-lib/index.js new file mode 100644 index 0000000..f3232ef --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/local-lib/index.js @@ -0,0 +1 @@ +module.exports = { greet: () => "hello from local-lib" }; diff --git a/millbun/integration/resources/typescript-unmanaged/local-lib/package.json b/millbun/integration/resources/typescript-unmanaged/local-lib/package.json new file mode 100644 index 0000000..027fb76 --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/local-lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "local-lib", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/millbun/integration/resources/typescript-unmanaged/src/main.ts b/millbun/integration/resources/typescript-unmanaged/src/main.ts new file mode 100644 index 0000000..a668f3c --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/src/main.ts @@ -0,0 +1,3 @@ +import { greet } from "local-lib"; + +console.log(greet()); diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 45eb959..f4854a8 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -238,6 +238,26 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(tester.eval("app.test.test").isSuccess) } + test("unmanaged local packages install under a frozen lockfile") { + // Positional install paths turned `bun install` into `bun add`, which --frozen-lockfile + // unconditionally rejects — unmanagedDeps never worked against a lockfile at all. + val tester = this.tester("typescript-unmanaged") + assert(tester.eval("app.bunLock").isSuccess) + val lock = os.read(tester.workspacePath / "bun.lock") + assert(lock.contains("file:vendor/local-lib")) + // The lock must not record where this repository happens to be checked out. + assert(!lock.contains(tester.workspacePath.toString)) + + assert(tester.eval("app.npmInstall").isSuccess) + val installed = outputPath(tester, "app.npmInstall") + assert(os.exists(installed / "node_modules" / "local-lib" / "package.json")) + + assert(tester.eval("app.bundle").isSuccess) + val bundle = outputPath(tester, "app.bundle") + val run = os.call(Seq("bun", bundle.toString)) + assert(run.out.text().contains("hello from local-lib")) + } + test("test modules adding nothing reuse the outer install") { // A bare test module must not demand a second lockfile. val tester = this.tester("typescript-tests") diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 2d0d6c9..51aee0f 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -39,6 +39,14 @@ object BunWorkspaceIntegrationTests extends TestSuite: assert(os.exists(workspaceInstall / "node_modules" / "is-even" / "package.json")) assert(os.exists(workspaceInstall / "node_modules" / "is-odd" / "package.json")) + // Unmanaged local packages arrive as file: specifiers with vendor trees staged beside the + // member's package.json — never as positional install args, which turn `bun install` into + // `bun add` and are unconditionally rejected by --frozen-lockfile. + val scalaJson = ujson.read(os.read(workspaceInstall / "packages" / "scalaApp" / "package.json")) + assert(scalaJson("dependencies").obj("shared-local").str == "file:./vendor/shared-local") + assert(os.exists(workspaceInstall / "packages" / "scalaApp" / "vendor" / "shared-local" / "package.json")) + assert(!os.read(workspaceInstall / ".workspace-installed").contains("shared-local")) + val scalaResult = tester.eval("scalaApp.bunInstall") val typescriptResult = tester.eval("typescriptApp.npmInstall") assert(scalaResult.isSuccess) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 980d333..5cb1f34 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -301,6 +301,78 @@ object BunToolchainModule { } } + /** + * npm dependency pairs plus `file:` pairs for local (unmanaged) packages. + * + * Local paths must arrive through the generated package.json, never as positional + * `bun install` arguments: a positional path turns the invocation into `bun add`, which + * `--frozen-lockfile` unconditionally rejects — so unmanaged deps could never install against + * a lockfile at all. The specifier points under `vendor/` beside the package.json (staged by + * [[stageUnmanagedDeps]]), so the recorded lock entry (`file:vendor/`) is independent + * of where the repository is checked out. + */ + private[mill] def dependencyPairsWithUnmanaged( + npm: Seq[(String, ujson.Str)], + unmanaged: Seq[PathRef] + ): Seq[(String, ujson.Str)] = { + val filePairs = unmanagedDependencyPairs(unmanaged) + val collisions = npm.map(_._1).toSet.intersect(filePairs.map(_._1).toSet).toSeq.sorted + if (collisions.nonEmpty) { + throw new IllegalArgumentException( + s"Declared both as an npm dependency and in unmanagedDeps: ${collisions.mkString(", ")}. " + + "A package can be resolved from the registry or from a local directory, not both." + ) + } + npm ++ filePairs + } + + private[bun] def unmanagedDependencyPairs(deps: Seq[PathRef]): Seq[(String, ujson.Str)] = { + val named = deps.map(_.path).distinct.map(path => unmanagedPackageName(path) -> path) + val duplicates = named.groupBy(_._1).collect { + case (name, entries) if entries.map(_._2).distinct.size > 1 => + s"$name (${entries.map(_._2).distinct.mkString(", ")})" + }.toSeq.sorted + if (duplicates.nonEmpty) { + throw new IllegalArgumentException( + s"Multiple unmanagedDeps declare the same package name: ${duplicates.mkString("; ")}." + ) + } + named.distinctBy(_._1).sortBy(_._1).map { case (name, _) => + name -> ujson.Str(s"file:./vendor/${vendorDirectoryName(name)}") + } + } + + private[bun] def unmanagedPackageName(source: os.Path): String = { + if (!os.isDir(source)) { + throw new IllegalArgumentException( + s"Unmanaged Bun dependency $source is not a directory. Point unmanagedDeps at unpacked " + + "package directories containing a package.json." + ) + } + val packageJson = source / "package.json" + if (!os.exists(packageJson)) { + throw new IllegalArgumentException(s"Unmanaged Bun dependency $source has no package.json.") + } + ujson.read(os.read(packageJson)).obj.get("name") match { + case Some(ujson.Str(name)) if name.nonEmpty => name + case _ => + throw new IllegalArgumentException(s"$packageJson does not declare a package name.") + } + } + + /** Scoped names need one path segment: `@scope/pkg` becomes `scope+pkg`, as in Bun workspaces. */ + private[bun] def vendorDirectoryName(name: String): String = + name.stripPrefix("@").replace('/', '+') + + /** Copy each unmanaged package into `vendor/` beside the generated package.json. */ + private[mill] def stageUnmanagedDeps(deps: Seq[PathRef], installRoot: os.Path): Unit = + deps.map(_.path).distinct.foreach { source => + val name = unmanagedPackageName(source) + // A local package's own node_modules is development debris; bun resolves the package's + // declared dependencies through the lockfile instead. + copyTree(source, installRoot / "vendor" / vendorDirectoryName(name), exclude = Set("node_modules")) + } + /** Add unmodeled package.json fields without allowing typed dependency data to be replaced. */ def mergePackageJson(base: ujson.Obj, extras: ujson.Obj): ujson.Obj = { val conflicts = extras.value.keySet.intersect(ModeledPackageJsonFields).toSeq.sorted diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index a651969..fd5b4d5 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -67,13 +67,16 @@ trait BunWorkspaceModule extends BunToolchainModule: if duplicateDirectories.nonEmpty then Task.fail(s"Bun workspace package names map to duplicate directories: ${duplicateDirectories.mkString(", ")}") - packages.foreach { case (name, json, _) => + packages.foreach { case (name, json, unmanaged) => val directory = packageDirectory(name) os.write.over( Task.dest / "packages" / directory / "package.json", json.render(indent = 2), createFolders = true ) + // Members declare local packages as `file:./vendor/` relative to their own + // package.json, so their vendor trees live beside it in the layout. + BunToolchainModule.stageUnmanagedDeps(unmanaged, Task.dest / "packages" / directory) } val root = ujson.Obj( @@ -122,7 +125,7 @@ trait BunWorkspaceModule extends BunToolchainModule: bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ packages.flatMap(_._3).map(_.path.toString), + ), cwd = Task.dest, env = bunEnv() ) @@ -131,7 +134,6 @@ trait BunWorkspaceModule extends BunToolchainModule: /** Resolve the full workspace and update its source-controlled `bun.lock`. */ def bunLock(): Command[PathRef] = Task.Command { - val packages = resolvedPackages() BunToolchainModule.copyWorkspace(bunWorkspaceLayout().path, Task.dest) copyConfigs(Task.dest, npmRc().path, bunfigFiles()) copyBunLockfile(bunLockfile(), Task.dest) @@ -143,7 +145,7 @@ trait BunWorkspaceModule extends BunToolchainModule: bunInstallExtraArgs(), bunLockfile().nonEmpty, updateLockfile = true - ) ++ packages.flatMap(_._3).map(_.path.toString), + ), cwd = Task.dest, env = bunEnv() ) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 6ff9dbc..6fcedde 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -93,7 +93,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with name = if (user.name.nonEmpty) user.name else moduleName, version = if (user.version.nonEmpty) user.version else "1.0.0", `type` = if (enableEsm()) "module" else user.`type`, - dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), + transitiveUnmanagedDeps() + )), devDependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps() ++ tsDeps(), overrides)) ).cleanJson.obj.toSeq ) @@ -163,6 +166,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val lockfile = bunLockfile() requireBunLockfile(true, lockfile, bunRequireLockfile()) copyBunLockfile(lockfile, dest) + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), @@ -171,7 +175,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunToolEnv() ) @@ -188,6 +192,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with mkBunPackageJson() copyBunWorkspaceConfigs() copyBunLockfile(bunLockfile(), dest) + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), @@ -196,7 +201,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunInstallExtraArgs(), bunLockfile().nonEmpty, updateLockfile = true - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunToolEnv() ) @@ -442,7 +447,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with def bunTestPackageJson: T[ujson.Obj] = Task { val user = outer.packageJson() val overrides = outer.npmOverrides() - val outerDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides) + val outerDeps = BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides), + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct + ) val outerDevDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDevDeps() ++ outer.tsDeps(), overrides) val outerPackageNames = (outerDeps.iterator ++ outerDevDeps.iterator).map(_._1).toSet @@ -511,6 +519,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with lockfilePath = moduleDir / "bun.lock" ) outer.copyBunLockfile(lockfile, dest) + BunToolchainModule.stageUnmanagedDeps( + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct, + dest + ) outer.runBun( outer.bunExecutable(), @@ -519,8 +531,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with outer.bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()) - .distinct.map(_.path.toString), + ), cwd = dest, env = outer.bunToolEnv() ) @@ -545,6 +556,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with ) outer.copyBunWorkspaceConfigs() outer.copyBunLockfile(this.bunLockfile(), dest) + BunToolchainModule.stageUnmanagedDeps( + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct, + dest + ) outer.runBun( outer.bunExecutable(), @@ -553,8 +568,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with outer.bunInstallExtraArgs(), this.bunLockfile().nonEmpty, updateLockfile = true - ) ++ (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()) - .distinct.map(_.path.toString), + ), cwd = dest, env = outer.bunToolEnv() ) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index e8a1ecb..74025de 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -62,7 +62,10 @@ trait BunPublishModule extends BunScalaJSModule { os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } - val deps = BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()) + val deps = BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()), + unmanagedDeps() + ) val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps() ++ bunOptionalDeps(), npmOverrides()) val base = ujson.Obj( "name" -> defaultPackageName, @@ -85,6 +88,7 @@ trait BunPublishModule extends BunScalaJSModule { requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile()) copyBunLockfile(lockfile, dest) if hasRuntimeInputs then + BunToolchainModule.stageUnmanagedDeps(unmanagedDeps(), dest) runBun( bunExecutable(), Seq("install") ++ resolvedBunInstallArgs( @@ -92,7 +96,7 @@ trait BunPublishModule extends BunScalaJSModule { bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ unmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunEnv() ) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index fbb8800..18316ba 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -39,7 +39,13 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac */ def bunDevDeps: T[Seq[String]] = Task { Seq.empty } - /** Local tarballs / package directories. */ + /** + * Local package directories, each containing a `package.json` with a name. + * + * Every entry is staged into `vendor/` beside the generated package.json and declared as a + * `file:./vendor/` dependency, so the recorded lockfile entry stays independent of the + * checkout path and frozen installs work. Tarballs are not supported — unpack them. + */ def unmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } private def npmRc = Task.Source(BuildCtx.workspaceRoot / ".npmrc") @@ -200,7 +206,10 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac "name" -> defaultPackageName, "private" -> true, "version" -> "0.0.0", - "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), + transitiveUnmanagedDeps() + )), "devDependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps(), overrides)) ) if allOptional.nonEmpty then @@ -282,6 +291,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac copyBunLockfile(lockfile, dest) if hasInstallInputs then + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), Seq("install") ++ resolvedBunInstallArgs( @@ -289,7 +299,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunEnv() ) @@ -310,6 +320,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } mkBunPackageJson() + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) copyBunLockfile(bunLockfile(), dest) runBun( @@ -319,7 +330,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bunInstallExtraArgs(), bunLockfile().nonEmpty, updateLockfile = true - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunEnv() ) diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index 98bddf3..94d9c12 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -151,6 +151,47 @@ object BunToolchainTests extends TestSuite: assert(os.read(cached) == "bun-binary") assert(os.list(cached / os.up) == Seq(cached)) + test("unmanaged deps become file: specifiers under vendor"): + val dep = os.temp.dir() / "local-lib" + os.write(dep / "package.json", """{"name":"local-lib","version":"1.0.0"}""", createFolders = true) + val scoped = os.temp.dir() / "scoped" + os.write(scoped / "package.json", """{"name":"@acme/util","version":"2.0.0"}""", createFolders = true) + + val pairs = BunToolchainModule.unmanagedDependencyPairs(Seq(mill.api.PathRef(dep), mill.api.PathRef(scoped))) + assert(pairs == Seq( + "@acme/util" -> ujson.Str("file:./vendor/acme+util"), + "local-lib" -> ujson.Str("file:./vendor/local-lib") + )) + + test("unmanaged dep without a package.json fails with guidance"): + val dep = os.temp.dir() / "raw" + os.makeDir.all(dep) + val err = intercept[IllegalArgumentException] { + BunToolchainModule.unmanagedDependencyPairs(Seq(mill.api.PathRef(dep))) + } + assert(err.getMessage.contains("package.json")) + + test("a name declared both as npm dep and unmanaged dep is rejected"): + val dep = os.temp.dir() / "local-react" + os.write(dep / "package.json", """{"name":"react","version":"1.0.0"}""", createFolders = true) + val err = intercept[IllegalArgumentException] { + BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(Seq("react@^19.0.0")), + Seq(mill.api.PathRef(dep)) + ) + } + assert(err.getMessage.contains("react")) + + test("staging copies packages into vendor and skips their node_modules"): + val dep = os.temp.dir() / "local-lib" + os.write(dep / "package.json", """{"name":"local-lib","version":"1.0.0"}""", createFolders = true) + os.write(dep / "node_modules" / "junk" / "package.json", "{}", createFolders = true) + + val root = os.temp.dir() + BunToolchainModule.stageUnmanagedDeps(Seq(mill.api.PathRef(dep)), root) + assert(os.exists(root / "vendor" / "local-lib" / "package.json")) + assert(!os.exists(root / "vendor" / "local-lib" / "node_modules")) + test("computes SHA-256"): val file = os.temp(contents = "hello") assert( From 141058a4b1b0f7215c3d032ca88c5fd4e9b43ceb Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:22:49 -0400 Subject: [PATCH 10/18] Finish the 0.3.0 renames: bunInstall, testForked, bunBundleFormat, env hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript install task is canonically bunInstall; Mill's inherited npmInstall delegates to it and stays usable. All internal call sites use the canonical name, so install artifacts live in bunInstall.dest. Both module kinds share one test entrypoint name: testForked. The TypeScript test command `test` is a deprecated alias, and the Scala.js bunTest — which duplicated the inherited testForked exactly, since the overridden jsEnvConfig and testLinkTask already put every test run on Bun — is deprecated in its favor. TypeScript bunBundleFormat is Option[String], matching Scala.js. The type disagreement was the one rename no alias could bridge; 0.3.0 takes the one-time break, and None now lets `bun build` infer the format. BunWorkersModule.workerBundleFormat follows it directly. Environment hooks share one vocabulary: bunToolEnv (install, lock, build subprocesses) is defined once on BunToolchainModule and used by every runBun call site — Scala.js, TypeScript, workspace, publish, and workers modules previously mixed bunEnv() and bunToolEnv() — and the TypeScript bunRuntimeEnv is public instead of protected. Integration tests exercise the canonical names throughout, with explicit alias regression coverage: npmInstall must resolve to bunInstall.dest, and the deprecated test / bunTest commands must keep working until their removal at 1.0. MIGRATING-0.3.md documents the new rows and the bunBundleFormat break with a before/after example. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++ README.md | 4 +- docs/MIGRATING-0.3.md | 27 +++++++++ examples/build.mill | 2 +- .../resources/typescript-bundle/build.mill | 2 +- .../mill/bun/BunScalaJSIntegrationTests.scala | 8 ++- .../bun/BunTypeScriptIntegrationTests.scala | 48 ++++++++------- .../bun/BunWorkspaceIntegrationTests.scala | 4 +- millbun/src/mill/bun/BunToolchainModule.scala | 3 + millbun/src/mill/bun/BunWorkersModule.scala | 6 +- millbun/src/mill/bun/BunWorkspaceModule.scala | 4 +- .../bun/BunTypeScriptModule.scala | 60 ++++++++++++------- .../bun/BunTypeScriptWebModule.scala | 2 +- .../scalajslib/bun/BunPublishModule.scala | 2 +- .../scalajslib/bun/BunScalaJSModule.scala | 19 +++--- .../scalajslib/bun/BunScalaJSWebModule.scala | 2 +- 16 files changed, 134 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1418fd4..68544c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Development dependencies are local tooling inputs and are no longer published transitively. - `bunPackageJsonExtras` rejects dependency fields now represented by typed settings. - Missing dependency versions are represented as `latest` instead of an empty package.json value. +- TypeScript `bunBundleFormat` is `Option[String]`, matching Scala.js; `None` lets `bun build` infer. +- `unmanagedDeps` entries are staged into `vendor/` and declared as `file:./vendor/` + dependencies, so local packages install under frozen lockfiles and locks stay portable. +- The TypeScript install task is canonically `bunInstall`; Mill's inherited `npmInstall` delegates to it. +- `bunToolEnv` is defined once on `BunToolchainModule` for all toolchain subprocesses, and the + TypeScript `bunRuntimeEnv` is public. ### Deprecated @@ -36,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `bunOptionalDeps` in favor of `npmOptionalDeps`. - `managedBunExecutable` in favor of `bunExecutableOverride`. - The TypeScript `bunCompileExecutable: Boolean` switch in favor of the `compileExecutable` task. +- The TypeScript test command `test` and the Scala.js test command `bunTest`, both in favor of `testForked`. ## [0.2.1] - Overridable test-time JS env (2026-04-17) diff --git a/README.md b/README.md index aefe80d..eef6df6 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Scala.js versions are explicit: choose the version your application tests agains ./mill app.run ./mill app.bundle ./mill app.compileExecutable -./mill app.test.test +./mill app.test.testForked ``` `BunScalaJSModule` delegates `fastLinkJS`, `fullLinkJS`, and test linking to Mill's standard `ScalaJSModule` hooks. That keeps the plugin compatible with Mill's linker lifecycle and removes its former private linker-worker coupling. @@ -76,7 +76,7 @@ object app extends BunTypeScriptModule { ./mill app.run ./mill app.bundle ./mill app.compileExecutable -./mill app.test.test +./mill app.test.testForked ``` ## Web applications diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md index e38fa53..6dcd494 100644 --- a/docs/MIGRATING-0.3.md +++ b/docs/MIGRATING-0.3.md @@ -29,6 +29,25 @@ Subsequent installs are frozen. During a staged migration only, set `MILL_BUN_RE Use `npmDeps`, `npmDevDeps`, `npmOptionalDeps`, `npmPeerDeps`, and `npmOverrides`. `bunPackageJsonExtras` remains available for unmodeled fields such as `scripts`, but now rejects dependency sections so task invalidation and published metadata remain correct. +### `bunBundleFormat` is `Option[String]` on TypeScript modules + +Scala.js and TypeScript previously disagreed on this member's type — `Option[String]` versus +`String` — which no deprecation alias can bridge, so 0.3 takes the one-time break. Both are now +`T[Option[String]]`; `None` lets `bun build` infer the format. + +```scala +// 0.2 +override def bunBundleFormat = Task { "esm" } +// 0.3 +override def bunBundleFormat = Task { Some("esm") } +``` + +### Local packages via `unmanagedDeps` must be directories with a `package.json` + +Entries are staged into `vendor/` beside the generated package.json and declared as +`file:./vendor/` dependencies, so they now work under frozen lockfile installs and the +lockfile stays independent of the checkout path. Tarballs are no longer accepted — unpack them. + ## Renamed APIs | 0.2 name | 0.3 name | Status | @@ -39,9 +58,17 @@ Use `npmDeps`, `npmDevDeps`, `npmOptionalDeps`, `npmPeerDeps`, and `npmOverrides | `bunCompileExecutables` | `compileExecutables` | Compatibility alias retained | | `bunOptionalDeps` | `npmOptionalDeps` | Deprecated compatibility setting | | `managedBunExecutable` | `bunExecutableOverride` | Deprecated compatibility setting | +| `npmInstall` (TypeScript) | `bunInstall` | Mill's inherited name delegates to `bunInstall` and stays usable | +| `test` (TypeScript test modules) | `testForked` | Deprecated compatibility command | +| `bunTest` (Scala.js test modules) | `testForked` (inherited from Mill) | Deprecated compatibility command | The old names are planned for removal at 1.0. +Environment hooks share one vocabulary: `bunToolEnv` (toolchain subprocesses — install, lock, +build) is defined on `BunToolchainModule` for every module kind, and the TypeScript +`bunRuntimeEnv` (program and test processes) is now public. Scala.js keeps `bunJsEnv` / +`bunJsEnvArgs` / `bunTestJsEnv` for its Scala.js-test JS environment, unchanged. + ## Toolchain behavior The default is now checksum-verified managed Bun 1.4.0. To preserve the old PATH behavior: diff --git a/examples/build.mill b/examples/build.mill index a904533..12e20a9 100644 --- a/examples/build.mill +++ b/examples/build.mill @@ -79,7 +79,7 @@ object fullstack extends Module { // No moduleDir or sources overrides needed. // // ./mill tsLib.compile # TypeScript type-check -// ./mill tsLib.test.test # Bun-native tests +// ./mill tsLib.test.testForked # Bun-native tests // ./mill client.bundle # TS browser bundle (imports tsLib) // ./mill scalaClient.bundle # Scala.js browser bundle // ./mill backend.run # JVM Cask server on :8080 diff --git a/millbun/integration/resources/typescript-bundle/build.mill b/millbun/integration/resources/typescript-bundle/build.mill index 02c1ede..3b296da 100644 --- a/millbun/integration/resources/typescript-bundle/build.mill +++ b/millbun/integration/resources/typescript-bundle/build.mill @@ -18,5 +18,5 @@ object app extends BunTypeScriptModule { ) } override def bunBundleTarget = Task { "bun" } - override def bunBundleFormat = Task { "esm" } + override def bunBundleFormat = Task { Some("esm") } } diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index da59704..0836c27 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -123,10 +123,12 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(runBundledScript(mainJs) == "Hello from transitive scala.js bun") } - test("bunTest") { + test("testForked runs Scala.js tests on Bun") { val tester = this.tester("scalajs-test") - val res = tester.eval("app.test.bunTest") + val res = tester.eval("app.test.testForked") assert(res.isSuccess) + // The deprecated alias must keep resolving until removal. + assert(tester.eval("app.test.bunTest").isSuccess) } test("bunfig propagates to Scala.js workspaces without leaking .npmrc") { @@ -150,7 +152,7 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(os.exists(compileWorkspace / "bunfig.toml")) assert(!os.exists(compileWorkspace / ".npmrc")) - val testRes = tester.eval("app.test.bunTest") + val testRes = tester.eval("app.test.testForked") assert(testRes.isSuccess) val testRoot = tester.workspacePath / "out" / "app" / "test" assert(os.exists(testRoot)) diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index f4854a8..8f4dfef 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -86,25 +86,25 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("strict installs require a source lock and bunLock creates it") { val tester = this.tester("typescript-lock") - val missingLock = tester.eval("app.npmInstall") + val missingLock = tester.eval("app.bunInstall") assert(!missingLock.isSuccess) val lockResult = tester.eval("app.bunLock") assert(lockResult.isSuccess) assert(os.exists(tester.workspacePath / "bun.lock")) - val installResult = tester.eval("app.npmInstall") + val installResult = tester.eval("app.bunInstall") assert(installResult.isSuccess) - val args = os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / ".bun-args") + val args = os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".bun-args") assert(args.contains("--frozen-lockfile")) } test("bun target ambient types are pinned") { val tester = this.tester("typescript-simple") - val res = tester.eval("app.npmInstall") + val res = tester.eval("app.bunInstall") assert(res.isSuccess) - val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / "package.json")) val devDeps = packageJson("devDependencies").obj // Pinned, and pinned to the Bun we ship: @types/bun is published in lockstep with Bun. @@ -118,7 +118,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val res = tester.eval("app.compile") assert(res.isSuccess) - val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / "package.json")) val devDeps = packageJson("devDependencies").obj assert(devDeps("typescript").str == "5.7.3") @@ -128,8 +128,12 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("bun test module") { val tester = this.tester("typescript-tests") - val res = tester.eval("app.test.test") + val res = tester.eval("app.test.testForked") assert(res.isSuccess) + // Deprecated and inherited aliases must keep resolving until removal. + assert(tester.eval("app.test.test").isSuccess) + assert(tester.eval("app.npmInstall").isSuccess) + assert(outputPath(tester, "app.npmInstall") == tester.workspacePath / "out" / "app" / "bunInstall.dest") } test("bundle workers") { @@ -165,7 +169,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val res = tester.eval("app.compile") assert(res.isSuccess) - val installDir = tester.workspacePath / "out" / "app" / "npmInstall.dest" + val installDir = tester.workspacePath / "out" / "app" / "bunInstall.dest" val compileDir = tester.workspacePath / "out" / "app" / "compile.dest" // Install workspace keeps both configs. @@ -183,16 +187,16 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(tester.eval("app.test.bunLock").isSuccess) // Outer module should have is-even in dependencies - val outerRes = tester.eval("app.npmInstall") + val outerRes = tester.eval("app.bunInstall") assert(outerRes.isSuccess) - val outerPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) + val outerPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / "package.json")) assert(outerPkg("dependencies").obj.contains("is-even")) assert(!outerPkg("dependencies").obj.contains("is-odd")) // Test module should have is-odd in devDependencies (not dependencies) - val testRes = tester.eval("app.test.npmInstall") + val testRes = tester.eval("app.test.bunInstall") assert(testRes.isSuccess) - val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "npmInstall.dest" / "package.json")) + val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "bunInstall.dest" / "package.json")) assert(testPkg("devDependencies").obj.contains("is-odd")) assert(!testPkg("dependencies").obj.contains("is-odd")) assert(!testPkg("devDependencies").obj.contains("is-even")) @@ -200,7 +204,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(testPkg("dependencies").obj.contains("is-even")) // Tests should actually run (both deps available) - val runRes = tester.eval("app.test.test") + val runRes = tester.eval("app.test.testForked") assert(runRes.isSuccess) } @@ -211,7 +215,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val tester = this.tester("typescript-test-deps") // Without any lock, the install refuses and names the test module's own path. - val unlocked = tester.eval("app.test.npmInstall") + val unlocked = tester.eval("app.test.bunInstall") assert(!unlocked.isSuccess) assert(tester.eval("app.bunLock").isSuccess) @@ -234,8 +238,8 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(declaredDeps(testLock).contains("is-odd")) // The frozen install now succeeds against the test module's own lock. - assert(tester.eval("app.test.npmInstall").isSuccess) - assert(tester.eval("app.test.test").isSuccess) + assert(tester.eval("app.test.bunInstall").isSuccess) + assert(tester.eval("app.test.testForked").isSuccess) } test("unmanaged local packages install under a frozen lockfile") { @@ -248,8 +252,8 @@ object BunTypeScriptIntegrationTests extends TestSuite { // The lock must not record where this repository happens to be checked out. assert(!lock.contains(tester.workspacePath.toString)) - assert(tester.eval("app.npmInstall").isSuccess) - val installed = outputPath(tester, "app.npmInstall") + assert(tester.eval("app.bunInstall").isSuccess) + val installed = outputPath(tester, "app.bunInstall") assert(os.exists(installed / "node_modules" / "local-lib" / "package.json")) assert(tester.eval("app.bundle").isSuccess) @@ -261,12 +265,12 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("test modules adding nothing reuse the outer install") { // A bare test module must not demand a second lockfile. val tester = this.tester("typescript-tests") - assert(tester.eval("app.test.npmInstall").isSuccess) + assert(tester.eval("app.test.bunInstall").isSuccess) assert(!os.exists(tester.workspacePath / "test" / "bun.lock")) // And must actually reuse the outer install — not run a second one that merely succeeds // because the lockfile requirement happens to be off in this suite. - val installPath = outputPath(tester, "app.test.npmInstall") - assert(installPath == tester.workspacePath / "out" / "app" / "npmInstall.dest") + val installPath = outputPath(tester, "app.test.bunInstall") + assert(installPath == tester.workspacePath / "out" / "app" / "bunInstall.dest") } test("bunEnv") { @@ -274,7 +278,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val res = tester.eval("app.bundle") assert(res.isSuccess) - val installLog = os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / ".bun-env-log") + val installLog = os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".bun-env-log") val compileLog = os.read(tester.workspacePath / "out" / "app" / "compile.dest" / ".bun-env-log") val bundleLog = os.read(tester.workspacePath / "out" / "app" / "bundle.dest" / ".bun-env-log") diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 51aee0f..6475eef 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -48,8 +48,8 @@ object BunWorkspaceIntegrationTests extends TestSuite: assert(!os.read(workspaceInstall / ".workspace-installed").contains("shared-local")) val scalaResult = tester.eval("scalaApp.bunInstall") - val typescriptResult = tester.eval("typescriptApp.npmInstall") + val typescriptResult = tester.eval("typescriptApp.bunInstall") assert(scalaResult.isSuccess) assert(typescriptResult.isSuccess) assert(os.isLink(outputPath(tester, "scalaApp.bunInstall") / "node_modules")) - assert(os.isLink(outputPath(tester, "typescriptApp.npmInstall") / "node_modules")) + assert(os.isLink(outputPath(tester, "typescriptApp.bunInstall") / "node_modules")) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 5cb1f34..5db38be 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -535,6 +535,9 @@ trait BunToolchainModule extends Module { /** Environment passed to Bun subprocesses. */ def bunEnv: T[Map[String, String]] = Task { Map.empty } + /** Environment for Bun toolchain subprocesses: install, lock, build, and bundle. */ + def bunToolEnv: T[Map[String, String]] = Task { bunEnv() } + /** Explicit resolutions for conflicting transitive npm dependency declarations. */ def npmOverrides: T[Map[String, String]] = Task { Map.empty } diff --git a/millbun/src/mill/bun/BunWorkersModule.scala b/millbun/src/mill/bun/BunWorkersModule.scala index cb50d8a..db095c9 100644 --- a/millbun/src/mill/bun/BunWorkersModule.scala +++ b/millbun/src/mill/bun/BunWorkersModule.scala @@ -33,7 +33,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => def workerBundleTarget: T[String] = Task { bunBundleTarget() } /** Output format for worker bundles. Defaults to the module format. */ - def workerBundleFormat: T[Option[String]] = Task { Some(bunBundleFormat()) } + def workerBundleFormat: T[Option[String]] = Task { bunBundleFormat() } /** Extra raw flags for worker bundling. */ def workerBundleArgs: T[Seq[String]] = Task { Seq.empty } @@ -61,7 +61,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => val outDir = Task.dest / "workers" // Declared explicitly: the staged tree carries a node_modules symlink into this install, // and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compile().path, workspace) os.makeDir.all(outDir) @@ -94,7 +94,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => target ) ++ formatArgs ++ extraArgs, cwd = workspace, - env = bunEnv() + env = bunToolEnv() ) } diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index fd5b4d5..dfc3892 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -127,7 +127,7 @@ trait BunWorkspaceModule extends BunToolchainModule: updateLockfile = false ), cwd = Task.dest, - env = bunEnv() + env = bunToolEnv() ) PathRef(Task.dest) } @@ -147,7 +147,7 @@ trait BunWorkspaceModule extends BunToolchainModule: updateLockfile = true ), cwd = Task.dest, - env = bunEnv() + env = bunToolEnv() ) val generated = Task.dest / "bun.lock" diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 6fcedde..a0490db 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -24,8 +24,13 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Target used by `bun build`: browser | bun | node. */ def bunBundleTarget: T[String] = Task { "bun" } - /** Output format used by `bun build`. */ - def bunBundleFormat: T[String] = Task { if (enableEsm()) "esm" else "cjs" } + /** Output format passed to `bun build --format`; `None` lets bun infer. + * + * `Option[String]` to match the Scala.js module's member of the same name — a `T[String]` + * here made the two module kinds' shared vocabulary diverge on type, which no alias can + * bridge. 0.3.0 takes the one-time break. + */ + def bunBundleFormat: T[Option[String]] = Task { Some(if (enableEsm()) "esm" else "cjs") } /** Emit a standalone executable instead of a JS bundle. */ @deprecated("Use the compileExecutable task", "0.3.0") @@ -49,11 +54,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Bun-only package.json fields not modeled by Mill's typed PackageJson. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } - /** Environment for Bun toolchain subprocesses such as install/build/test. */ - protected def bunToolEnv: T[Map[String, String]] = Task { bunEnv() } - /** Runtime environment for Bun-executed programs and tests. */ - protected def bunRuntimeEnv: T[Map[String, String]] = Task { bunEnv() ++ forkEnv() } + def bunRuntimeEnv: T[Map[String, String]] = Task { bunEnv() ++ forkEnv() } /** TypeScript version used for `bun x tsc`. */ def typeScriptVersion: T[String] = Task { "5.7.3" } @@ -146,8 +148,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } } - /** Replace npm install with bun install. */ - override def npmInstall: T[PathRef] = Task { + /** Install dependencies with Bun. The canonical name; Mill's [[npmInstall]] delegates here. */ + def bunInstall: T[PathRef] = Task { val dest = Task.dest os.makeDir.all(dest) mkBunPackageJson() @@ -183,6 +185,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with PathRef(dest) } + /** Mill's inherited install name; delegates to [[bunInstall]]. */ + override def npmInstall: T[PathRef] = Task { bunInstall() } + /** Resolve dependencies and update the source-controlled `bun.lock`. */ def bunLock(): Command[PathRef] = Task.Command { if bunWorkspaceInstall().nonEmpty then @@ -222,7 +227,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with tscCopyGenSources() tscLinkResources() BunTypeScriptModule.removeInstallOnlyConfigs(Task.dest) - ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) + ensureInstallArtifacts(Task.dest, bunInstall().path, bunLockfiles()) BunTypeScriptModule.copyBunfigsTo(Task.dest, resolvedBunfigs()) mkTsconfig() @@ -237,7 +242,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } override def createNodeModulesSymlink: Task[Unit] = Task.Anon { - ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) + ensureInstallArtifacts(Task.dest, bunInstall().path, bunLockfiles()) } /** Run the entrypoint directly with Bun. */ @@ -291,7 +296,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -309,10 +314,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with "--outfile", outFile.toString, "--target", - bunBundleTarget(), - "--format", - bunBundleFormat() - ) ++ packagesExternal ++ externalArgs ++ compileArgs ++ bunBuildArgs(), + bunBundleTarget() + ) ++ bunBundleFormat().toSeq.flatMap(format => Seq("--format", format)) + ++ packagesExternal ++ externalArgs ++ compileArgs ++ bunBuildArgs(), cwd = buildDir, env = bunToolEnv() ) @@ -329,7 +333,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -369,7 +373,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -499,8 +503,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunTestPackageJson().render() == outer.bunWorkspacePackageJson().render() } - override def npmInstall: T[PathRef] = Task { - if (reusesOuterInstall()) outer.npmInstall() + /** Install this test module's dependencies with Bun; reuses the outer install when equal. */ + def bunInstall: T[PathRef] = Task { + if (reusesOuterInstall()) outer.bunInstall() else { val dest = Task.dest os.makeDir.all(dest) @@ -540,6 +545,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } } + /** Mill's inherited install name; delegates to [[bunInstall]]. */ + override def npmInstall: T[PathRef] = Task { bunInstall() } + /** * Resolve this test module's dependencies and update its own `bun.lock`. * @@ -584,7 +592,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val dest = Task.dest BunToolchainModule.copyWorkspace(this.compile().path, dest) BunTypeScriptModule.removeInstallOnlyConfigs(dest) - outer.ensureInstallArtifacts(dest, npmInstall().path, bunLockfiles()) + outer.ensureInstallArtifacts(dest, bunInstall().path, bunLockfiles()) BunTypeScriptModule.copyBunfigsTo(dest, outer.resolvedBunfigs()) PathRef(dest) } @@ -601,6 +609,18 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunTestArgs() ++ timeoutArgs ++ reporterArgs } + /** Run `bun test`. Named after Mill's standard test entrypoint, so both module kinds share it. */ + def testForked(args: mill.api.Args): Command[CommandResult] = Task.Command { + os.call( + Seq(bunExecutable(), "test") ++ resolvedTestFlags() ++ args.value, + cwd = preparedTestWorkspace().path, + env = outer.bunRuntimeEnv(), + stdout = os.Inherit, + stderr = os.Inherit + ) + } + + @deprecated("Use testForked", "0.3.0") def test(args: mill.api.Args): Command[CommandResult] = Task.Command { os.call( Seq(bunExecutable(), "test") ++ resolvedTestFlags() ++ args.value, diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala index 22c9ac6..98de0c3 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -65,7 +65,7 @@ trait BunTypeScriptWebModule extends BunTypeScriptModule: sources() ++ generatedSources() ++ resources(), webEntryPoints(), webPublicSources(), - npmInstall().path, + bunInstall().path, bunfigFiles(), webScriptEntryPoint().path ) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 74025de..0fb3d6f 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -98,7 +98,7 @@ trait BunPublishModule extends BunScalaJSModule { updateLockfile = false ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) PathRef(dest) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 18316ba..2f29847 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -301,7 +301,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac updateLockfile = false ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") @@ -332,7 +332,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac updateLockfile = true ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) val generated = dest / "bun.lock" @@ -449,7 +449,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bytecodeArgs ++ bunBundleArgs(), cwd = linked.dest.path, - env = bunEnv() + env = bunToolEnv() ) PathRef(outDir) @@ -481,7 +481,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac splittingArgs ++ bunBundleArgs(), cwd = linked.dest.path, - env = bunEnv() + env = bunToolEnv() ) PathRef(outDir) @@ -518,7 +518,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bytecodeArgs ++ bunBundleArgs(), cwd = buildDir, - env = bunEnv() + env = bunToolEnv() ) PathRef(outFile) @@ -560,7 +560,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bytecodeArgs ++ bunBundleArgs(), cwd = buildDir, - env = bunEnv() + env = bunToolEnv() ) target -> PathRef(outFile) @@ -624,7 +624,12 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac linked } - /** Run Scala.js tests through Mill's test bridge with Bun as the JS runtime. */ + /** Run Scala.js tests through Mill's test bridge with Bun as the JS runtime. + * + * The inherited `testForked` already does exactly this — the overridden [[jsEnvConfig]] and + * [[testLinkTask]] put every test run on Bun — so this alias adds nothing over it. + */ + @deprecated("Use the inherited testForked", "0.3.0") def bunTest(args: mill.api.Args): Command[(msg: String, results: Seq[mill.javalib.testrunner.TestResult])] = Task.Command { testTask( diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala index 68a2f34..e7dfb84 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala @@ -94,7 +94,7 @@ trait BunScalaJSWebModule extends BunScalaJSModule: bunExecutable(), Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBundleArgs(), cwd = stage, - env = bunEnv() + env = bunToolEnv() ) PathRef(destination) } From b992fdd3ee75b93069b8a0634e4b7eaf51aceb44 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:41:52 -0400 Subject: [PATCH 11/18] Run the integration suite on shipped defaults with committed lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.mill forced MILL_BUN_REQUIRE_LOCKFILE=false across the entire integration suite, so the strict-lockfile default — the plugin's headline 0.3.0 behavior — was exercised by almost nothing. Every install-path bug fixed earlier in this stack lived in exactly that gap. All 13 real-bun dependency-bearing fixtures now commit a bun.lock generated by the pinned Bun 1.4.0 and install frozen in CI. The RegenerateFixtureLocks entry point re-locks every fixture in one command (MILL_BUN_REGENERATE_LOCKS=1 gates it; without the env it lists what it would do and touches nothing). The forkEnv default is removed; scalajs-dependency-manifests opts out in its own build.mill because its stub cannot produce a real lock. scalajs-transitive is restructured: lib and app both claimed the workspace root as moduleDir, so both resolved the same bun.lock and only one dependency set could be recorded there. lib now lives in lib/ with its own lockfile identity. Committed locks make the Bun pin load-bearing — bun.lock is forward- but not backward-compatible, and the raw UnknownLockfileVersion error from an older Bun never mentions how to recover. A frozen install against a lock the pinned Bun cannot read now fails before bun runs, telling the user to regenerate with bunLock or raise bunVersion. The supported-lockfile-version table lives beside the checksum table, and a unit test forces every bundled Bun to declare its entry. The new typescript-stale-lock fixture proves the guard fires before any bun subprocess is reached, and fails with the guard neutralized. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + build.mill | 6 +- .../resources/scalajs-bundle/bun.lock | 15 +++++ .../scalajs-dependency-manifests/build.mill | 2 + .../resources/scalajs-transitive/build.mill | 4 +- .../resources/scalajs-transitive/bun.lock | 15 +++++ .../lib/{ => src}/LibWords.scala | 0 .../resources/scalajs-web/bun.lock | 15 +++++ .../resources/typescript-browser/bun.lock | 15 +++++ .../resources/typescript-bundle/bun.lock | 24 +++++++ .../resources/typescript-bunfig/app/bun.lock | 24 +++++++ .../resources/typescript-compile/bun.lock | 24 +++++++ .../resources/typescript-env/bun.lock | 24 +++++++ .../resources/typescript-simple/bun.lock | 24 +++++++ .../typescript-stale-lock/build.mill | 30 +++++++++ .../resources/typescript-stale-lock/bun.lock | 11 ++++ .../typescript-stale-lock/src/main.ts | 1 + .../resources/typescript-tests/bun.lock | 24 +++++++ .../resources/typescript-tsx/bun.lock | 24 +++++++ .../resources/typescript-web/bun.lock | 15 +++++ .../resources/typescript-workers/bun.lock | 32 ++++++++++ .../bun/BunTypeScriptIntegrationTests.scala | 12 ++++ .../src/mill/bun/RegenerateFixtureLocks.scala | 64 +++++++++++++++++++ millbun/src/mill/bun/BunToolchainModule.scala | 34 ++++++++++ millbun/src/mill/bun/BunWorkspaceModule.scala | 2 +- .../bun/BunTypeScriptModule.scala | 3 +- .../scalajslib/bun/BunPublishModule.scala | 2 +- .../scalajslib/bun/BunScalaJSModule.scala | 2 +- .../test/src/mill/bun/BunToolchainTests.scala | 23 +++++++ 29 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 millbun/integration/resources/scalajs-bundle/bun.lock create mode 100644 millbun/integration/resources/scalajs-transitive/bun.lock rename millbun/integration/resources/scalajs-transitive/lib/{ => src}/LibWords.scala (100%) create mode 100644 millbun/integration/resources/scalajs-web/bun.lock create mode 100644 millbun/integration/resources/typescript-browser/bun.lock create mode 100644 millbun/integration/resources/typescript-bundle/bun.lock create mode 100644 millbun/integration/resources/typescript-bunfig/app/bun.lock create mode 100644 millbun/integration/resources/typescript-compile/bun.lock create mode 100644 millbun/integration/resources/typescript-env/bun.lock create mode 100644 millbun/integration/resources/typescript-simple/bun.lock create mode 100644 millbun/integration/resources/typescript-stale-lock/build.mill create mode 100644 millbun/integration/resources/typescript-stale-lock/bun.lock create mode 100644 millbun/integration/resources/typescript-stale-lock/src/main.ts create mode 100644 millbun/integration/resources/typescript-tests/bun.lock create mode 100644 millbun/integration/resources/typescript-tsx/bun.lock create mode 100644 millbun/integration/resources/typescript-web/bun.lock create mode 100644 millbun/integration/resources/typescript-workers/bun.lock create mode 100644 millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 68544c8..281e213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `BunWorkspaceModule` for one install and lockfile across mixed Scala.js/TypeScript packages. - Published dependency manifest schema v2 with runtime, optional, and peer requirements. - `bunDoctor` diagnostics and managed-toolchain CI smoke coverage. +- A committed lockfile written by a newer Bun fails frozen installs with regeneration guidance, + instead of surfacing bun's raw `UnknownLockfileVersion`. ### Changed diff --git a/build.mill b/build.mill index 0219c36..ff46997 100644 --- a/build.mill +++ b/build.mill @@ -41,8 +41,10 @@ object millbun extends ScalaModule, PublishModule { // pins rather than whatever the developer happens to have on PATH. The shared // download cache means all fixtures reuse one archive. Export MILL_BUN_USE_SYSTEM=true // to run against a local Bun instead. - "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "false"), - "MILL_BUN_REQUIRE_LOCKFILE" -> Task.env.getOrElse("MILL_BUN_REQUIRE_LOCKFILE", "false") + "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "false") + // The lockfile requirement runs the SHIPPED default (strict): dependency-bearing + // fixtures commit a bun.lock, regenerated via mill.bun.RegenerateFixtureLocks, and + // fixtures that deliberately test the relaxed path opt out in their own build.mill. ) } diff --git a/millbun/integration/resources/scalajs-bundle/bun.lock b/millbun/integration/resources/scalajs-bundle/bun.lock new file mode 100644 index 0000000..5b62cfe --- /dev/null +++ b/millbun/integration/resources/scalajs-bundle/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + } +} diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill index d9b0660..1646f15 100644 --- a/millbun/integration/resources/scalajs-dependency-manifests/build.mill +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -17,6 +17,8 @@ trait StubBunModule extends BunScalaJSModule { def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.CommonJSModule } override def bunExecutable = Task { "stub-bun" } + // The stub never produces a real lockfile; the strict path is covered by real-bun fixtures. + override def bunRequireLockfile = Task { false } override protected def runBun( bunExe: String, diff --git a/millbun/integration/resources/scalajs-transitive/build.mill b/millbun/integration/resources/scalajs-transitive/build.mill index 059a350..1cbbf76 100644 --- a/millbun/integration/resources/scalajs-transitive/build.mill +++ b/millbun/integration/resources/scalajs-transitive/build.mill @@ -10,13 +10,13 @@ import mill.scalajslib.* import mill.scalajslib.api.* import mill.scalajslib.bun.* +// lib keeps its own moduleDir (lib/): sharing the root with app would make both modules +// resolve the same bun.lock, and only one dependency set can be recorded there. object lib extends BunScalaJSModule { - override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } - override def sources = Task.Sources(moduleDir / "lib") override def npmDeps = Task { Seq("lodash@4.17.21") } } diff --git a/millbun/integration/resources/scalajs-transitive/bun.lock b/millbun/integration/resources/scalajs-transitive/bun.lock new file mode 100644 index 0000000..5b62cfe --- /dev/null +++ b/millbun/integration/resources/scalajs-transitive/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + } +} diff --git a/millbun/integration/resources/scalajs-transitive/lib/LibWords.scala b/millbun/integration/resources/scalajs-transitive/lib/src/LibWords.scala similarity index 100% rename from millbun/integration/resources/scalajs-transitive/lib/LibWords.scala rename to millbun/integration/resources/scalajs-transitive/lib/src/LibWords.scala diff --git a/millbun/integration/resources/scalajs-web/bun.lock b/millbun/integration/resources/scalajs-web/bun.lock new file mode 100644 index 0000000..5b62cfe --- /dev/null +++ b/millbun/integration/resources/scalajs-web/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + } +} diff --git a/millbun/integration/resources/typescript-browser/bun.lock b/millbun/integration/resources/typescript-browser/bun.lock new file mode 100644 index 0000000..23524fb --- /dev/null +++ b/millbun/integration/resources/typescript-browser/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + } +} diff --git a/millbun/integration/resources/typescript-bundle/bun.lock b/millbun/integration/resources/typescript-bundle/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-bundle/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-bunfig/app/bun.lock b/millbun/integration/resources/typescript-bunfig/app/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/app/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-compile/bun.lock b/millbun/integration/resources/typescript-compile/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-compile/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-env/bun.lock b/millbun/integration/resources/typescript-env/bun.lock new file mode 100644 index 0000000..a7b8336 --- /dev/null +++ b/millbun/integration/resources/typescript-env/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-simple/bun.lock b/millbun/integration/resources/typescript-simple/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-simple/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-stale-lock/build.mill b/millbun/integration/resources/typescript-stale-lock/build.mill new file mode 100644 index 0000000..b60587c --- /dev/null +++ b/millbun/integration/resources/typescript-stale-lock/build.mill @@ -0,0 +1,30 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +// Pins a Bun that cannot read the committed lockfileVersion (1.3.14 reads only v1; the lock +// says v2). The install must fail with regeneration guidance before bun ever runs — the stub +// proves no subprocess is reached. +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def bunVersion = Task { "1.3.14" } + override def bunVerifyVersion = Task { false } + override def bunExecutable = Task { "stub-bun" } + override def npmDeps = Task { Seq("is-even@1.0.0") } + + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.write.over(cwd / ".stub-bun-ran", args.mkString(" ")) + os.call(Seq("true"), cwd = cwd, env = env) + } +} diff --git a/millbun/integration/resources/typescript-stale-lock/bun.lock b/millbun/integration/resources/typescript-stale-lock/bun.lock new file mode 100644 index 0000000..4eed9da --- /dev/null +++ b/millbun/integration/resources/typescript-stale-lock/bun.lock @@ -0,0 +1,11 @@ +{ + "lockfileVersion": 2, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "is-even": "1.0.0", + }, + }, + }, +} diff --git a/millbun/integration/resources/typescript-stale-lock/src/main.ts b/millbun/integration/resources/typescript-stale-lock/src/main.ts new file mode 100644 index 0000000..8b179f2 --- /dev/null +++ b/millbun/integration/resources/typescript-stale-lock/src/main.ts @@ -0,0 +1 @@ +console.log("never installs"); diff --git a/millbun/integration/resources/typescript-tests/bun.lock b/millbun/integration/resources/typescript-tests/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-tests/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-tsx/bun.lock b/millbun/integration/resources/typescript-tsx/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-tsx/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-web/bun.lock b/millbun/integration/resources/typescript-web/bun.lock new file mode 100644 index 0000000..23524fb --- /dev/null +++ b/millbun/integration/resources/typescript-web/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + } +} diff --git a/millbun/integration/resources/typescript-workers/bun.lock b/millbun/integration/resources/typescript-workers/bun.lock new file mode 100644 index 0000000..1f06ab3 --- /dev/null +++ b/millbun/integration/resources/typescript-workers/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + "devDependencies": { + "@types/bun": "1.4.0", + "@types/lodash": "4.17.20", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 8f4dfef..d4d2bcc 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -99,6 +99,18 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(args.contains("--frozen-lockfile")) } + test("a lockfile from a newer Bun fails with regeneration guidance") { + // bun.lock is forward- but not backward-compatible; bun's own failure is a raw + // UnknownLockfileVersion that never mentions bunLock. The fixture's stub proves the + // guard fires before any bun subprocess is reached. + val tester = this.tester("typescript-stale-lock") + val res = tester.eval("app.bunInstall") + assert(!res.isSuccess) + // The message text is asserted at unit level (lockfileSkewError); in-memory evals expose + // no readable error stream, so here the contract is: fail, and never reach bun. + assert(!os.exists(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".stub-bun-ran")) + } + test("bun target ambient types are pinned") { val tester = this.tester("typescript-simple") val res = tester.eval("app.bunInstall") diff --git a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala new file mode 100644 index 0000000..4f698c5 --- /dev/null +++ b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala @@ -0,0 +1,64 @@ +package mill.bun + +import mill.testkit.IntegrationTester +import utest.* + +/** + * Maintenance entry point, not a test: regenerates every committed fixture lockfile with the + * pinned Bun, so a dependency-default change or a `bunVersion` bump is one command instead of a + * hunt across fixtures. Without the env guard it reports what would run and does nothing. + * + * {{{ + * MILL_BUN_REGENERATE_LOCKS=1 ./mill millbun.integration.testOnly mill.bun.RegenerateFixtureLocks + * }}} + */ +object RegenerateFixtureLocks extends TestSuite: + val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + /** Fixture -> the bunLock commands that produce its committed lockfiles. */ + val lockedFixtures: Seq[(String, Seq[String])] = Seq( + "scalajs-bundle" -> Seq("app.bunLock"), + "scalajs-transitive" -> Seq("app.bunLock"), + "scalajs-web" -> Seq("app.bunLock"), + "typescript-browser" -> Seq("app.bunLock"), + "typescript-bundle" -> Seq("app.bunLock"), + "typescript-bunfig" -> Seq("app.bunLock"), + "typescript-compile" -> Seq("app.bunLock"), + "typescript-env" -> Seq("app.bunLock"), + "typescript-simple" -> Seq("app.bunLock"), + "typescript-tests" -> Seq("app.bunLock"), + "typescript-tsx" -> Seq("app.bunLock"), + "typescript-web" -> Seq("app.bunLock"), + "typescript-workers" -> Seq("app.bunLock") + ) + + def tests: Tests = Tests: + test("regenerate"): + if !sys.env.get("MILL_BUN_REGENERATE_LOCKS").exists(_.nonEmpty) then + println( + s"MILL_BUN_REGENERATE_LOCKS is not set; would regenerate locks for " + + s"${lockedFixtures.size} fixtures. Set it to 1 to actually rewrite them." + ) + else + lockedFixtures.foreach { case (fixture, commands) => + val tester = new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / fixture, + millExecutable = millExe, + useInMemory = true + ) + commands.foreach { command => + val result = tester.eval(command) + Predef.assert(result.isSuccess, s"$fixture: $command failed") + } + val generated = os + .walk(tester.workspacePath, skip = p => p.last == "out" || p.last == "node_modules") + .filter(_.last == "bun.lock") + Predef.assert(generated.nonEmpty, s"$fixture: no bun.lock produced") + generated.foreach { lock => + val target = resourceDir / fixture / lock.relativeTo(tester.workspacePath) + os.copy.over(lock, target, createFolders = true) + println(s"regenerated ${target.relativeTo(resourceDir)}") + } + } diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 5db38be..83491bd 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -75,6 +75,33 @@ object BunToolchainModule { /** Versions with a complete bundled checksum table, for diagnostics. */ private[bun] def bundledVersions: Seq[String] = BundledChecksums.keys.toSeq.sorted + /** Highest `lockfileVersion` each bundled Bun can read. 1.4.0 writes v2; 1.3.14 fails on v2. */ + private val SupportedLockfileVersions: Map[String, Int] = Map( + "1.3.14" -> 1, + "1.4.0" -> 2 + ) + + private[bun] def supportedLockfileVersion(bunVersion: String): Option[Int] = + SupportedLockfileVersions.get(bunVersion) + + /** bun.lock is JSONC (trailing commas), so extract the version lexically rather than parsing. */ + private[bun] def lockfileVersion(lockText: String): Option[Int] = + """"lockfileVersion"\s*:\s*(\d+)""".r.findFirstMatchIn(lockText).map(_.group(1).toInt) + + /** Error text when the committed lock was written by a newer Bun; None when readable. */ + private[bun] def lockfileSkewError( + lockText: String, + lockPath: os.Path, + pinnedBunVersion: String + ): Option[String] = + for { + version <- lockfileVersion(lockText) + supported <- supportedLockfileVersion(pinnedBunVersion) + if version > supported + } yield s"$lockPath has lockfileVersion $version, which Bun $pinnedBunVersion cannot read " + + s"(it supports up to $supported). Regenerate the lockfile with this module's bunLock " + + "command, or raise bunVersion." + /** * Compose a Bun release asset name from the platform axes. * @@ -600,6 +627,7 @@ trait BunToolchainModule extends Module { hasInstallInputs: Boolean, lockfile: Option[PathRef], required: Boolean, + pinnedBunVersion: String, lockfilePath: os.Path = moduleDir / "bun.lock" ): Unit = { if (hasInstallInputs && required && lockfile.isEmpty) { @@ -607,6 +635,12 @@ trait BunToolchainModule extends Module { s"Missing $lockfilePath. Run this module's bunLock command and commit the generated lockfile." ) } + // bun.lock is forward- but not backward-compatible: a lock written by a newer Bun makes an + // older one fail with a raw UnknownLockfileVersion that never mentions how to recover. + lockfile.foreach { lock => + BunToolchainModule.lockfileSkewError(os.read(lock.path), lock.path, pinnedBunVersion) + .foreach(message => throw new RuntimeException(message)) + } } /** diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index dfc3892..f9eb152 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -115,7 +115,7 @@ trait BunWorkspaceModule extends BunToolchainModule: copyConfigs(Task.dest, npmRc().path, bunfigFiles()) val lockfile = bunLockfile() - requireBunLockfile(hasDependencyInputs(packages), lockfile, bunRequireLockfile()) + requireBunLockfile(hasDependencyInputs(packages), lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, Task.dest) runBun( diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index a0490db..caed8b2 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -166,7 +166,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } case None => val lockfile = bunLockfile() - requireBunLockfile(true, lockfile, bunRequireLockfile()) + requireBunLockfile(true, lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, dest) BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) @@ -521,6 +521,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with hasInstallInputs = true, lockfile = lockfile, required = outer.bunRequireLockfile(), + pinnedBunVersion = outer.bunVersion(), lockfilePath = moduleDir / "bun.lock" ) outer.copyBunLockfile(lockfile, dest) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 0fb3d6f..8956b06 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -85,7 +85,7 @@ trait BunPublishModule extends BunScalaJSModule { val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty val lockfile = bunLockfile() - requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile()) + requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, dest) if hasRuntimeInputs then BunToolchainModule.stageUnmanagedDeps(unmanagedDeps(), dest) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 2f29847..eb0b739 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -287,7 +287,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac } case None => val lockfile = bunLockfile() - requireBunLockfile(hasInstallInputs, lockfile, bunRequireLockfile()) + requireBunLockfile(hasInstallInputs, lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, dest) if hasInstallInputs then diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index 94d9c12..e74f10b 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -151,6 +151,29 @@ object BunToolchainTests extends TestSuite: assert(os.read(cached) == "bun-binary") assert(os.list(cached / os.up) == Seq(cached)) + test("lockfile version is extracted lexically from JSONC"): + // bun.lock has trailing commas, so a JSON parser cannot be the extraction mechanism. + assert(BunToolchainModule.lockfileVersion("{\n \"lockfileVersion\": 2,\n}") == Some(2)) + assert(BunToolchainModule.lockfileVersion("not a lockfile") == None) + + test("a newer lock against an older Bun explains how to recover"): + val lock = "{\n \"lockfileVersion\": 2,\n}" + val error = BunToolchainModule.lockfileSkewError(lock, os.root / "bun.lock", "1.3.14") + assert(error.exists(_.contains("Regenerate the lockfile"))) + assert(error.exists(_.contains("lockfileVersion 2"))) + // Readable combinations stay silent: same version, older lock, or an unbundled Bun. + assert(BunToolchainModule.lockfileSkewError(lock, os.root / "bun.lock", "1.4.0").isEmpty) + val v1 = "{\n \"lockfileVersion\": 1,\n}" + assert(BunToolchainModule.lockfileSkewError(v1, os.root / "bun.lock", "1.3.14").isEmpty) + assert(BunToolchainModule.lockfileSkewError(lock, os.root / "bun.lock", "9.9.9").isEmpty) + + test("every bundled Bun declares its supported lockfile version"): + // Committed fixture locks make the pin load-bearing: a version added to the checksum + // table without a lockfile-version entry silently disables the skew guard for it. + BunToolchainModule.bundledVersions.foreach { version => + assert(BunToolchainModule.supportedLockfileVersion(version).nonEmpty) + } + test("unmanaged deps become file: specifiers under vendor"): val dep = os.temp.dir() / "local-lib" os.write(dep / "package.json", """{"name":"local-lib","version":"1.0.0"}""", createFolders = true) From fcfa17b91e080f85a933362c9eb4d4632c6b89c9 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:45:41 -0400 Subject: [PATCH 12/18] Add Windows to the CI matrix and cache the managed Bun download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin carries Windows-specific code — bun.exe extraction, executableCandidates, the mill.bat fork path in the integration harness — with zero coverage. Both jobs now run on windows-latest with fail-fast off, using bash as the step shell so one ./mill invocation works on all three platforms. The official Mill 1.1.5 mill.bat launcher is committed; build.mill already selects it on Windows, but it never existed, so the integration suite could not have forked a fixture build there at all. Both jobs also cache ~/.cache/mill-bun keyed on the toolchain source, so a managed-Bun run downloads each pinned archive once per platform rather than once per CI run. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 23 ++- mill.bat | 296 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 mill.bat diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1694ba3..7411761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,14 @@ on: jobs: build: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + defaults: + run: + # One wrapper invocation everywhere: git-bash on the Windows runner runs ./mill too. + shell: bash steps: - uses: actions/checkout@v4 @@ -18,6 +23,11 @@ jobs: distribution: temurin java-version: '21' + - uses: actions/cache@v4 + with: + path: ~/.cache/mill-bun + key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.0 @@ -38,9 +48,13 @@ jobs: managed-bun: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 @@ -49,6 +63,11 @@ jobs: distribution: temurin java-version: '21' + - uses: actions/cache@v4 + with: + path: ~/.cache/mill-bun + key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - name: Managed Bun smoke test env: MILL_BUN_USE_SYSTEM: 'false' diff --git a/mill.bat b/mill.bat new file mode 100644 index 0000000..948392f --- /dev/null +++ b/mill.bat @@ -0,0 +1,296 @@ +@echo off + +setlocal enabledelayedexpansion + +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.5" ) + +if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) + +if [!MILL_MAIN_CLI!]==[] ( set "MILL_MAIN_CLI=%~f0" ) + +set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill" + +SET MILL_BUILD_SCRIPT= + +if exist "build.mill" ( + set MILL_BUILD_SCRIPT=build.mill +) else ( + if exist "build.mill.scala" ( + set MILL_BUILD_SCRIPT=build.mill.scala + ) else ( + if exist "build.sc" ( + set MILL_BUILD_SCRIPT=build.sc + ) else ( + rem no-op + ) + ) +) + +if [!MILL_VERSION!]==[] ( + if exist .mill-version ( + set /p MILL_VERSION=<.mill-version + ) else ( + if exist .config\mill-version ( + set /p MILL_VERSION=<.config\mill-version + ) else ( + rem Determine which config file to use for version extraction + set "MILL_VERSION_CONFIG_FILE=" + set "MILL_VERSION_SEARCH_PATTERN=" + + if exist build.mill.yaml ( + set "MILL_VERSION_CONFIG_FILE=build.mill.yaml" + set "MILL_VERSION_SEARCH_PATTERN=mill-version:" + ) else ( + if not "%MILL_BUILD_SCRIPT%"=="" ( + set "MILL_VERSION_CONFIG_FILE=%MILL_BUILD_SCRIPT%" + set "MILL_VERSION_SEARCH_PATTERN=//\|.*mill-version" + ) + ) + + rem Process the config file if found + if not "!MILL_VERSION_CONFIG_FILE!"=="" ( + rem Find the line and process it + for /f "tokens=*" %%a in ('findstr /R /C:"!MILL_VERSION_SEARCH_PATTERN!" "!MILL_VERSION_CONFIG_FILE!"') do ( + set "line=%%a" + + rem --- 1. Replicate sed 's/.*://' --- + rem This removes everything up to and including the first colon + set "line=!line:*:=!" + + rem --- 2. Replicate sed 's/#.*//' --- + rem Split on '#' and keep the first part + for /f "tokens=1 delims=#" %%b in ("!line!") do ( + set "line=%%b" + ) + + rem --- 3. Replicate sed 's/['"]//g' --- + rem Remove all quotes + set "line=!line:'=!" + set "line=!line:"=!" + + rem --- 4. Replicate sed's trim/space removal --- + rem Remove all space characters from the result. This is more robust. + set "MILL_VERSION=!line: =!" + + rem We found the version, so we can exit the loop + goto :version_found + ) + + :version_found + rem no-op + ) + ) + ) +) + +if [!MILL_VERSION!]==[] ( + set MILL_VERSION=%DEFAULT_MILL_VERSION% +) + +if [!MILL_FINAL_DOWNLOAD_FOLDER!]==[] set MILL_FINAL_DOWNLOAD_FOLDER=%USERPROFILE%\.cache\mill\download + +rem without bat file extension, cmd doesn't seem to be able to run it + +set "MILL_NATIVE_SUFFIX=-native" +set "MILL_JVM_SUFFIX=-jvm" +set "MILL_FULL_VERSION=%MILL_VERSION%" +set "MILL_DOWNLOAD_EXT=.bat" +set "ARTIFACT_SUFFIX=" +REM Check if MILL_VERSION contains MILL_NATIVE_SUFFIX +echo !MILL_VERSION! | findstr /C:"%MILL_NATIVE_SUFFIX%" >nul +if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-native=%" + REM -native images compiled with graal do not support windows-arm + REM https://github.com/oracle/graal/issues/9215 + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) else ( + rem no-op + ) +) else ( + echo !MILL_VERSION! | findstr /C:"%MILL_JVM_SUFFIX%" >nul + if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-jvm=%" + ) else ( + set "SKIP_VERSION=false" + set "MILL_PREFIX=%MILL_VERSION:~0,4%" + if "!MILL_PREFIX!"=="0.1." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.2." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.3." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.4." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.5." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.6." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.7." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.8." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.9." set "SKIP_VERSION=true" + set "MILL_PREFIX=%MILL_VERSION:~0,5%" + if "!MILL_PREFIX!"=="0.10." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.11." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.12." set "SKIP_VERSION=true" + + if "!SKIP_VERSION!"=="false" ( + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) + ) else ( + rem no-op + ) + ) +) + +set MILL=%MILL_FINAL_DOWNLOAD_FOLDER%\!MILL_FULL_VERSION!!MILL_DOWNLOAD_EXT! + +set MILL_RESOLVE_DOWNLOAD= + +if not exist "%MILL%" ( + set MILL_RESOLVE_DOWNLOAD=true +) else ( + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + set MILL_RESOLVE_DOWNLOAD=true + ) else ( + rem no-op + ) +) + + +if [!MILL_RESOLVE_DOWNLOAD!]==[true] ( + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,4% + set MILL_SHORT_VERSION_PREFIX=%MILL_VERSION:~0,2% + rem Since 0.5.0 + set MILL_DOWNLOAD_SUFFIX=-assembly + rem Since 0.11.0 + set MILL_DOWNLOAD_FROM_MAVEN=1 + if [!MILL_VERSION_PREFIX!]==[0.0.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.1.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.2.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.3.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.4.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.5.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.6.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.7.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.8.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.9.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + if [!MILL_VERSION_PREFIX!]==[0.10.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,8% + if [!MILL_VERSION_PREFIX!]==[0.11.0-M] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + set DOWNLOAD_EXT=exe + if [!MILL_SHORT_VERSION_PREFIX!]==[0.] set DOWNLOAD_EXT=jar + if [!MILL_VERSION_PREFIX!]==[0.12.] set DOWNLOAD_EXT=exe + if [!MILL_VERSION!]==[0.12.0] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.1] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.2] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.3] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.4] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.5] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.6] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.7] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.8] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.9] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.10] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.11] set DOWNLOAD_EXT=jar + + set MILL_VERSION_PREFIX= + set MILL_SHORT_VERSION_PREFIX= + + for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A + set MILL_VERSION_MILESTONE= + for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A + set MILL_VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1! + if [!MILL_VERSION_MILESTONE_START!]==[M] ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE! + ) else ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE! + ) + if [!MILL_DOWNLOAD_FROM_MAVEN!]==[1] ( + set MILL_DOWNLOAD_URL=https://repo1.maven.org/maven2/com/lihaoyi/mill-dist!ARTIFACT_SUFFIX!/!MILL_VERSION!/mill-dist!ARTIFACT_SUFFIX!-!MILL_VERSION!.!DOWNLOAD_EXT! + ) else ( + set MILL_DOWNLOAD_URL=!MILL_GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!MILL_DOWNLOAD_SUFFIX! + ) + + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + echo !MILL_DOWNLOAD_URL! + echo !MILL! + exit /b 0 + ) + + rem there seems to be no way to generate a unique temporary file path (on native Windows) + if defined MILL_OUTPUT_DIR ( + set MILL_TEMP_DOWNLOAD_FILE=%MILL_OUTPUT_DIR%\mill-temp-download + if not exist "%MILL_OUTPUT_DIR%" mkdir "%MILL_OUTPUT_DIR%" + ) else ( + set MILL_TEMP_DOWNLOAD_FILE=out\mill-bootstrap-download + if not exist "out" mkdir "out" + ) + + echo Downloading mill !MILL_VERSION! from !MILL_DOWNLOAD_URL! ... 1>&2 + + curl -f -L "!MILL_DOWNLOAD_URL!" -o "!MILL_TEMP_DOWNLOAD_FILE!" + + if not exist "%MILL_FINAL_DOWNLOAD_FOLDER%" mkdir "%MILL_FINAL_DOWNLOAD_FOLDER%" + move /y "!MILL_TEMP_DOWNLOAD_FILE!" "%MILL%" + + set MILL_TEMP_DOWNLOAD_FILE= + set MILL_DOWNLOAD_SUFFIX= +) + +set MILL_FINAL_DOWNLOAD_FOLDER= +set MILL_VERSION= +set MILL_REPO_URL= + +rem Need to preserve the first position of those listed options +set MILL_FIRST_ARG= +if [%~1%]==[--bsp] ( + set MILL_FIRST_ARG=%1% +) else ( + if [%~1%]==[-i] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--interactive] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-server] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-daemon] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) + ) + ) + ) + ) +) +set "MILL_PARAMS=%*%" + +if not [!MILL_FIRST_ARG!]==[] ( + for /f "tokens=1*" %%a in ("%*") do ( + set "MILL_PARAMS=%%b" + ) +) + +rem -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS% From 21a6fc7d11f287ef1ed91b1a487b4c66de6e6ed5 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:54:56 -0400 Subject: [PATCH 13/18] Route Windows CI through mill.bat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sh wrapper fetches Mill's native launcher, which has no Windows build — every Windows step died instantly with "This native mill launcher supports only Linux and macOS." A launcher-selection step now points Windows at the committed mill.bat and everything else at ./mill. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7411761..1e89ccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,15 +28,21 @@ jobs: path: ~/.cache/mill-bun key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - name: Select Mill launcher + # The sh wrapper fetches Mill's native launcher, which has no Windows build + # ("This native mill launcher supports only Linux and macOS") — Windows goes + # through the committed mill.bat instead. + run: echo "MILL=./mill$([ "$RUNNER_OS" = "Windows" ] && echo .bat)" >> "$GITHUB_ENV" + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.0 - name: Compile - run: ./mill --no-server millbun.compile + run: $MILL --no-server millbun.compile - name: Unit Tests - run: ./mill --no-server millbun.test + run: $MILL --no-server millbun.test # The suite defaults to the managed toolchain; this job pins system Bun to the same # version so `findOnPath` resolution and version verification keep CI coverage, while @@ -44,7 +50,7 @@ jobs: - name: Integration Tests env: MILL_BUN_USE_SYSTEM: 'true' - run: ./mill --no-server millbun.integration + run: $MILL --no-server millbun.integration managed-bun: strategy: @@ -68,7 +74,13 @@ jobs: path: ~/.cache/mill-bun key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - name: Select Mill launcher + # The sh wrapper fetches Mill's native launcher, which has no Windows build + # ("This native mill launcher supports only Linux and macOS") — Windows goes + # through the committed mill.bat instead. + run: echo "MILL=./mill$([ "$RUNNER_OS" = "Windows" ] && echo .bat)" >> "$GITHUB_ENV" + - name: Managed Bun smoke test env: MILL_BUN_USE_SYSTEM: 'false' - run: ./mill --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests + run: $MILL --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests From d5f6b61c76465a6589ca51ebcb9fe4c077a93f8d Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:00:27 -0400 Subject: [PATCH 14/18] Share the integration harness and cover the last untested features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five fixture suites copy-pasted the same tester/outputPath/ commandLogPath helpers and each re-encoded Mill's out// layout; BunIntegrationSuite now owns both, and the regenerator uses it too. bundle and bundleFast differed only in linker and --bytecode across 30 duplicated lines of args assembly; both now delegate to one Task.Anon body. bundleFast never bytecode-compiles — it exists for iteration speed — and its scaladoc says so. First-ever coverage for three shipped features: - BunSQLiteModule: typescript-compile mixes it in with a discovered data/app.db and asserts it lands in the compile workspace. The fixture chains bunCompileResources through super — an unqualified override silently drops the mixin's contribution, which is exactly the mistake a user would make, so the fixture models the right form. - npmOverrides end-to-end: the new typescript-overrides fixture has lib and app pinning conflicting is-odd specifiers; the override resolves the install against a committed lock, and the generated package.json carries both the resolved specifier and the overrides block. - bunDoctor: evaluated in the managed-toolchain suite. Also fixes the two scaladoc examples that still used Mill 0.x's millSourcePath (BunSQLiteModule, BunWorkersModule) — neither compiled. Re-running RegenerateFixtureLocks against the committed locks produces zero diffs, confirming lock generation is deterministic. Co-Authored-By: Claude Fable 5 --- .../resources/typescript-compile/build.mill | 9 +- .../resources/typescript-compile/data/app.db | 1 + .../resources/typescript-overrides/build.mill | 22 +++++ .../resources/typescript-overrides/bun.lock | 34 ++++++++ .../typescript-overrides/lib/src/index.ts | 3 + .../typescript-overrides/src/main.ts | 3 + ...unDependencyManifestIntegrationTests.scala | 16 +--- .../src/mill/bun/BunIntegrationSuite.scala | 39 +++++++++ .../BunManagedToolchainIntegrationTests.scala | 13 +-- .../mill/bun/BunScalaJSIntegrationTests.scala | 24 +----- .../bun/BunTypeScriptIntegrationTests.scala | 41 ++++----- .../bun/BunWorkspaceIntegrationTests.scala | 18 +--- .../src/mill/bun/RegenerateFixtureLocks.scala | 12 +-- millbun/src/mill/bun/BunSQLiteModule.scala | 2 +- millbun/src/mill/bun/BunWorkersModule.scala | 4 +- .../scalajslib/bun/BunScalaJSModule.scala | 84 ++++++++----------- 16 files changed, 173 insertions(+), 152 deletions(-) create mode 100644 millbun/integration/resources/typescript-compile/data/app.db create mode 100644 millbun/integration/resources/typescript-overrides/build.mill create mode 100644 millbun/integration/resources/typescript-overrides/bun.lock create mode 100644 millbun/integration/resources/typescript-overrides/lib/src/index.ts create mode 100644 millbun/integration/resources/typescript-overrides/src/main.ts create mode 100644 millbun/integration/src/mill/bun/BunIntegrationSuite.scala diff --git a/millbun/integration/resources/typescript-compile/build.mill b/millbun/integration/resources/typescript-compile/build.mill index 119dcd7..2dbf47d 100644 --- a/millbun/integration/resources/typescript-compile/build.mill +++ b/millbun/integration/resources/typescript-compile/build.mill @@ -6,9 +6,10 @@ package build import mill.* +import mill.bun.* import mill.javascriptlib.bun.* -object app extends BunTypeScriptModule { +object app extends BunTypeScriptModule with BunSQLiteModule { override def moduleDir = build.moduleDir override def npmDevDeps = Task { Seq("typescript@5.7.3") } override def compilerOptions = Task { @@ -18,5 +19,9 @@ object app extends BunTypeScriptModule { ) } override def bunBundleTarget = Task { "bun" } - override def bunCompileResources = Task.Sources("embedded") + // Chained through super so BunSQLiteModule's discovered databases stay in the set — an + // unqualified override here would silently drop the mixin's contribution. + def embeddedResources = Task.Sources("embedded") + override def bunCompileResources = Task { super.bunCompileResources() ++ embeddedResources() } + override def sqliteDatabaseDir = Task.Input { Some(PathRef(moduleDir / "data")) } } diff --git a/millbun/integration/resources/typescript-compile/data/app.db b/millbun/integration/resources/typescript-compile/data/app.db new file mode 100644 index 0000000..f7697b4 --- /dev/null +++ b/millbun/integration/resources/typescript-compile/data/app.db @@ -0,0 +1 @@ +not-a-real-sqlite-file-but-copying-is-what-we-test \ No newline at end of file diff --git a/millbun/integration/resources/typescript-overrides/build.mill b/millbun/integration/resources/typescript-overrides/build.mill new file mode 100644 index 0000000..f6c07ee --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/build.mill @@ -0,0 +1,22 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +// lib and app pin different is-odd specifiers; without the override the install fails with a +// deterministic conflict, and npmOverrides is the documented escape hatch. +object lib extends BunTypeScriptModule { + override def npmDeps = Task { Seq("is-odd@^3.0.0") } +} + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def moduleDeps = Seq(lib) + override def npmDeps = Task { Seq("is-odd@3.0.1") } + override def npmOverrides = Task { Map("is-odd" -> "3.0.1") } +} diff --git a/millbun/integration/resources/typescript-overrides/bun.lock b/millbun/integration/resources/typescript-overrides/bun.lock new file mode 100644 index 0000000..b4b59cf --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "is-odd": "3.0.1", + }, + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "overrides": { + "is-odd": "3.0.1", + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "is-number": ["is-number@6.0.0", "", {}, "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg=="], + + "is-odd": ["is-odd@3.0.1", "", { "dependencies": { "is-number": "^6.0.0" } }, "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-overrides/lib/src/index.ts b/millbun/integration/resources/typescript-overrides/lib/src/index.ts new file mode 100644 index 0000000..8adf276 --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/lib/src/index.ts @@ -0,0 +1,3 @@ +import isOdd from "is-odd"; + +export const oddCheck = (n: number): boolean => isOdd(n); diff --git a/millbun/integration/resources/typescript-overrides/src/main.ts b/millbun/integration/resources/typescript-overrides/src/main.ts new file mode 100644 index 0000000..f07f59d --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/src/main.ts @@ -0,0 +1,3 @@ +import isOdd from "is-odd"; + +console.log(`three is odd: ${isOdd(3)}`); diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 5d72c45..b966b50 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -4,21 +4,7 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest.* -object BunDependencyManifestIntegrationTests extends TestSuite { - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(resource: String): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / resource, - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - +object BunDependencyManifestIntegrationTests extends BunIntegrationSuite { def tests: Tests = Tests { test("invalid bun literal fails in build definitions") { diff --git a/millbun/integration/src/mill/bun/BunIntegrationSuite.scala b/millbun/integration/src/mill/bun/BunIntegrationSuite.scala new file mode 100644 index 0000000..1158cd3 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunIntegrationSuite.scala @@ -0,0 +1,39 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.TestSuite + +/** + * Shared harness for fixture-based integration suites. + * + * Every suite forks the same Mill executable against a fixture copied out of + * `millbun/integration/resources`, and most assertions navigate Mill's `out//` + * layout — this trait owns both, so the layout is encoded once instead of per suite. + */ +trait BunIntegrationSuite extends TestSuite { + val resourceDir: os.Path = + os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + protected def tester(resource: String): IntegrationTester = + new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / resource, + millExecutable = millExe, + useInMemory = true + ) + + /** The path a task's `PathRef` result points at. */ + protected def outputPath(tester: IntegrationTester, selector: String): os.Path = + tester.out(selector).value[PathRef].path + + /** Mill's per-command log file, where a forked command's console output lands. */ + protected def commandLogPath(tester: IntegrationTester, selector: String): os.Path = { + val segments = selector.split('.') + val rel = + if (segments.length <= 1) os.RelPath(".") + else os.RelPath(segments.dropRight(1).mkString("/")) + tester.workspacePath / "out" / rel / s"${segments.last}.log" + } +} diff --git a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala index 366c1c5..4d5a4a2 100644 --- a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -3,23 +3,18 @@ package mill.bun import mill.testkit.IntegrationTester import utest.* -object BunManagedToolchainIntegrationTests extends TestSuite: - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) +object BunManagedToolchainIntegrationTests extends BunIntegrationSuite: def tests: Tests = Tests: test("resolved toolchain runs the pinned Bun version"): - val tester = new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / "managed-bun", - millExecutable = millExe, - useInMemory = true - ) + val tester = this.tester("managed-bun") val result = tester.eval("app.bunExecutable") assert(result.isSuccess) val executable = tester.out("app.bunExecutable").value[String] val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim assert(version == "1.4.0") + // First-ever coverage for the diagnostics command: it must at least evaluate. + assert(tester.eval("app.bunDoctor").isSuccess) test("an evicted download cache is repopulated, not trusted"): val cacheDir = os.temp.dir() diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index 0836c27..9219895 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -4,29 +4,7 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest._ -object BunScalaJSIntegrationTests extends TestSuite { - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(resource: String): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / resource, - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - - private def commandLogPath(tester: IntegrationTester, selector: String): os.Path = { - val segments = selector.split('.') - val rel = - if (segments.length <= 1) os.RelPath(".") - else os.RelPath(segments.dropRight(1).mkString("/")) - tester.workspacePath / "out" / rel / s"${segments.last}.log" - } - +object BunScalaJSIntegrationTests extends BunIntegrationSuite { private def bundledScript(dist: os.Path): os.Path = os.walk(dist) .find(path => os.isFile(path) && path.ext == "js") diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index d4d2bcc..269aafc 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -4,29 +4,7 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest._ -object BunTypeScriptIntegrationTests extends TestSuite { - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(resource: String): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / resource, - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - - private def commandLogPath(tester: IntegrationTester, selector: String): os.Path = { - val segments = selector.split('.') - val rel = - if (segments.length <= 1) os.RelPath(".") - else os.RelPath(segments.dropRight(1).mkString("/")) - tester.workspacePath / "out" / rel / s"${segments.last}.log" - } - +object BunTypeScriptIntegrationTests extends BunIntegrationSuite { def tests: Tests = Tests { test("compile") { @@ -82,6 +60,23 @@ object BunTypeScriptIntegrationTests extends TestSuite { cwd = executable / os.up ) assert(run.out.text().trim == "Hello from compiled TypeScript executable!") + + // BunSQLiteModule feeds discovered databases into the compile workspace, alongside the + // module's own bunCompileResources (which must chain through super to keep them). + val workspace = tester.workspacePath / "out" / "app" / "compileExecutable.dest" / "workspace" + assert(os.exists(workspace / "data" / "app.db")) + } + + test("npmOverrides resolves conflicting transitive specifiers") { + // lib pins is-odd@^3.0.0 and app pins is-odd@3.0.1 — without the override this install + // fails with the deterministic conflict error; npmOverrides is the escape hatch. + val tester = this.tester("typescript-overrides") + assert(tester.eval("app.bunInstall").isSuccess) + + val packageJson = ujson.read(os.read(outputPath(tester, "app.bunInstall") / "package.json")) + assert(packageJson("dependencies").obj("is-odd").str == "3.0.1") + assert(packageJson("overrides").obj("is-odd").str == "3.0.1") + assert(os.exists(outputPath(tester, "app.bunInstall") / "node_modules" / "is-odd" / "package.json")) } test("strict installs require a source lock and bunLock creates it") { diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 6475eef..1bb1d3e 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -4,24 +4,10 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest.* -object BunWorkspaceIntegrationTests extends TestSuite: - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / "mixed-workspace", - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - +object BunWorkspaceIntegrationTests extends BunIntegrationSuite: def tests: Tests = Tests: test("mixed Scala.js and TypeScript packages share one install"): - val tester = this.tester() + val tester = this.tester("mixed-workspace") val lockResult = tester.eval("workspace.bunLock") assert(lockResult.isSuccess) diff --git a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala index 4f698c5..8676d9e 100644 --- a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala +++ b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala @@ -12,9 +12,7 @@ import utest.* * MILL_BUN_REGENERATE_LOCKS=1 ./mill millbun.integration.testOnly mill.bun.RegenerateFixtureLocks * }}} */ -object RegenerateFixtureLocks extends TestSuite: - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) +object RegenerateFixtureLocks extends BunIntegrationSuite: /** Fixture -> the bunLock commands that produce its committed lockfiles. */ val lockedFixtures: Seq[(String, Seq[String])] = Seq( @@ -26,6 +24,7 @@ object RegenerateFixtureLocks extends TestSuite: "typescript-bunfig" -> Seq("app.bunLock"), "typescript-compile" -> Seq("app.bunLock"), "typescript-env" -> Seq("app.bunLock"), + "typescript-overrides" -> Seq("app.bunLock"), "typescript-simple" -> Seq("app.bunLock"), "typescript-tests" -> Seq("app.bunLock"), "typescript-tsx" -> Seq("app.bunLock"), @@ -42,12 +41,7 @@ object RegenerateFixtureLocks extends TestSuite: ) else lockedFixtures.foreach { case (fixture, commands) => - val tester = new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / fixture, - millExecutable = millExe, - useInMemory = true - ) + val tester = this.tester(fixture) commands.foreach { command => val result = tester.eval(command) Predef.assert(result.isSuccess, s"$fixture: $command failed") diff --git a/millbun/src/mill/bun/BunSQLiteModule.scala b/millbun/src/mill/bun/BunSQLiteModule.scala index 8e497e9..6c780de 100644 --- a/millbun/src/mill/bun/BunSQLiteModule.scala +++ b/millbun/src/mill/bun/BunSQLiteModule.scala @@ -15,7 +15,7 @@ import mill.* * Usage: * {{{ * object app extends BunTypeScriptModule with BunSQLiteModule { - * override def sqliteDatabases = Task { Seq(PathRef(millSourcePath / "data" / "app.db")) } + * override def sqliteDatabases = Task.Sources(moduleDir / "data" / "app.db") * } * }}} */ diff --git a/millbun/src/mill/bun/BunWorkersModule.scala b/millbun/src/mill/bun/BunWorkersModule.scala index db095c9..98f9d28 100644 --- a/millbun/src/mill/bun/BunWorkersModule.scala +++ b/millbun/src/mill/bun/BunWorkersModule.scala @@ -15,9 +15,7 @@ import mill.javascriptlib.bun.BunTypeScriptModule * Usage: * {{{ * object app extends BunTypeScriptModule with BunWorkersModule { - * override def workerEntryPoints = Task { - * Seq(PathRef(millSourcePath / "src" / "worker.ts")) - * } + * override def workerEntryPoints = Task.Sources(moduleDir / "src" / "worker.ts") * } * }}} */ diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index eb0b739..39e7642 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -424,67 +424,49 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac ) } - /** Canonical production bundle task. */ - def bundle: T[PathRef] = Task { - val linked = fullLinkJS() + /** One body for [[bundle]] and [[bundleFast]]: they differ only in linker and bytecode. */ + private def bundleBuild(linkTask: Task[Report], bytecode: Task[Boolean]): Task[PathRef] = + Task.Anon { + val linked = linkTask() - val outDir = Task.dest / "dist" - os.makeDir.all(outDir) + val outDir = Task.dest / "dist" + os.makeDir.all(outDir) - val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) - val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") - val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) - val splittingArgs = if (bunBundleSplitting()) Seq("--splitting") else Nil - val bytecodeArgs = if (bunBundleBytecode()) Seq("--bytecode") else Nil + val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) + val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") + val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) + val splittingArgs = if (bunBundleSplitting()) Seq("--splitting") else Nil + val bytecodeArgs = if (bytecode()) Seq("--bytecode") else Nil - runBun( - bunExecutable(), - Seq("build") ++ - bundleEntrypoints(linked).map(_.toString) ++ - Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ - formatArgs ++ - sourcemapArgs ++ - externalArgs ++ - splittingArgs ++ - bytecodeArgs ++ - bunBundleArgs(), - cwd = linked.dest.path, - env = bunToolEnv() - ) + runBun( + bunExecutable(), + Seq("build") ++ + bundleEntrypoints(linked).map(_.toString) ++ + Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ + formatArgs ++ + sourcemapArgs ++ + externalArgs ++ + splittingArgs ++ + bytecodeArgs ++ + bunBundleArgs(), + cwd = linked.dest.path, + env = bunToolEnv() + ) + + PathRef(outDir) + } - PathRef(outDir) + /** Canonical production bundle task. */ + def bundle: T[PathRef] = Task { + bundleBuild(Task.Anon(fullLinkJS()), Task.Anon(bunBundleBytecode()))() } @deprecated("Use bundle", "0.3.0") def bunBundle: T[PathRef] = Task { bundle() } - /** Canonical fast-development bundle task. */ + /** Canonical fast-development bundle task. Never bytecode-compiles: it exists for iteration speed. */ def bundleFast: T[PathRef] = Task { - val linked = fastLinkJS() - - val outDir = Task.dest / "dist" - os.makeDir.all(outDir) - - val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) - val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") - val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) - val splittingArgs = if (bunBundleSplitting()) Seq("--splitting") else Nil - - runBun( - bunExecutable(), - Seq("build") ++ - bundleEntrypoints(linked).map(_.toString) ++ - Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ - formatArgs ++ - sourcemapArgs ++ - externalArgs ++ - splittingArgs ++ - bunBundleArgs(), - cwd = linked.dest.path, - env = bunToolEnv() - ) - - PathRef(outDir) + bundleBuild(Task.Anon(fastLinkJS()), Task.Anon(false))() } @deprecated("Use bundleFast", "0.3.0") From 3fb942ddeb2c4d530c2db6d9e06f2ebc7a17ce5e Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:09:51 -0400 Subject: [PATCH 15/18] Windows triage: POSIX permissions are not part of the publish contract there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.perms throws on NTFS, and bun.exe needs no executable bit — the permission half of the cross-filesystem publish test now runs only on POSIX. First genuine Windows finding; the managed-bun leg (download, extraction, shared cache, eviction recovery, mill.bat forking) already passes on windows-latest. Co-Authored-By: Claude Fable 5 --- millbun/test/src/mill/bun/BunToolchainTests.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index e74f10b..df23cb5 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -131,11 +131,14 @@ object BunToolchainTests extends TestSuite: val cached = root / "cache" / "def456" / "bun" val staged = root / "staged" / "bun" os.write(staged, "bun-binary", createFolders = true) - os.perms.set(staged, "rwxr-xr-x") + // NTFS has no POSIX permissions and os.perms throws there; Windows bun.exe needs no + // executable bit, so the permission half of this contract is POSIX-only. + val posix = !scala.util.Properties.isWin + if posix then os.perms.set(staged, "rwxr-xr-x") assert(BunToolchainModule.publishViaCopy(staged, cached) == cached) assert(os.read(cached) == "bun-binary") - assert(os.perms(cached).toString == "rwxr-xr-x") + if posix then assert(os.perms(cached).toString == "rwxr-xr-x") // The bytes must travel under a temp name and arrive by rename: a crash mid-publish can // never leave a partial file at the published path, and success leaves nothing behind. assert(os.list(cached / os.up) == Seq(cached)) From 6271d983c9d350086cf34826cc30c31ca4e8e8db Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:19:03 -0400 Subject: [PATCH 16/18] Make the examples runnable, wire them into CI, finish the scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit example-scalajs and example-typescript contained only a build.mill — the TypeScript one still using the pre-break bunBundleFormat: String form, so it did not even compile — while the README called them runnable. Both are now the README quick-starts made real: sources, tests, committed bun.lock, and their own mill/mill.bat wrappers. Both verified end to end (lock, run, testForked) against a locally published 0.3.0. The examples/ showcase needed locks for its two dependency-bearing modules — the strict default caught them with exactly the intended error — and now compiles fully. A new examples CI job publishes the plugin locally at whatever version the examples pin (extracted from the build header, so a release-version sweep cannot desynchronize the leg), runs both quick-starts, and compiles examples/. publishVersion is Task.Input: a plain Task captured PUBLISH_VERSION once and served the stale value until a manual clean — the runbook documented the workaround, and this session hit it for real when a 0.3.0 publishLocal left publishLocalTestRepo without the 0.0.0-NIGHTLY that every fixture pins. The fixtures then silently resolved a five-month-old jar from ~/.ivy2/local and 32 integration tests failed mysteriously. The fixture repo fallback also drops ivy2Local, so a stale local jar can never shadow the test repo again; the runbook note about cleaning cached publish metadata is gone because the footgun is. Scaffolding: CHANGELOG promoted to [0.3.0]; README's development section shows the shipped-defaults suite and one-command lock regeneration; RELEASING.md notes that mill-bun_mill1_3 is Mill's Scala 3 artifact mangling rather than a typo; the tracked-but-ignored .mill-jvm-version gitignore entry and the never-referenced millExecutable object are removed. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 34 +++ .gitignore | 1 - CHANGELOG.md | 2 +- README.md | 11 +- build.mill | 16 +- docs/RELEASING.md | 9 +- example-scalajs/build.mill | 21 +- example-scalajs/bun.lock | 15 ++ example-scalajs/mill.bat | 296 +++++++++++++++++++++++ example-scalajs/src/Main.scala | 12 + example-scalajs/test/src/MainTests.scala | 9 + example-typescript/build.mill | 31 +-- example-typescript/bun.lock | 29 +++ example-typescript/mill | 199 +++++++++++++++ example-typescript/mill.bat | 296 +++++++++++++++++++++++ example-typescript/src/main.ts | 6 + example-typescript/test/main.test.ts | 7 + examples/client/bun.lock | 15 ++ examples/tsLib/bun.lock | 24 ++ 19 files changed, 971 insertions(+), 62 deletions(-) create mode 100644 example-scalajs/bun.lock create mode 100644 example-scalajs/mill.bat create mode 100644 example-scalajs/src/Main.scala create mode 100644 example-scalajs/test/src/MainTests.scala create mode 100644 example-typescript/bun.lock create mode 100755 example-typescript/mill create mode 100644 example-typescript/mill.bat create mode 100644 example-typescript/src/main.ts create mode 100644 example-typescript/test/main.test.ts create mode 100644 examples/client/bun.lock create mode 100644 examples/tsLib/bun.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e89ccd..1ef38bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,3 +84,37 @@ jobs: env: MILL_BUN_USE_SYSTEM: 'false' run: $MILL --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests + + examples: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: actions/cache@v4 + with: + path: ~/.cache/mill-bun + key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + + # Publish at whatever version the examples pin, so a release-version sweep cannot + # silently desynchronize this leg. + - name: Publish the plugin locally + run: | + V=$(grep -o 'mill-bun_mill1:[0-9][0-9.]*' example-typescript/build.mill | cut -d: -f2) + PUBLISH_VERSION=$V ./mill --no-server millbun.publishLocal + + - name: example-typescript + run: cd example-typescript && ./mill --no-server app.run && ./mill --no-server app.test.testForked + + - name: example-scalajs + run: cd example-scalajs && ./mill --no-server app.run && ./mill --no-server app.test.testForked + + - name: examples project compiles + run: cd examples && ./mill --no-server __.compile diff --git a/.gitignore b/.gitignore index 4c94026..da65fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,4 @@ out/ *.tasty node_modules/ .DS_Store -.mill-jvm-version .claude/plans/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 281e213..96fcc66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `mill-bun-plugin` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.3.0] - Managed toolchain, strict lockfiles, canonical vocabulary (2026-08-27) ### Added diff --git a/README.md b/README.md index eef6df6..99e2b46 100644 --- a/README.md +++ b/README.md @@ -200,8 +200,15 @@ The older Scala.js `bunBundle*` and `bunCompile*` names remain as compatibility ```bash ./mill --no-server millbun.compile ./mill --no-server millbun.test -MILL_BUN_USE_SYSTEM=true MILL_BUN_REQUIRE_LOCKFILE=false \ - ./mill --no-server millbun.integration +./mill --no-server millbun.integration +``` + +The integration suite runs the shipped defaults: the managed Bun toolchain and strict lockfiles +against each fixture's committed `bun.lock`. Export `MILL_BUN_USE_SYSTEM=true` to run against a +local Bun instead, and regenerate every fixture lock after a dependency-default change with: + +```bash +MILL_BUN_REGENERATE_LOCKS=1 ./mill --no-server millbun.integration.testOnly mill.bun.RegenerateFixtureLocks ``` See [the 0.3 migration guide](docs/MIGRATING-0.3.md), the runnable `example-*` projects, and [the release runbook](docs/RELEASING.md). diff --git a/build.mill b/build.mill index ff46997..39c8df5 100644 --- a/build.mill +++ b/build.mill @@ -32,8 +32,10 @@ object millbun extends ScalaModule, PublishModule { val millScript = if (scala.util.Properties.isWin) BuildCtx.workspaceRoot / "mill.bat" else BuildCtx.workspaceRoot / "mill" + // No ivy2Local in the fallback: a stale mill-bun jar in ~/.ivy2/local would silently + // shadow the freshly published test repo whenever the pinned version goes missing there. val repos = Seq(publishLocalTestRepo().path.toNIO.toUri.toASCIIString) ++ - Seq(Task.env.getOrElse("COURSIER_REPOSITORIES", "ivy2Local|central")) + Seq(Task.env.getOrElse("COURSIER_REPOSITORIES", "central")) Map( "MILL_EXECUTABLE_PATH" -> millScript.toString, "COURSIER_REPOSITORIES" -> repos.mkString("|"), @@ -47,15 +49,13 @@ object millbun extends ScalaModule, PublishModule { // fixtures that deliberately test the relaxed path opt out in their own build.mill. ) } - - object millExecutable extends JavaModule { - def mvnDeps = Seq(mvn"com.lihaoyi:mill-runner-launcher_3:$millVersion") - def mainClass = Some("mill.launcher.MillLauncherMain") - } } - // CI injects release versions via PUBLISH_VERSION; local builds stay on SNAPSHOT. - def publishVersion: T[String] = Task { + // CI injects release versions via PUBLISH_VERSION; local builds stay on NIGHTLY. + // Task.Input, so switching PUBLISH_VERSION between runs re-evaluates instead of serving a + // stale cached version — a plain Task here silently kept the old value and poisoned + // publishLocalTestRepo until a manual clean. + def publishVersion: T[String] = Task.Input { Task.env.getOrElse("PUBLISH_VERSION", "0.0.0-NIGHTLY") } def pomSettings = PomSettings( diff --git a/docs/RELEASING.md b/docs/RELEASING.md index dcb5a5c..4d47403 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -59,12 +59,6 @@ Run the same checks the release workflow relies on: ./mill --no-server millbun.publishLocal ``` -If you switch `PUBLISH_VERSION` values in the same checkout, clear the cached publish metadata first: - -```bash -./mill --no-server clean millbun.publishVersion millbun.publishArtifacts -``` - Verify there are no remaining snapshot references in release-facing files: ```bash @@ -111,6 +105,9 @@ Expected artifact: - `com.tjclp:mill-bun_mill1_3:X.Y.Z` +The `_3` suffix is correct, not a typo: it is Mill's Scala 3 artifact mangling. Build headers +write `com.tjclp::mill-bun_mill1`, and the `::` shorthand resolves to this artifact id. + ## GPG Setup Example one-time setup: diff --git a/example-scalajs/build.mill b/example-scalajs/build.mill index 627a43a..bffdc70 100644 --- a/example-scalajs/build.mill +++ b/example-scalajs/build.mill @@ -6,35 +6,22 @@ package build import mill.* +import mill.scalalib.* import mill.scalajslib.* import mill.scalajslib.api.* import mill.scalajslib.bun.* +import mill.bun.bun object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" def scalaJSVersion = "1.22.0" - def mvnDeps = Seq( - mvn"org.scala-js::scalajs-dom::2.8.1" - ) - override def moduleKind = Task { ModuleKind.ESModule } - - // Example JSImport-backed package. - override def npmDeps = Task { - Seq( - "react@19.1.1", - "react-dom@19.1.1" - ) - } - - override def bunBundleTarget = Task { "browser" } + override def npmDeps = Task { Seq(bun"lodash@^4.17.21") } object test extends BunScalaJSTests, TestModule.Utest { - def mvnDeps = Seq( - mvn"com.lihaoyi::utest:0.8.5" - ) + def mvnDeps = Seq(mvn"com.lihaoyi::utest::0.8.5") def testFramework = "utest.runner.Framework" } } diff --git a/example-scalajs/bun.lock b/example-scalajs/bun.lock new file mode 100644 index 0000000..1a13766 --- /dev/null +++ b/example-scalajs/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "^4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + } +} diff --git a/example-scalajs/mill.bat b/example-scalajs/mill.bat new file mode 100644 index 0000000..948392f --- /dev/null +++ b/example-scalajs/mill.bat @@ -0,0 +1,296 @@ +@echo off + +setlocal enabledelayedexpansion + +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.5" ) + +if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) + +if [!MILL_MAIN_CLI!]==[] ( set "MILL_MAIN_CLI=%~f0" ) + +set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill" + +SET MILL_BUILD_SCRIPT= + +if exist "build.mill" ( + set MILL_BUILD_SCRIPT=build.mill +) else ( + if exist "build.mill.scala" ( + set MILL_BUILD_SCRIPT=build.mill.scala + ) else ( + if exist "build.sc" ( + set MILL_BUILD_SCRIPT=build.sc + ) else ( + rem no-op + ) + ) +) + +if [!MILL_VERSION!]==[] ( + if exist .mill-version ( + set /p MILL_VERSION=<.mill-version + ) else ( + if exist .config\mill-version ( + set /p MILL_VERSION=<.config\mill-version + ) else ( + rem Determine which config file to use for version extraction + set "MILL_VERSION_CONFIG_FILE=" + set "MILL_VERSION_SEARCH_PATTERN=" + + if exist build.mill.yaml ( + set "MILL_VERSION_CONFIG_FILE=build.mill.yaml" + set "MILL_VERSION_SEARCH_PATTERN=mill-version:" + ) else ( + if not "%MILL_BUILD_SCRIPT%"=="" ( + set "MILL_VERSION_CONFIG_FILE=%MILL_BUILD_SCRIPT%" + set "MILL_VERSION_SEARCH_PATTERN=//\|.*mill-version" + ) + ) + + rem Process the config file if found + if not "!MILL_VERSION_CONFIG_FILE!"=="" ( + rem Find the line and process it + for /f "tokens=*" %%a in ('findstr /R /C:"!MILL_VERSION_SEARCH_PATTERN!" "!MILL_VERSION_CONFIG_FILE!"') do ( + set "line=%%a" + + rem --- 1. Replicate sed 's/.*://' --- + rem This removes everything up to and including the first colon + set "line=!line:*:=!" + + rem --- 2. Replicate sed 's/#.*//' --- + rem Split on '#' and keep the first part + for /f "tokens=1 delims=#" %%b in ("!line!") do ( + set "line=%%b" + ) + + rem --- 3. Replicate sed 's/['"]//g' --- + rem Remove all quotes + set "line=!line:'=!" + set "line=!line:"=!" + + rem --- 4. Replicate sed's trim/space removal --- + rem Remove all space characters from the result. This is more robust. + set "MILL_VERSION=!line: =!" + + rem We found the version, so we can exit the loop + goto :version_found + ) + + :version_found + rem no-op + ) + ) + ) +) + +if [!MILL_VERSION!]==[] ( + set MILL_VERSION=%DEFAULT_MILL_VERSION% +) + +if [!MILL_FINAL_DOWNLOAD_FOLDER!]==[] set MILL_FINAL_DOWNLOAD_FOLDER=%USERPROFILE%\.cache\mill\download + +rem without bat file extension, cmd doesn't seem to be able to run it + +set "MILL_NATIVE_SUFFIX=-native" +set "MILL_JVM_SUFFIX=-jvm" +set "MILL_FULL_VERSION=%MILL_VERSION%" +set "MILL_DOWNLOAD_EXT=.bat" +set "ARTIFACT_SUFFIX=" +REM Check if MILL_VERSION contains MILL_NATIVE_SUFFIX +echo !MILL_VERSION! | findstr /C:"%MILL_NATIVE_SUFFIX%" >nul +if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-native=%" + REM -native images compiled with graal do not support windows-arm + REM https://github.com/oracle/graal/issues/9215 + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) else ( + rem no-op + ) +) else ( + echo !MILL_VERSION! | findstr /C:"%MILL_JVM_SUFFIX%" >nul + if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-jvm=%" + ) else ( + set "SKIP_VERSION=false" + set "MILL_PREFIX=%MILL_VERSION:~0,4%" + if "!MILL_PREFIX!"=="0.1." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.2." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.3." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.4." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.5." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.6." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.7." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.8." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.9." set "SKIP_VERSION=true" + set "MILL_PREFIX=%MILL_VERSION:~0,5%" + if "!MILL_PREFIX!"=="0.10." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.11." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.12." set "SKIP_VERSION=true" + + if "!SKIP_VERSION!"=="false" ( + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) + ) else ( + rem no-op + ) + ) +) + +set MILL=%MILL_FINAL_DOWNLOAD_FOLDER%\!MILL_FULL_VERSION!!MILL_DOWNLOAD_EXT! + +set MILL_RESOLVE_DOWNLOAD= + +if not exist "%MILL%" ( + set MILL_RESOLVE_DOWNLOAD=true +) else ( + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + set MILL_RESOLVE_DOWNLOAD=true + ) else ( + rem no-op + ) +) + + +if [!MILL_RESOLVE_DOWNLOAD!]==[true] ( + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,4% + set MILL_SHORT_VERSION_PREFIX=%MILL_VERSION:~0,2% + rem Since 0.5.0 + set MILL_DOWNLOAD_SUFFIX=-assembly + rem Since 0.11.0 + set MILL_DOWNLOAD_FROM_MAVEN=1 + if [!MILL_VERSION_PREFIX!]==[0.0.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.1.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.2.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.3.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.4.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.5.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.6.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.7.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.8.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.9.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + if [!MILL_VERSION_PREFIX!]==[0.10.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,8% + if [!MILL_VERSION_PREFIX!]==[0.11.0-M] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + set DOWNLOAD_EXT=exe + if [!MILL_SHORT_VERSION_PREFIX!]==[0.] set DOWNLOAD_EXT=jar + if [!MILL_VERSION_PREFIX!]==[0.12.] set DOWNLOAD_EXT=exe + if [!MILL_VERSION!]==[0.12.0] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.1] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.2] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.3] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.4] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.5] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.6] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.7] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.8] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.9] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.10] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.11] set DOWNLOAD_EXT=jar + + set MILL_VERSION_PREFIX= + set MILL_SHORT_VERSION_PREFIX= + + for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A + set MILL_VERSION_MILESTONE= + for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A + set MILL_VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1! + if [!MILL_VERSION_MILESTONE_START!]==[M] ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE! + ) else ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE! + ) + if [!MILL_DOWNLOAD_FROM_MAVEN!]==[1] ( + set MILL_DOWNLOAD_URL=https://repo1.maven.org/maven2/com/lihaoyi/mill-dist!ARTIFACT_SUFFIX!/!MILL_VERSION!/mill-dist!ARTIFACT_SUFFIX!-!MILL_VERSION!.!DOWNLOAD_EXT! + ) else ( + set MILL_DOWNLOAD_URL=!MILL_GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!MILL_DOWNLOAD_SUFFIX! + ) + + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + echo !MILL_DOWNLOAD_URL! + echo !MILL! + exit /b 0 + ) + + rem there seems to be no way to generate a unique temporary file path (on native Windows) + if defined MILL_OUTPUT_DIR ( + set MILL_TEMP_DOWNLOAD_FILE=%MILL_OUTPUT_DIR%\mill-temp-download + if not exist "%MILL_OUTPUT_DIR%" mkdir "%MILL_OUTPUT_DIR%" + ) else ( + set MILL_TEMP_DOWNLOAD_FILE=out\mill-bootstrap-download + if not exist "out" mkdir "out" + ) + + echo Downloading mill !MILL_VERSION! from !MILL_DOWNLOAD_URL! ... 1>&2 + + curl -f -L "!MILL_DOWNLOAD_URL!" -o "!MILL_TEMP_DOWNLOAD_FILE!" + + if not exist "%MILL_FINAL_DOWNLOAD_FOLDER%" mkdir "%MILL_FINAL_DOWNLOAD_FOLDER%" + move /y "!MILL_TEMP_DOWNLOAD_FILE!" "%MILL%" + + set MILL_TEMP_DOWNLOAD_FILE= + set MILL_DOWNLOAD_SUFFIX= +) + +set MILL_FINAL_DOWNLOAD_FOLDER= +set MILL_VERSION= +set MILL_REPO_URL= + +rem Need to preserve the first position of those listed options +set MILL_FIRST_ARG= +if [%~1%]==[--bsp] ( + set MILL_FIRST_ARG=%1% +) else ( + if [%~1%]==[-i] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--interactive] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-server] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-daemon] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) + ) + ) + ) + ) +) +set "MILL_PARAMS=%*%" + +if not [!MILL_FIRST_ARG!]==[] ( + for /f "tokens=1*" %%a in ("%*") do ( + set "MILL_PARAMS=%%b" + ) +) + +rem -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS% diff --git a/example-scalajs/src/Main.scala b/example-scalajs/src/Main.scala new file mode 100644 index 0000000..d41d139 --- /dev/null +++ b/example-scalajs/src/Main.scala @@ -0,0 +1,12 @@ +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +@js.native +@JSImport("lodash", JSImport.Default) +object Lodash extends js.Object { + def capitalize(s: String): String = js.native +} + +object Main extends App { + println(Lodash.capitalize("hello from scala.js on bun")) +} diff --git a/example-scalajs/test/src/MainTests.scala b/example-scalajs/test/src/MainTests.scala new file mode 100644 index 0000000..ea565e2 --- /dev/null +++ b/example-scalajs/test/src/MainTests.scala @@ -0,0 +1,9 @@ +import utest.* + +object MainTests extends TestSuite { + def tests: Tests = Tests { + test("lodash resolves under the Bun test runtime") { + assert(Lodash.capitalize("bun") == "Bun") + } + } +} diff --git a/example-typescript/build.mill b/example-typescript/build.mill index 9d52d4e..72904ae 100644 --- a/example-typescript/build.mill +++ b/example-typescript/build.mill @@ -1,4 +1,5 @@ //| mill-version: 1.1.5 +//| mill-jvm-version: system //| mvnDeps: //| - com.tjclp::mill-bun_mill1:0.3.0 @@ -7,33 +8,9 @@ package build import mill.* import mill.javascriptlib.bun.* -object shared extends BunTypeScriptModule { - override def npmDevDeps = Task { - Seq("typescript@5.7.3") - } -} - -object frontend extends BunTypeScriptModule { - override def moduleDeps = Seq(shared) - - override def npmDeps = Task { - Seq( - "react@19.1.1", - "react-dom@19.1.1" - ) - } - - override def npmDevDeps = Task { - Seq( - "typescript@5.7.3", - "@types/react@19.1.2", - "@types/react-dom@19.1.2" - ) - } - - override def enableEsm = Task { true } - override def bunBundleTarget = Task { "browser" } - override def bunBundleFormat = Task { "esm" } +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def npmDeps = Task { Seq("hono@^4.9.0") } object test extends BunTypeScriptTests } diff --git a/example-typescript/bun.lock b/example-typescript/bun.lock new file mode 100644 index 0000000..144e1cf --- /dev/null +++ b/example-typescript/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "hono": "^4.9.0", + }, + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/example-typescript/mill b/example-typescript/mill new file mode 100755 index 0000000..155baa7 --- /dev/null +++ b/example-typescript/mill @@ -0,0 +1,199 @@ +#!/usr/bin/env sh + +set -e + +if [ -z "${DEFAULT_MILL_VERSION}" ] ; then DEFAULT_MILL_VERSION="1.1.5"; fi + +if [ -z "${GITHUB_RELEASE_CDN}" ] ; then GITHUB_RELEASE_CDN=""; fi + +if [ -z "$MILL_MAIN_CLI" ] ; then MILL_MAIN_CLI="${0}"; fi + +MILL_REPO_URL="https://github.com/com-lihaoyi/mill" + +MILL_BUILD_SCRIPT="" + +if [ -f "build.mill" ] ; then + MILL_BUILD_SCRIPT="build.mill" +elif [ -f "build.mill.scala" ] ; then + MILL_BUILD_SCRIPT="build.mill.scala" +elif [ -f "build.sc" ] ; then + MILL_BUILD_SCRIPT="build.sc" +fi + +# `s/.*://`: +# This is a greedy match that removes everything from the beginning of the line up to (and including) the last +# colon (:). This effectively isolates the value part of the declaration. +# +# `s/#.*//`: +# This removes any comments at the end of the line. +# +# `s/['\"]//g`: +# This removes all single and double quotes from the string, wherever they appear (g is for "global"). +# +# `s/^[[:space:]]*//; s/[[:space:]]*$//`: +# These two expressions trim any leading or trailing whitespace ([[:space:]] matches spaces and tabs). +TRIM_VALUE_SED="s/.*://; s/#.*//; s/['\"]//g; s/^[[:space:]]*//; s/[[:space:]]*$//" + +if [ -z "${MILL_VERSION}" ] ; then + if [ -f ".mill-version" ] ; then + MILL_VERSION="$(tr '\r' '\n' < .mill-version | head -n 1 2> /dev/null)" + elif [ -f ".config/mill-version" ] ; then + MILL_VERSION="$(tr '\r' '\n' < .config/mill-version | head -n 1 2> /dev/null)" + elif [ -f "build.mill.yaml" ] ; then + MILL_VERSION="$(grep -E "mill-version:" "build.mill.yaml" | sed -E "$TRIM_VALUE_SED")" + elif [ -n "${MILL_BUILD_SCRIPT}" ] ; then + MILL_VERSION="$(grep -E "//\|.*mill-version" "${MILL_BUILD_SCRIPT}" | sed -E "$TRIM_VALUE_SED")" + fi +fi + +if [ -z "${MILL_VERSION}" ] ; then MILL_VERSION="${DEFAULT_MILL_VERSION}"; fi + +MILL_USER_CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/mill" + +if [ -z "${MILL_FINAL_DOWNLOAD_FOLDER}" ] ; then MILL_FINAL_DOWNLOAD_FOLDER="${MILL_USER_CACHE_DIR}/download"; fi + +MILL_NATIVE_SUFFIX="-native" +MILL_JVM_SUFFIX="-jvm" +ARTIFACT_SUFFIX="" + +# Check if GLIBC version is at least the required version +# Returns 0 (true) if GLIBC >= required version, 1 (false) otherwise +check_glibc_version() { + required_version="2.39" + required_major=$(echo "$required_version" | cut -d. -f1) + required_minor=$(echo "$required_version" | cut -d. -f2) + # Get GLIBC version from ldd --version (first line contains version like "ldd (GNU libc) 2.31") + glibc_version=$(ldd --version 2>/dev/null | head -n 1 | grep -oE '[0-9]+\.[0-9]+$' || echo "") + if [ -z "$glibc_version" ]; then + # If we can't determine GLIBC version, assume it's too old + return 1 + fi + glibc_major=$(echo "$glibc_version" | cut -d. -f1) + glibc_minor=$(echo "$glibc_version" | cut -d. -f2) + if [ "$glibc_major" -gt "$required_major" ]; then + return 0 + elif [ "$glibc_major" -eq "$required_major" ] && [ "$glibc_minor" -ge "$required_minor" ]; then + return 0 + else + return 1 + fi +} + +set_artifact_suffix() { + if [ "$(uname -s 2>/dev/null | cut -c 1-5)" = "Linux" ]; then + # Native binaries require new enough GLIBC; fall back to JVM launcher if older + if ! check_glibc_version; then + return + fi + if [ "$(uname -m)" = "aarch64" ]; then ARTIFACT_SUFFIX="-native-linux-aarch64" + else ARTIFACT_SUFFIX="-native-linux-amd64"; fi + elif [ "$(uname)" = "Darwin" ]; then + if [ "$(uname -m)" = "arm64" ]; then ARTIFACT_SUFFIX="-native-mac-aarch64" + else ARTIFACT_SUFFIX="-native-mac-amd64"; fi + else + echo "This native mill launcher supports only Linux and macOS." 1>&2 + exit 1 + fi +} + +case "$MILL_VERSION" in + *"$MILL_NATIVE_SUFFIX") + MILL_VERSION=${MILL_VERSION%"$MILL_NATIVE_SUFFIX"} + set_artifact_suffix + ;; + + *"$MILL_JVM_SUFFIX") + MILL_VERSION=${MILL_VERSION%"$MILL_JVM_SUFFIX"} + ;; + + *) + case "$MILL_VERSION" in + 0.1.* | 0.2.* | 0.3.* | 0.4.* | 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.* | 0.12.*) + ;; + *) + set_artifact_suffix + ;; + esac + ;; +esac + +MILL="${MILL_FINAL_DOWNLOAD_FOLDER}/$MILL_VERSION$ARTIFACT_SUFFIX" + +# If not already downloaded, download it +if [ ! -s "${MILL}" ] || [ "$MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT" = "1" ] ; then + case $MILL_VERSION in + 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.*) + MILL_DOWNLOAD_SUFFIX="" + MILL_DOWNLOAD_FROM_MAVEN=0 + ;; + 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M*) + MILL_DOWNLOAD_SUFFIX="-assembly" + MILL_DOWNLOAD_FROM_MAVEN=0 + ;; + *) + MILL_DOWNLOAD_SUFFIX="-assembly" + MILL_DOWNLOAD_FROM_MAVEN=1 + ;; + esac + case $MILL_VERSION in + 0.12.0 | 0.12.1 | 0.12.2 | 0.12.3 | 0.12.4 | 0.12.5 | 0.12.6 | 0.12.7 | 0.12.8 | 0.12.9 | 0.12.10 | 0.12.11) + MILL_DOWNLOAD_EXT="jar" + ;; + 0.12.*) + MILL_DOWNLOAD_EXT="exe" + ;; + 0.*) + MILL_DOWNLOAD_EXT="jar" + ;; + *) + MILL_DOWNLOAD_EXT="exe" + ;; + esac + + MILL_TEMP_DOWNLOAD_FILE="${MILL_OUTPUT_DIR:-out}/mill-temp-download" + mkdir -p "$(dirname "${MILL_TEMP_DOWNLOAD_FILE}")" + + if [ "$MILL_DOWNLOAD_FROM_MAVEN" = "1" ] ; then + MILL_DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist${ARTIFACT_SUFFIX}/${MILL_VERSION}/mill-dist${ARTIFACT_SUFFIX}-${MILL_VERSION}.${MILL_DOWNLOAD_EXT}" + else + MILL_VERSION_TAG=$(echo "$MILL_VERSION" | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') + MILL_DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${MILL_DOWNLOAD_SUFFIX}" + unset MILL_VERSION_TAG + fi + + + if [ "$MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT" = "1" ] ; then + echo "$MILL_DOWNLOAD_URL" + echo "$MILL" + exit 0 + fi + + echo "Downloading mill ${MILL_VERSION} from ${MILL_DOWNLOAD_URL} ..." 1>&2 + curl -f -L -o "${MILL_TEMP_DOWNLOAD_FILE}" "${MILL_DOWNLOAD_URL}" + + chmod +x "${MILL_TEMP_DOWNLOAD_FILE}" + + mkdir -p "${MILL_FINAL_DOWNLOAD_FOLDER}" + mv "${MILL_TEMP_DOWNLOAD_FILE}" "${MILL}" + + unset MILL_TEMP_DOWNLOAD_FILE + unset MILL_DOWNLOAD_SUFFIX +fi + +MILL_FIRST_ARG="" +if [ "$1" = "--bsp" ] || [ "${1#"-i"}" != "$1" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--no-daemon" ] || [ "$1" = "--help" ] ; then + # Need to preserve the first position of those listed options + MILL_FIRST_ARG=$1 + shift +fi + +unset MILL_FINAL_DOWNLOAD_FOLDER +unset MILL_OLD_DOWNLOAD_PATH +unset OLD_MILL +unset MILL_VERSION +unset MILL_REPO_URL + +# -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes +# shellcheck disable=SC2086 +exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" diff --git a/example-typescript/mill.bat b/example-typescript/mill.bat new file mode 100644 index 0000000..948392f --- /dev/null +++ b/example-typescript/mill.bat @@ -0,0 +1,296 @@ +@echo off + +setlocal enabledelayedexpansion + +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.5" ) + +if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) + +if [!MILL_MAIN_CLI!]==[] ( set "MILL_MAIN_CLI=%~f0" ) + +set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill" + +SET MILL_BUILD_SCRIPT= + +if exist "build.mill" ( + set MILL_BUILD_SCRIPT=build.mill +) else ( + if exist "build.mill.scala" ( + set MILL_BUILD_SCRIPT=build.mill.scala + ) else ( + if exist "build.sc" ( + set MILL_BUILD_SCRIPT=build.sc + ) else ( + rem no-op + ) + ) +) + +if [!MILL_VERSION!]==[] ( + if exist .mill-version ( + set /p MILL_VERSION=<.mill-version + ) else ( + if exist .config\mill-version ( + set /p MILL_VERSION=<.config\mill-version + ) else ( + rem Determine which config file to use for version extraction + set "MILL_VERSION_CONFIG_FILE=" + set "MILL_VERSION_SEARCH_PATTERN=" + + if exist build.mill.yaml ( + set "MILL_VERSION_CONFIG_FILE=build.mill.yaml" + set "MILL_VERSION_SEARCH_PATTERN=mill-version:" + ) else ( + if not "%MILL_BUILD_SCRIPT%"=="" ( + set "MILL_VERSION_CONFIG_FILE=%MILL_BUILD_SCRIPT%" + set "MILL_VERSION_SEARCH_PATTERN=//\|.*mill-version" + ) + ) + + rem Process the config file if found + if not "!MILL_VERSION_CONFIG_FILE!"=="" ( + rem Find the line and process it + for /f "tokens=*" %%a in ('findstr /R /C:"!MILL_VERSION_SEARCH_PATTERN!" "!MILL_VERSION_CONFIG_FILE!"') do ( + set "line=%%a" + + rem --- 1. Replicate sed 's/.*://' --- + rem This removes everything up to and including the first colon + set "line=!line:*:=!" + + rem --- 2. Replicate sed 's/#.*//' --- + rem Split on '#' and keep the first part + for /f "tokens=1 delims=#" %%b in ("!line!") do ( + set "line=%%b" + ) + + rem --- 3. Replicate sed 's/['"]//g' --- + rem Remove all quotes + set "line=!line:'=!" + set "line=!line:"=!" + + rem --- 4. Replicate sed's trim/space removal --- + rem Remove all space characters from the result. This is more robust. + set "MILL_VERSION=!line: =!" + + rem We found the version, so we can exit the loop + goto :version_found + ) + + :version_found + rem no-op + ) + ) + ) +) + +if [!MILL_VERSION!]==[] ( + set MILL_VERSION=%DEFAULT_MILL_VERSION% +) + +if [!MILL_FINAL_DOWNLOAD_FOLDER!]==[] set MILL_FINAL_DOWNLOAD_FOLDER=%USERPROFILE%\.cache\mill\download + +rem without bat file extension, cmd doesn't seem to be able to run it + +set "MILL_NATIVE_SUFFIX=-native" +set "MILL_JVM_SUFFIX=-jvm" +set "MILL_FULL_VERSION=%MILL_VERSION%" +set "MILL_DOWNLOAD_EXT=.bat" +set "ARTIFACT_SUFFIX=" +REM Check if MILL_VERSION contains MILL_NATIVE_SUFFIX +echo !MILL_VERSION! | findstr /C:"%MILL_NATIVE_SUFFIX%" >nul +if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-native=%" + REM -native images compiled with graal do not support windows-arm + REM https://github.com/oracle/graal/issues/9215 + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) else ( + rem no-op + ) +) else ( + echo !MILL_VERSION! | findstr /C:"%MILL_JVM_SUFFIX%" >nul + if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-jvm=%" + ) else ( + set "SKIP_VERSION=false" + set "MILL_PREFIX=%MILL_VERSION:~0,4%" + if "!MILL_PREFIX!"=="0.1." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.2." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.3." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.4." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.5." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.6." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.7." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.8." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.9." set "SKIP_VERSION=true" + set "MILL_PREFIX=%MILL_VERSION:~0,5%" + if "!MILL_PREFIX!"=="0.10." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.11." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.12." set "SKIP_VERSION=true" + + if "!SKIP_VERSION!"=="false" ( + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) + ) else ( + rem no-op + ) + ) +) + +set MILL=%MILL_FINAL_DOWNLOAD_FOLDER%\!MILL_FULL_VERSION!!MILL_DOWNLOAD_EXT! + +set MILL_RESOLVE_DOWNLOAD= + +if not exist "%MILL%" ( + set MILL_RESOLVE_DOWNLOAD=true +) else ( + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + set MILL_RESOLVE_DOWNLOAD=true + ) else ( + rem no-op + ) +) + + +if [!MILL_RESOLVE_DOWNLOAD!]==[true] ( + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,4% + set MILL_SHORT_VERSION_PREFIX=%MILL_VERSION:~0,2% + rem Since 0.5.0 + set MILL_DOWNLOAD_SUFFIX=-assembly + rem Since 0.11.0 + set MILL_DOWNLOAD_FROM_MAVEN=1 + if [!MILL_VERSION_PREFIX!]==[0.0.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.1.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.2.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.3.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.4.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.5.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.6.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.7.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.8.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.9.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + if [!MILL_VERSION_PREFIX!]==[0.10.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,8% + if [!MILL_VERSION_PREFIX!]==[0.11.0-M] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + set DOWNLOAD_EXT=exe + if [!MILL_SHORT_VERSION_PREFIX!]==[0.] set DOWNLOAD_EXT=jar + if [!MILL_VERSION_PREFIX!]==[0.12.] set DOWNLOAD_EXT=exe + if [!MILL_VERSION!]==[0.12.0] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.1] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.2] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.3] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.4] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.5] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.6] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.7] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.8] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.9] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.10] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.11] set DOWNLOAD_EXT=jar + + set MILL_VERSION_PREFIX= + set MILL_SHORT_VERSION_PREFIX= + + for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A + set MILL_VERSION_MILESTONE= + for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A + set MILL_VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1! + if [!MILL_VERSION_MILESTONE_START!]==[M] ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE! + ) else ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE! + ) + if [!MILL_DOWNLOAD_FROM_MAVEN!]==[1] ( + set MILL_DOWNLOAD_URL=https://repo1.maven.org/maven2/com/lihaoyi/mill-dist!ARTIFACT_SUFFIX!/!MILL_VERSION!/mill-dist!ARTIFACT_SUFFIX!-!MILL_VERSION!.!DOWNLOAD_EXT! + ) else ( + set MILL_DOWNLOAD_URL=!MILL_GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!MILL_DOWNLOAD_SUFFIX! + ) + + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + echo !MILL_DOWNLOAD_URL! + echo !MILL! + exit /b 0 + ) + + rem there seems to be no way to generate a unique temporary file path (on native Windows) + if defined MILL_OUTPUT_DIR ( + set MILL_TEMP_DOWNLOAD_FILE=%MILL_OUTPUT_DIR%\mill-temp-download + if not exist "%MILL_OUTPUT_DIR%" mkdir "%MILL_OUTPUT_DIR%" + ) else ( + set MILL_TEMP_DOWNLOAD_FILE=out\mill-bootstrap-download + if not exist "out" mkdir "out" + ) + + echo Downloading mill !MILL_VERSION! from !MILL_DOWNLOAD_URL! ... 1>&2 + + curl -f -L "!MILL_DOWNLOAD_URL!" -o "!MILL_TEMP_DOWNLOAD_FILE!" + + if not exist "%MILL_FINAL_DOWNLOAD_FOLDER%" mkdir "%MILL_FINAL_DOWNLOAD_FOLDER%" + move /y "!MILL_TEMP_DOWNLOAD_FILE!" "%MILL%" + + set MILL_TEMP_DOWNLOAD_FILE= + set MILL_DOWNLOAD_SUFFIX= +) + +set MILL_FINAL_DOWNLOAD_FOLDER= +set MILL_VERSION= +set MILL_REPO_URL= + +rem Need to preserve the first position of those listed options +set MILL_FIRST_ARG= +if [%~1%]==[--bsp] ( + set MILL_FIRST_ARG=%1% +) else ( + if [%~1%]==[-i] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--interactive] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-server] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-daemon] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) + ) + ) + ) + ) +) +set "MILL_PARAMS=%*%" + +if not [!MILL_FIRST_ARG!]==[] ( + for /f "tokens=1*" %%a in ("%*") do ( + set "MILL_PARAMS=%%b" + ) +) + +rem -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS% diff --git a/example-typescript/src/main.ts b/example-typescript/src/main.ts new file mode 100644 index 0000000..441cf8b --- /dev/null +++ b/example-typescript/src/main.ts @@ -0,0 +1,6 @@ +import { Hono } from "hono"; + +export const app = new Hono(); +app.get("/", (c) => c.text("Hello from mill-bun!")); + +console.log("hono app ready"); diff --git a/example-typescript/test/main.test.ts b/example-typescript/test/main.test.ts new file mode 100644 index 0000000..f9605bc --- /dev/null +++ b/example-typescript/test/main.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from "bun:test"; +import { app } from "../src/main"; + +test("the root route responds", async () => { + const response = await app.request("/"); + expect(await response.text()).toBe("Hello from mill-bun!"); +}); diff --git a/examples/client/bun.lock b/examples/client/bun.lock new file mode 100644 index 0000000..3c2bd2c --- /dev/null +++ b/examples/client/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "client", + "devDependencies": { + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + } +} diff --git a/examples/tsLib/bun.lock b/examples/tsLib/bun.lock new file mode 100644 index 0000000..1d053f9 --- /dev/null +++ b/examples/tsLib/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "tsLib", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} From c12eef389506d86e103226a91985ac2998f57ac3 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:33:07 -0400 Subject: [PATCH 17/18] Windows triage: the typescript-env proxy needs a .cmd twin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture's bun-proxy is a POSIX sh script, which CreateProcess cannot execute — the only failure in the entire Windows integration suite. The fixture now carries bun-proxy.cmd with identical semantics (log first-arg:marker, delegate to PATH bun, propagate the exit code) and picks the right one per platform. Co-Authored-By: Claude Fable 5 --- millbun/integration/resources/typescript-env/build.mill | 5 ++++- .../integration/resources/typescript-env/bun-proxy.cmd | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 millbun/integration/resources/typescript-env/bun-proxy.cmd diff --git a/millbun/integration/resources/typescript-env/build.mill b/millbun/integration/resources/typescript-env/build.mill index 22ed549..686ac00 100644 --- a/millbun/integration/resources/typescript-env/build.mill +++ b/millbun/integration/resources/typescript-env/build.mill @@ -11,7 +11,10 @@ import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { override def moduleDir = build.moduleDir override def npmDevDeps = Task { Seq("typescript@5.7.3") } - def bunProxy = Task.Source(moduleDir / "bun-proxy") + // The sh proxy cannot execute on Windows, so the fixture carries a .cmd twin. + def bunProxy = Task.Source( + if (scala.util.Properties.isWin) moduleDir / "bun-proxy.cmd" else moduleDir / "bun-proxy" + ) override def managedBunExecutable = Task { Some(PathRef(bunProxy().path)) } // The proxy execs whatever Bun is on PATH, so its --version reports the developer's Bun rather // than bunVersion. This fixture tests env propagation, not the version pin. diff --git a/millbun/integration/resources/typescript-env/bun-proxy.cmd b/millbun/integration/resources/typescript-env/bun-proxy.cmd new file mode 100644 index 0000000..3e105e3 --- /dev/null +++ b/millbun/integration/resources/typescript-env/bun-proxy.cmd @@ -0,0 +1,9 @@ +@echo off +setlocal +set "FIRST=%~1" +if "%FIRST%"=="" set "FIRST=unknown" +set "MARKER=%BUN_PROXY_MARKER%" +if "%MARKER%"=="" set "MARKER=missing" +>> "%CD%\.bun-env-log" echo %FIRST%:%MARKER% +bun %* +exit /b %ERRORLEVEL% From 6cebce8b56fcf48a27a04323c56647ce8890dbc9 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Thu, 27 Aug 2026 11:15:13 -0400 Subject: [PATCH 18/18] Final-review fixes: stale env toggles, zip-slip, Windows exe, test env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bunUseSystem and bunRequireLockfile read Task.env inside plain cached Tasks, so the documented workflows — toggling MILL_BUN_USE_SYSTEM, or MIGRATING-0.3's mid-migration MILL_BUN_REQUIRE_LOCKFILE=false — were silently ignored on a warm out/ until a manual clean. Both are Task.Input now, with a forked-eval integration test that toggles the requirement between runs on one warm out/ tree (confirmed failing with plain Task). mergeFromJar accepted `..` segments from jar entries: a dependency jar carrying the vendored marker could write arbitrary files during bunInstall without executing any code (zip-slip). Entries are backslash-normalized and refused when they climb out of the bundle root; a unit test builds a hostile jar and asserts refusal plus zero leakage. compileExecutable on Windows recorded a PathRef without the .exe bun actually appends — CreateProcess masked it in CI while downstream copies would fail and a missing path's constant signature broke invalidation. Both module kinds record the .exe name on Windows, and both integration tests now assert the recorded file exists. The TypeScript test trait gains bunTestEnv (default: outer bunRuntimeEnv), the test-side env lever mirroring bunTestJsEnv — overriding forkEnv on a test object compiles and does nothing. All six test-execution commands use it; the typescript-tests fixture asserts the variable reaches bun test. Generated member manifests now carry bunWorkspacePackageName (user packageJson.name still wins on TypeScript): overriding the name — the documented fix for colliding module names — previously satisfied Mill's duplicate guard while bun still saw the colliding defaults. The mixed-workspace fixture renames a member and asserts directory, manifest name, and vendor staging all follow. bunPublishedRuntimeInstall declared its .npmrc read as a Task.Source; the raw workspace read tripped Mill's filesystem checker the moment the file existed and never invalidated on change. Docs and CI: the README Scala.js quick start compiles now (missing scalalib import and utest dependency); the workspace snippet's imports are complete; the examples-CI version grep survives prerelease pins; RELEASING.md records why the release integration step deliberately omits PUBLISH_VERSION; MIGRATING-0.3 documents that deterministic conflict failure applies to every install, with npmOverrides as the escape hatch. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- README.md | 8 ++++- docs/MIGRATING-0.3.md | 10 +++++++ docs/RELEASING.md | 2 +- .../resources/mixed-workspace/build.mill | 3 ++ .../resources/typescript-tests/build.mill | 6 +++- .../typescript-tests/test/env.test.ts | 5 ++++ .../mill/bun/BunScalaJSIntegrationTests.scala | 3 ++ .../bun/BunTypeScriptIntegrationTests.scala | 21 ++++++++++++++ .../bun/BunWorkspaceIntegrationTests.scala | 9 ++++-- millbun/src/mill/bun/BunToolchainModule.scala | 8 +++-- .../src/mill/bun/BunVendoredNodeModules.scala | 9 +++++- .../bun/BunTypeScriptModule.scala | 29 ++++++++++++++----- .../scalajslib/bun/BunPublishModule.scala | 7 ++++- .../scalajslib/bun/BunScalaJSModule.scala | 10 +++++-- .../bun/BunVendoredNodeModulesTests.scala | 23 ++++++++++++++- 16 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 millbun/integration/resources/typescript-tests/test/env.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ef38bb..950aa79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: # silently desynchronize this leg. - name: Publish the plugin locally run: | - V=$(grep -o 'mill-bun_mill1:[0-9][0-9.]*' example-typescript/build.mill | cut -d: -f2) + V=$(grep -o 'mill-bun_mill1:[0-9][-0-9A-Za-z.]*' example-typescript/build.mill | cut -d: -f2) PUBLISH_VERSION=$V ./mill --no-server millbun.publishLocal - name: example-typescript diff --git a/README.md b/README.md index 99e2b46..f2ddb4e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Bun does not need to be installed. The plugin downloads and checksum-verifies Bu package build import mill.* +import mill.scalalib.* import mill.bun.bun import mill.scalajslib.api.* import mill.scalajslib.bun.* @@ -34,7 +35,10 @@ object app extends BunScalaJSModule { override def moduleKind = Task { ModuleKind.ESModule } override def npmDeps = Task { Seq(bun"lodash@^4.17.21") } - object test extends BunScalaJSTests, TestModule.Utest + object test extends BunScalaJSTests, TestModule.Utest { + def mvnDeps = Seq(mvn"com.lihaoyi::utest::0.8.5") + def testFramework = "utest.runner.Framework" + } } ``` @@ -148,6 +152,8 @@ Set `bunArchiveUrl` as well to download from a mirror; a mirror always requires ```scala import mill.bun.* +import mill.javascriptlib.bun.* +import mill.scalajslib.bun.* object scalaApp extends BunScalaJSModule { def scalaVersion = "3.8.2" diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md index 6dcd494..b6cb44d 100644 --- a/docs/MIGRATING-0.3.md +++ b/docs/MIGRATING-0.3.md @@ -42,6 +42,16 @@ override def bunBundleFormat = Task { "esm" } override def bunBundleFormat = Task { Some("esm") } ``` +### Contradictory npm declarations fail every install, not just publishing + +0.2 resolved the same package declared with different specifiers last-wins. 0.3 fails +deterministically for **all** generated package.json files — direct declarations, module-graph +aggregation, and classpath manifests alike. Resolve with `npmOverrides`: + +```scala +override def npmOverrides = Task { Map("react" -> "^19.0.0") } +``` + ### Local packages via `unmanagedDeps` must be directories with a `package.json` Entries are staged into `vendor/` beside the generated package.json and declared as diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4d47403..cbd88c0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -5,7 +5,7 @@ This document describes how to publish `mill-bun-plugin` to Maven Central and cr ## Overview - Releases are triggered by pushing a semver tag like `v0.1.0` or `v0.1.0-RC1`. -- The release workflow injects `PUBLISH_VERSION` from the tag, then runs `millbun.compile`, `millbun.test`, `millbun.integration`, and the Maven Central publish step. +- The release workflow injects `PUBLISH_VERSION` from the tag for the compile, unit test, and publish steps. The integration step deliberately runs **without** `PUBLISH_VERSION`: fixtures pin `0.0.0-NIGHTLY` against `publishLocalTestRepo`, and injecting the release version there would republish the test repo at the wrong version and break every fixture resolution. Do not "fix" that inconsistency. - If the tag is annotated, the tag message is used as the GitHub release body. Otherwise GitHub generates release notes automatically. ## Prerequisites diff --git a/millbun/integration/resources/mixed-workspace/build.mill b/millbun/integration/resources/mixed-workspace/build.mill index d158b78..d85e2e4 100644 --- a/millbun/integration/resources/mixed-workspace/build.mill +++ b/millbun/integration/resources/mixed-workspace/build.mill @@ -12,6 +12,9 @@ import mill.scalajslib.api.* import mill.scalajslib.bun.* object scalaApp extends BunScalaJSModule { + // Renamed deliberately: the layout's directory naming, duplicate guard, AND the generated + // member manifest must all follow this name, or bun sees a different identity than Mill. + override def bunWorkspacePackageName = Task { "scala-app-renamed" } def scalaVersion = "3.8.2" def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/typescript-tests/build.mill b/millbun/integration/resources/typescript-tests/build.mill index 3504a2a..3e3731f 100644 --- a/millbun/integration/resources/typescript-tests/build.mill +++ b/millbun/integration/resources/typescript-tests/build.mill @@ -11,5 +11,9 @@ import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { override def moduleDir = build.moduleDir - object test extends BunTypeScriptTests + object test extends BunTypeScriptTests { + // The test-side env lever: overriding forkEnv here compiles but is ignored (only upstream's + // Node runners read it), and outer bunRuntimeEnv would also change `run`. + override def bunTestEnv = Task { super.bunTestEnv() + ("BUN_TEST_MARKER" -> "set") } + } } diff --git a/millbun/integration/resources/typescript-tests/test/env.test.ts b/millbun/integration/resources/typescript-tests/test/env.test.ts new file mode 100644 index 0000000..09468b6 --- /dev/null +++ b/millbun/integration/resources/typescript-tests/test/env.test.ts @@ -0,0 +1,5 @@ +import { expect, test } from "bun:test"; + +test("bunTestEnv reaches the bun test process", () => { + expect(process.env.BUN_TEST_MARKER).toBe("set"); +}); diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index 9219895..1335e37 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -88,6 +88,9 @@ object BunScalaJSIntegrationTests extends BunIntegrationSuite { assert(res.isSuccess) val executable = outputPath(tester, "app.compileExecutable") + // The recorded PathRef must name the file bun actually wrote — on Windows bun appends + // .exe, and CreateProcess would happily run an extensionless path that does not exist. + assert(os.isFile(executable)) assert(runExecutable(executable) == "Hello from scala.js with lodash on bun") } diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 269aafc..a06b2ad 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -55,6 +55,7 @@ object BunTypeScriptIntegrationTests extends BunIntegrationSuite { assert(res.isSuccess) val executable = outputPath(tester, "app.compileExecutable") + assert(os.isFile(executable)) val run = os.call( Seq(executable.toString), cwd = executable / os.up @@ -94,6 +95,26 @@ object BunTypeScriptIntegrationTests extends BunIntegrationSuite { assert(args.contains("--frozen-lockfile")) } + test("lockfile requirement toggles between runs on a warm out directory") { + // bunRequireLockfile reads MILL_BUN_REQUIRE_LOCKFILE inside Task.Input: a plain Task + // caches the first-seen value, so the documented mid-migration escape hatch + // (MILL_BUN_REQUIRE_LOCKFILE=false) was silently ignored after one strict run. + // Forked evals, because only a subprocess sees a per-eval env. + val tester = new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / "typescript-simple", + millExecutable = millExe, + useInMemory = false + ) + // typescript-simple ships a committed lock; remove it from the copy so the strict default + // has something to reject. (typescript-lock is unsuitable here: it pins the requirement.) + os.remove(tester.workspacePath / "bun.lock") + val strict = tester.eval("app.bunInstall", env = Map("MILL_BUN_REQUIRE_LOCKFILE" -> "true")) + assert(!strict.isSuccess) + val relaxed = tester.eval("app.bunInstall", env = Map("MILL_BUN_REQUIRE_LOCKFILE" -> "false")) + assert(relaxed.isSuccess) + } + test("a lockfile from a newer Bun fails with regeneration guidance") { // bun.lock is forward- but not backward-compatible; bun's own failure is a raw // UnknownLockfileVersion that never mentions bunLock. The fixture's stub proves the diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 1bb1d3e..023cce6 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -19,7 +19,7 @@ object BunWorkspaceIntegrationTests extends BunIntegrationSuite: assert(os.read(workspaceInstall / ".workspace-installed").contains("--frozen-lockfile")) val rootJson = ujson.read(os.read(workspaceInstall / "package.json")) assert(rootJson("workspaces").arr.map(_.str).toSet == Set( - "packages/scalaApp", + "packages/scala-app-renamed", "packages/typescriptApp" )) assert(os.exists(workspaceInstall / "node_modules" / "is-even" / "package.json")) @@ -28,9 +28,12 @@ object BunWorkspaceIntegrationTests extends BunIntegrationSuite: // Unmanaged local packages arrive as file: specifiers with vendor trees staged beside the // member's package.json — never as positional install args, which turn `bun install` into // `bun add` and are unconditionally rejected by --frozen-lockfile. - val scalaJson = ujson.read(os.read(workspaceInstall / "packages" / "scalaApp" / "package.json")) + val scalaJson = ujson.read(os.read(workspaceInstall / "packages" / "scala-app-renamed" / "package.json")) + // The manifest identity must be the workspace package name, not the module's default — + // otherwise a rename satisfies Mill's duplicate guard while bun still sees collisions. + assert(scalaJson("name").str == "scala-app-renamed") assert(scalaJson("dependencies").obj("shared-local").str == "file:./vendor/shared-local") - assert(os.exists(workspaceInstall / "packages" / "scalaApp" / "vendor" / "shared-local" / "package.json")) + assert(os.exists(workspaceInstall / "packages" / "scala-app-renamed" / "vendor" / "shared-local" / "package.json")) assert(!os.read(workspaceInstall / ".workspace-installed").contains("shared-local")) val scalaResult = tester.eval("scalaApp.bunInstall") diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 83491bd..6728a70 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -515,7 +515,9 @@ trait BunToolchainModule extends Module { def bunUseMusl: T[Boolean] = Task.Input { BunToolchainModule.detectMusl() } /** Resolve Bun from PATH instead of using the managed distribution. */ - def bunUseSystem: T[Boolean] = Task { + // Task.Input: a plain Task caches the first-seen env value until a manual clean, so toggling + // MILL_BUN_USE_SYSTEM between runs — the documented workflow — would be silently ignored. + def bunUseSystem: T[Boolean] = Task.Input { Task.env.get("MILL_BUN_USE_SYSTEM").exists(_.equalsIgnoreCase("true")) } @@ -578,7 +580,9 @@ trait BunToolchainModule extends Module { } /** Require dependency-bearing modules to provide [[bunLockfile]]. */ - def bunRequireLockfile: T[Boolean] = Task { + // Task.Input for the same reason as bunUseSystem: MIGRATING-0.3 tells users mid-migration to + // set MILL_BUN_REQUIRE_LOCKFILE=false, which must take effect on a warm out/ directory. + def bunRequireLockfile: T[Boolean] = Task.Input { !Task.env.get("MILL_BUN_REQUIRE_LOCKFILE").exists(_.equalsIgnoreCase("false")) } diff --git a/millbun/src/mill/bun/BunVendoredNodeModules.scala b/millbun/src/mill/bun/BunVendoredNodeModules.scala index 23050c0..061008e 100644 --- a/millbun/src/mill/bun/BunVendoredNodeModules.scala +++ b/millbun/src/mill/bun/BunVendoredNodeModules.scala @@ -84,9 +84,16 @@ object BunVendoredNodeModules: if entries.isEmpty then return false entries.sortBy(_.getName).foreach { entry => - val relString = entry.getName.stripPrefix(prefix) + // Normalize the separators some zip writers use, then refuse entries that climb out of + // the bundle root: a hostile jar with `META-INF/bun/node_modules/../../x` must never + // become a write outside the destination (zip-slip). + val relString = entry.getName.replace('\\', '/').stripPrefix(prefix) if relString.nonEmpty then val rel = os.RelPath(relString) + if rel.ups != 0 then + throw new RuntimeException( + s"Vendored Bun bundle entry escapes its root in $jarPath: ${entry.getName}" + ) if !shouldSkip(rel.toNIO) then val dest = destNodeModules / rel val sourceLabel = s"$jarPath!/${entry.getName}" diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index caed8b2..2e33a8e 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -92,7 +92,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val resolved = ujson.Obj.from( user.copy( - name = if (user.name.nonEmpty) user.name else moduleName, + name = if (user.name.nonEmpty) user.name else bunWorkspacePackageName(), version = if (user.version.nonEmpty) user.version else "1.0.0", `type` = if (enableEsm()) "module" else user.`type`, dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( @@ -329,7 +329,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val compileDir = compile().path val buildDir = Task.dest / "workspace" val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString - val outFile = Task.dest / bunBinaryName() + // bun appends .exe to extensionless --compile outputs on Windows; the recorded PathRef + // must name the file bun actually writes, or downstream copies fail and caching never + // invalidates (a missing path's signature is constant). + val outFile = Task.dest / (bunBinaryName() + (if (scala.util.Properties.isWin) ".exe" else "")) // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. @@ -430,6 +433,16 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with */ override def tsDeps: T[Seq[String]] = Task { outer.tsDeps() } + /** + * Runtime environment for `bun test` processes, overridable per test module. + * + * `override def forkEnv` on a test object compiles but is silently ignored here (only + * upstream's Node-based runners read it), and `bunRuntimeEnv` lives on the outer module + * where overriding it also changes `run`. This is the test-side lever, mirroring the + * Scala.js `bunTestJsEnv`. + */ + def bunTestEnv: T[Map[String, String]] = Task { outer.bunRuntimeEnv() } + /** Test timeout in milliseconds. 0 means no timeout. */ def bunTestTimeout: T[Int] = Task { 0 } @@ -615,7 +628,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with os.call( Seq(bunExecutable(), "test") ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -626,7 +639,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with os.call( Seq(bunExecutable(), "test") ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -637,7 +650,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with os.call( Seq(bunExecutable(), "test", "--watch") ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -648,7 +661,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with os.call( Seq(bunExecutable(), "test", "--update-snapshots") ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -669,7 +682,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with coverageDir.toString ) ++ coverageReporterArgs ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -691,7 +704,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with coverageDir.toString ) ++ coverageReporterArgs ++ resolvedTestFlags(), cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv() + env = bunTestEnv() ) PathRef(coverageDir) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 8956b06..c290066 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -20,6 +20,11 @@ import mill.scalajslib.api.ModuleKind */ trait BunPublishModule extends BunScalaJSModule { + // Declared as a source: a plain read of workspaceRoot/.npmrc trips Mill's filesystem checker + // the moment the file exists (exactly the private-registry case vendoring needs), and an + // undeclared read would never invalidate this task when the file changes. + private def publishNpmRc = Task.Source(BuildCtx.workspaceRoot / ".npmrc") + /** Embed resolved `node_modules` into published artifacts. * * Disabled by default because published JARs are cross-platform, while @@ -56,7 +61,7 @@ trait BunPublishModule extends BunScalaJSModule { val dest = Task.dest os.makeDir.all(dest) - val npmRc = BuildCtx.workspaceRoot / ".npmrc" + val npmRc = publishNpmRc().path if (os.exists(npmRc)) os.copy.over(npmRc, dest / ".npmrc", createFolders = true) bunfigFiles().foreach { cfg => os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 39e7642..0c03165 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -203,7 +203,10 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac val allOptional = BunToolchainModule.dependencyPairs(transitiveNpmOptionalDeps(), overrides) val allPeers = BunToolchainModule.dependencyPairs(transitiveNpmPeerDeps(), overrides) val base = ujson.Obj( - "name" -> defaultPackageName, + // bunWorkspacePackageName, not the raw default: the workspace layout names directories + // and detects duplicates by it, so the manifest must carry the same identity or an + // override satisfies the guard while bun still sees the colliding default names. + "name" -> bunWorkspacePackageName(), "private" -> true, "version" -> "0.0.0", "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( @@ -486,7 +489,10 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac resolvedBunConfigs().foreach(cfg => os.copy.over(cfg.path, buildDir / cfg.path.last, createFolders = true)) copyCompileResources(bunCompileResources(), buildDir) - val outFile = Task.dest / bunBinaryName() + // bun appends .exe to extensionless --compile outputs on Windows; the recorded PathRef + // must name the file bun actually writes, or downstream copies fail and caching never + // invalidates (a missing path's signature is constant). + val outFile = Task.dest / (bunBinaryName() + (if (scala.util.Properties.isWin) ".exe" else "")) val entry = primaryEntrypoint(linked).relativeTo(linked.dest.path).toString val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") diff --git a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala index ac34238..949afd3 100644 --- a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -101,6 +101,26 @@ object BunVendoredNodeModulesTests extends TestSuite { assert(err.getMessage.contains("Vendored Bun bundle conflict")) } + test("jar entries that climb out of the bundle root are refused") { + // Zip-slip: a hostile dependency jar carrying the vendored marker plus a `..` entry must + // never become a file write outside the destination — this runs during bunInstall for any + // module whose classpath contains the jar, without executing any dependency code. + val jarPath = tempJar( + Map( + s"${BunVendoredNodeModules.BundleRoot}/react/package.json" -> """{"name":"react"}""", + s"${BunVendoredNodeModules.BundleRoot}/../../../escaped.txt" -> "outside" + ) + ) + val destRoot = os.temp.dir() + + val err = intercept[RuntimeException] { + BunVendoredNodeModules.mergeFromClasspathEntry(jarPath, destRoot / "node_modules") + } + assert(err.getMessage.contains("escapes its root")) + assert(!os.walk(destRoot).exists(_.last == "escaped.txt")) + assert(!os.exists(destRoot / os.up / "escaped.txt")) + } + test("merging through a symlinked destination is refused") { // In workspace mode node_modules links into the shared install's Task.dest. Merging // through it would silently mutate a directory every workspace member depends on. @@ -130,10 +150,11 @@ object BunVendoredNodeModulesTests extends TestSuite { private def tempJar(entries: Map[String, String]): os.Path = { val jarPath = os.temp.dir() / "bundle.jar" val jarOut = new JarOutputStream(new FileOutputStream(jarPath.toIO)) + val written = scala.collection.mutable.Set.empty[String] try entries.toSeq.sortBy(_._1).foreach { case (path, content) => val parentDirs = parentDirectories(path) - parentDirs.foreach { dir => + parentDirs.filter(written.add).foreach { dir => jarOut.putNextEntry(new JarEntry(dir)) jarOut.closeEntry() }