diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11a0874..950aa79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,18 @@ on: push: branches: [main] pull_request: - branches: [main] 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 @@ -19,15 +23,98 @@ 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: 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: latest + bun-version: 1.4.0 - 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 + # 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 - run: ./mill millbun.integration + env: + MILL_BUN_USE_SYSTEM: 'true' + run: $MILL --no-server millbun.integration + + managed-bun: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + 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') }} + + - 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 + + 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-9A-Za-z.]*' 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/.github/workflows/release.yml b/.github/workflows/release.yml index f3f63fc..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: latest + bun-version: 1.4.0 - 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/.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 da12f99..96fcc66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,46 @@ 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 - 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.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. +- `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. +- A committed lockfile written by a newer Bun fails frozen installs with regeneration guidance, + instead of surfacing bun's raw `UnknownLockfileVersion`. + +### 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. +- 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 + +- 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. +- 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) @@ -40,4 +74,3 @@ object test extends BunScalaJSTests: super.bunTestJsEnv() + ("NODE_ENV" -> "production") } ``` - diff --git a/README.md b/README.md index 716e277..f2ddb4e 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,66 @@ # 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 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 +## 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.scalalib.* 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 + object test extends BunScalaJSTests, TestModule.Utest { + def mvnDeps = Seq(mvn"com.lihaoyi::utest::0.8.5") + def testFramework = "utest.runner.Framework" + } } ``` -`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.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. -### TypeScript +## 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 +69,155 @@ 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.testForked +``` + +## Web applications -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: +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`. + +```bash +./mill --watch frontend.dev # reliable Scala.js relink + browser reload +./mill admin.dev # Bun-native TypeScript HMR +./mill frontend.bundle +./mill admin.bundle +``` + +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.4.0 + +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 + +`BunWorkspaceModule` gives multiple packages one install and one root lockfile: + +```scala +import mill.bun.* +import mill.javascriptlib.bun.* +import mill.scalajslib.bun.* + +object scalaApp extends BunScalaJSModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} -override def bunDeps = Task { Seq( - bun"react@^19.0.0", - bun"@anthropic-ai/claude-agent-sdk@^0.2.90", - bun"zod@^4.0.0" -)} +object tsApp extends BunTypeScriptModule { + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object workspace extends BunWorkspaceModule { + def bunWorkspacePackages = Seq(scalaApp, tsApp) +} ``` -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. +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 --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 `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..39c8df5 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 { @@ -35,22 +32,30 @@ 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("|") + "COURSIER_REPOSITORIES" -> repos.mkString("|"), + // 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") + // 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. ) } - - 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/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md new file mode 100644 index 0000000..b6cb44d --- /dev/null +++ b/docs/MIGRATING-0.3.md @@ -0,0 +1,105 @@ +# 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. + +### `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") } +``` + +### 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 +`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 | +|---|---|---| +| `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 | +| `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: + +```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/docs/RELEASING.md b/docs/RELEASING.md index dcb5a5c..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 @@ -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 921f9e4..bffdc70 100644 --- a/example-scalajs/build.mill +++ b/example-scalajs/build.mill @@ -1,39 +1,27 @@ //| 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.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 mvnDeps = Seq( - mvn"org.scala-js::scalajs-dom::2.8.1" - ) + def scalaJSVersion = "1.22.0" 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 20c0bfb..72904ae 100644 --- a/example-typescript/build.mill +++ b/example-typescript/build.mill @@ -1,39 +1,16 @@ //| 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.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/build.mill b/examples/build.mill index 9d35347..12e20a9 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") @@ -74,9 +79,9 @@ 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.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/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=="], + } +} 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% 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/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/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..d85e2e4 --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/build.mill @@ -0,0 +1,63 @@ +//| 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 { + // 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 } + 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 } + 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/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/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-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-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..1646f15 100644 --- a/millbun/integration/resources/scalajs-dependency-manifests/build.mill +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -14,8 +14,11 @@ 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" } + // 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, @@ -58,13 +61,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 +79,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 +96,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..1cbbf76 100644 --- a/millbun/integration/resources/scalajs-transitive/build.mill +++ b/millbun/integration/resources/scalajs-transitive/build.mill @@ -10,18 +10,20 @@ 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") } } 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-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/build.mill b/millbun/integration/resources/scalajs-web/build.mill new file mode 100644 index 0000000..853e71b --- /dev/null +++ b/millbun/integration/resources/scalajs-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.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 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/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/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..4654008 --- /dev/null +++ b/millbun/integration/resources/scalajs-web/src/Main.scala @@ -0,0 +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 = Lodash.capitalize("hello from scala.js web") 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/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/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/build.mill b/millbun/integration/resources/typescript-compile/build.mill index 8fa780e..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 { @@ -17,7 +18,10 @@ object app extends BunTypeScriptModule { "moduleResolution" -> ujson.Str("bundler") ) } - override def bunCompileExecutable = Task { true } 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/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-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-env/build.mill b/millbun/integration/resources/typescript-env/build.mill index b1d1228..686ac00 100644 --- a/millbun/integration/resources/typescript-env/build.mill +++ b/millbun/integration/resources/typescript-env/build.mill @@ -11,7 +11,13 @@ 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. + override def bunVerifyVersion = Task { false } override def bunEnv = Task { Map("BUN_PROXY_MARKER" -> "present") } } 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% 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-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-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/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-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/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/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-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/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-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/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/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-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/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/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 6911ef8..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") { @@ -27,20 +13,25 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(!res.isSuccess) } - test("published dev-only manifests are still emitted") { + 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") 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 +39,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 +64,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 +85,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/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 new file mode 100644 index 0000000..4d5a4a2 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -0,0 +1,43 @@ +package mill.bun + +import mill.testkit.IntegrationTester +import utest.* + +object BunManagedToolchainIntegrationTests extends BunIntegrationSuite: + + def tests: Tests = Tests: + test("resolved toolchain runs the pinned Bun version"): + 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() + 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/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index 2138810..1335e37 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") @@ -61,39 +39,77 @@ 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("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.bunCompileExecutable") + val res = tester.eval("app.compileExecutable") assert(res.isSuccess) - val executable = outputPath(tester, "app.bunCompileExecutable") + 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") } 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") } - 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") { @@ -111,13 +127,13 @@ 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")) - 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 d7194d9..a06b2ad 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") { @@ -50,6 +28,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,26 +51,92 @@ 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") + assert(os.isFile(executable)) val run = os.call( Seq(executable.toString), 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") { + val tester = this.tester("typescript-lock") + 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.bunInstall") + assert(installResult.isSuccess) + val args = os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".bun-args") + 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 + // 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.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 - 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")) } @@ -90,7 +146,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") @@ -100,8 +156,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") { @@ -137,7 +197,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. @@ -150,18 +210,21 @@ 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") + 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")) @@ -169,16 +232,81 @@ 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) } + 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.bunInstall") + 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.bunInstall").isSuccess) + assert(tester.eval("app.test.testForked").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.bunInstall").isSuccess) + val installed = outputPath(tester, "app.bunInstall") + 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") + 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.bunInstall") + assert(installPath == tester.workspacePath / "out" / "app" / "bunInstall.dest") + } + test("bunEnv") { val tester = this.tester("typescript-env") 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 new file mode 100644 index 0000000..023cce6 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -0,0 +1,44 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.* + +object BunWorkspaceIntegrationTests extends BunIntegrationSuite: + def tests: Tests = Tests: + test("mixed Scala.js and TypeScript packages share one install"): + val tester = this.tester("mixed-workspace") + + 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/scala-app-renamed", + "packages/typescriptApp" + )) + 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" / "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" / "scala-app-renamed" / "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.bunInstall") + assert(scalaResult.isSuccess) + assert(typescriptResult.isSuccess) + assert(os.isLink(outputPath(tester, "scalaApp.bunInstall") / "node_modules")) + assert(os.isLink(outputPath(tester, "typescriptApp.bunInstall") / "node_modules")) diff --git a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala new file mode 100644 index 0000000..8676d9e --- /dev/null +++ b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala @@ -0,0 +1,58 @@ +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 BunIntegrationSuite: + + /** 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-overrides" -> 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 = this.tester(fixture) + 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/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/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/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/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 0e65db9..6728a70 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -2,18 +2,414 @@ 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(""))) + /** + * 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", + "optionalDependencies", + "peerDependencies", + "overrides", + "workspaces" + ) + + private[bun] final case class NpmDependency(name: String, specifier: String) + + private[bun] final case class Distribution( + assetName: String, + executableName: String + ) + + /** + * 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" + ) + ) + + /** 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. + * + * 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("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 + _ <- + 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${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] = + BundledChecksums.get(version).flatMap(_.get(assetName)) + + 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") + } + } + + /** + * 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.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 + 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) + } + } + + /** + * 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 + 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,16 +426,50 @@ 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. */ - 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) + // 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 { + 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. @@ -57,31 +487,105 @@ 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) } } } trait BunToolchainModule extends Module { + /** Tested Bun release downloaded by default when no override is configured. */ + 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. */ + // 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")) + } + /** 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 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(_, BuildCtx.workspaceRoot)) + .getOrElse(os.home / ".cache" / "mill-bun") + } + + /** 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 } + /** 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 } + /** 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]]. */ + // 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")) + } + /** Hoisted installs are the safest default for Node-compatible resolution. */ def bunLinker: T[String] = Task { "hoisted" } @@ -89,6 +593,60 @@ 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) + } + + /** + * @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, + pinnedBunVersion: String, + lockfilePath: os.Path = moduleDir / "bun.lock" + ): Unit = { + if (hasInstallInputs && required && lockfile.isEmpty) { + throw new RuntimeException( + 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)) + } + } + /** * Bun config files to copy into generated workspaces. * @@ -118,14 +676,117 @@ 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"), + 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.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}" + ) + 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}). " + + s"Bundled versions are ${BunToolchainModule.bundledVersions.mkString(", ")}. " + + "Set bunArchiveSha256 to the archive's SHA-256 to use this version anyway." )) + .toLowerCase + + 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)) BunToolchainModule.cachedExecutableRef(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) + BunToolchainModule.cachedExecutableRef(BunToolchainModule.publishToCache(staged, cached)) + } + } + + 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()}") + 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/bun/BunVendoredNodeModules.scala b/millbun/src/mill/bun/BunVendoredNodeModules.scala index 623d487..061008e 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 @@ -74,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/bun/BunWebSupport.scala b/millbun/src/mill/bun/BunWebSupport.scala new file mode 100644 index 0000000..0b779c1 --- /dev/null +++ b/millbun/src/mill/bun/BunWebSupport.scala @@ -0,0 +1,136 @@ +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, + exclude: Set[String] = Set.empty + ): Unit = + if os.exists(source) then + 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 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 + ): 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 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( + entries.head, + s""" + | + | + |
+ | + |""".stripMargin, + createFolders = true + ) + entries + + 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/BunWorkersModule.scala b/millbun/src/mill/bun/BunWorkersModule.scala index 8e1934d..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") * } * }}} */ @@ -33,7 +31,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 } @@ -59,6 +57,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. + bunInstall() BunToolchainModule.copyWorkspace(compile().path, workspace) os.makeDir.all(outDir) @@ -91,7 +92,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 new file mode 100644 index 0000000..f9eb152 --- /dev/null +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -0,0 +1,158 @@ +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, 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( + "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(), bunVersion()) + copyBunLockfile(lockfile, Task.dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ), + cwd = Task.dest, + env = bunToolEnv() + ) + PathRef(Task.dest) + } + + /** Resolve the full workspace and update its source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + 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 + ), + cwd = Task.dest, + env = bunToolEnv() + ) + + 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..2e33a8e 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 } @@ -13,10 +24,16 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out /** 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") def bunCompileExecutable: T[Boolean] = Task { false } /** Treat all packages as external during bundling. */ @@ -37,11 +54,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out /** 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" } @@ -49,8 +63,12 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out /** 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 { @@ -66,22 +84,41 @@ 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, + 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(transitiveNpmDeps().map(BunToolchainModule.splitDep)), - devDependencies = ujson.Obj.from((transitiveNpmDevDeps() ++ tsDeps()).map(BunToolchainModule.splitDep)) + dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), + transitiveUnmanagedDeps() + )), + 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 { @@ -111,21 +148,74 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out } } - /** 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() + 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(), bunVersion()) + copyBunLockfile(lockfile, dest) + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ), + cwd = dest, + env = bunToolEnv() + ) + + 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 + 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) + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ), 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 @@ -137,7 +227,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out tscCopyGenSources() tscLinkResources() BunTypeScriptModule.removeInstallOnlyConfigs(Task.dest) - ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) + ensureInstallArtifacts(Task.dest, bunInstall().path, bunLockfiles()) BunTypeScriptModule.copyBunfigsTo(Task.dest, resolvedBunfigs()) mkTsconfig() @@ -152,7 +242,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out } 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. */ @@ -204,6 +294,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out 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. + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -221,10 +314,47 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out "--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() + ) + + 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 + // 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. + bunInstall() + 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() ) @@ -237,13 +367,16 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out * 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\").") 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. + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -276,6 +409,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. * @@ -284,6 +421,28 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out */ 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() } + + /** + * 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 } @@ -293,22 +452,29 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out /** 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 outerDeps = outer.transitiveNpmDeps().map(BunToolchainModule.splitDep) - val outerDevDeps = (outer.transitiveNpmDevDeps() ++ outer.tsDeps()).map(BunToolchainModule.splitDep) + val overrides = outer.npmOverrides() + 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 // 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(this.transitiveNpmDeps() ++ this.npmDevDeps() ++ this.tsDeps(), overrides) .filterNot { case (name, _) => outerPackageNames.contains(name) } val resolved = ujson.Obj.from( @@ -321,26 +487,126 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out ).cleanJson.obj.toSeq ) - val merged = ujson.Obj.from(resolved.value.toSeq ++ outer.bunPackageJsonExtras().value.toSeq) - os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + 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)) + ) + + BunToolchainModule.mergePackageJson(resolved, outer.bunPackageJsonExtras()) + } + + /** + * 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 + } + + /** 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() + } + + /** 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) + 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(), + pinnedBunVersion = outer.bunVersion(), + lockfilePath = moduleDir / "bun.lock" + ) + outer.copyBunLockfile(lockfile, dest) + BunToolchainModule.stageUnmanagedDeps( + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct, + dest + ) + + outer.runBun( + outer.bunExecutable(), + Seq("install") ++ outer.resolvedBunInstallArgs( + outer.bunInstallArgs(), + outer.bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ), + cwd = dest, + env = outer.bunToolEnv() + ) + + PathRef(dest) + } + } + + /** 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`. + * + * 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) + BunToolchainModule.stageUnmanagedDeps( + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct, + dest + ) - runBun( - bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), + outer.runBun( + outer.bunExecutable(), + Seq("install") ++ outer.resolvedBunInstallArgs( + outer.bunInstallArgs(), + outer.bunInstallExtraArgs(), + this.bunLockfile().nonEmpty, + updateLockfile = true + ), 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 { 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) } @@ -357,11 +623,23 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out 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 = bunTestEnv(), + 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, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -372,7 +650,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out os.call( Seq(bunExecutable(), "test", "--watch") ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -383,7 +661,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out os.call( Seq(bunExecutable(), "test", "--update-snapshots") ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -404,7 +682,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out coverageDir.toString ) ++ coverageReporterArgs ++ resolvedTestFlags() ++ args.value, cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv(), + env = bunTestEnv(), stdout = os.Inherit, stderr = os.Inherit ) @@ -426,7 +704,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out coverageDir.toString ) ++ coverageReporterArgs ++ resolvedTestFlags(), cwd = preparedTestWorkspace().path, - env = outer.bunRuntimeEnv() + env = bunTestEnv() ) PathRef(coverageDir) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala new file mode 100644 index 0000000..98de0c3 --- /dev/null +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -0,0 +1,109 @@ +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.materializeHtmlEntries(htmlRefs, moduleDir, destination, script) + } + + /** + * 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(), + webEntryPoints(), + webPublicSources(), + bunInstall().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 { + // 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)) + .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 = webStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage) + 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..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 @@ -27,27 +32,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 ) } @@ -67,14 +61,17 @@ 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) } - val deps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep) - val optional = bunOptionalDeps().map(BunToolchainModule.splitDep) + val deps = BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()), + unmanagedDeps() + ) + val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps() ++ bunOptionalDeps(), npmOverrides()) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, @@ -88,17 +85,25 @@ 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(), bunVersion()) + copyBunLockfile(lockfile, dest) if hasRuntimeInputs then + BunToolchainModule.stageUnmanagedDeps(unmanagedDeps(), dest) runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ unmanagedDeps().map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) PathRef(dest) @@ -126,14 +131,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..0c03165 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 } @@ -40,7 +39,13 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out */ 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") @@ -77,16 +82,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 +110,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 +123,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 +181,46 @@ 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, + // 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(transitiveNpmDeps().map(BunToolchainModule.splitDep)), - "devDependencies" -> ujson.Obj.from(transitiveNpmDevDeps().map(BunToolchainModule.splitDep)) + "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), + transitiveUnmanagedDeps() + )), + "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 +230,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,25 +261,90 @@ 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() - ) - val ownResourceRoots = resources().map(_.path).toSet val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) - mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") + + 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") + 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(), bunVersion()) + copyBunLockfile(lockfile, dest) + + if hasInstallInputs then + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ), + cwd = dest, + env = bunToolEnv() + ) + + mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") 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() + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) + copyBunLockfile(bunLockfile(), dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ), + cwd = dest, + env = bunToolEnv() + ) + + 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,76 +427,72 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out ) } - def bunBundle: T[PathRef] = Task { - val linked = fullLinkJS() - - 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 + /** 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() - 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 = bunEnv() - ) + val outDir = Task.dest / "dist" + os.makeDir.all(outDir) - PathRef(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 (bytecode()) Seq("--bytecode") else Nil - def bunBundleFast: T[PathRef] = Task { - val linked = fastLinkJS() + 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() + ) - val outDir = Task.dest / "dist" - os.makeDir.all(outDir) + PathRef(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 + /** Canonical production bundle task. */ + def bundle: T[PathRef] = Task { + bundleBuild(Task.Anon(fullLinkJS()), Task.Anon(bunBundleBytecode()))() + } - runBun( - bunExecutable(), - Seq("build") ++ - bundleEntrypoints(linked).map(_.toString) ++ - Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ - formatArgs ++ - sourcemapArgs ++ - externalArgs ++ - splittingArgs ++ - bunBundleArgs(), - cwd = linked.dest.path, - env = bunEnv() - ) + @deprecated("Use bundle", "0.3.0") + def bunBundle: T[PathRef] = Task { bundle() } - PathRef(outDir) + /** Canonical fast-development bundle task. Never bytecode-compiles: it exists for iteration speed. */ + def bundleFast: T[PathRef] = Task { + bundleBuild(Task.Anon(fastLinkJS()), Task.Anon(false))() } + @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() + // 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)) 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") @@ -395,21 +506,25 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out bytecodeArgs ++ bunBundleArgs(), cwd = buildDir, - env = bunEnv() + env = bunToolEnv() ) 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\").") 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)) @@ -433,14 +548,17 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out bytecodeArgs ++ bunBundleArgs(), cwd = buildDir, - env = bunEnv() + env = bunToolEnv() ) target -> PathRef(outFile) }.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,30 +607,17 @@ 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. */ + /** 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 new file mode 100644 index 0000000..e7dfb84 --- /dev/null +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala @@ -0,0 +1,100 @@ +package mill.scalajslib +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: + + /** 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 } + + /** + * 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.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 { + 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) + 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) + val destination = Task.dest / "dist" + runBun( + bunExecutable(), + Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBundleArgs(), + cwd = stage, + env = bunToolEnv() + ) + PathRef(destination) + } 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. } } 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..df23cb5 --- /dev/null +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -0,0 +1,281 @@ +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("composes musl and baseline asset names"): + assert( + BunToolchainModule.distribution("Linux", "x64", musl = true) == Right( + BunToolchainModule.Distribution("bun-linux-x64-musl.zip", "bun") + ) + ) + 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}"))) + + 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("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) + // 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") + 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)) + + 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("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) + 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( + 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/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala index 54caa89..949afd3 100644 --- a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -100,6 +100,45 @@ 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. + 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 = { @@ -111,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() } 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..db629e7 --- /dev/null +++ b/millbun/test/src/mill/bun/CopyTreeTests.scala @@ -0,0 +1,87 @@ +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("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") + 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) 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") {