From b01010d51abb7e40d9dc92a4c7a3f96580d14bb4 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Fri, 3 Apr 2026 16:47:56 -0400 Subject: [PATCH 1/5] feat: transitive bun dependencies via JAR manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Scala.js library publishes with BunScalaJSModule, it can embed a bun dependency manifest (META-INF/bun/bun-dependencies.json) and lockfile (META-INF/bun/bun.lock) in the JAR. Consumer builds scan classpath JARs for these manifests and merge them into package.json automatically — making JS deps transitive, like Coursier does for JVM. Write side (library authors): - bunDependencyManifest: generates manifest + embeds lockfile - bunDependencyManifestResources: wire into JAR resources - bunOptionalDeps: optional JS packages (won't fail if unavailable) Read side (consumers): - classpathBunDeps/classpathBunDevDeps: scan JARs for manifests - classpathBunLockfiles: extract embedded lockfiles - transitiveNpmDeps: now includes JAR-discovered deps - bunInstall: seeds lockfile from JARs for deterministic resolution BunManifest: data class + JSON serialization + JAR I/O + merge. 11 new unit tests covering round-trip, JAR read/write, merge, edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunManifest.scala | 106 ++++++++++++ .../scalajslib/bun/BunScalaJSModule.scala | 94 ++++++++++- .../test/src/mill/bun/BunManifestTests.scala | 155 ++++++++++++++++++ 3 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 millbun/src/mill/bun/BunManifest.scala create mode 100644 millbun/test/src/mill/bun/BunManifestTests.scala diff --git a/millbun/src/mill/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala new file mode 100644 index 0000000..7dc6446 --- /dev/null +++ b/millbun/src/mill/bun/BunManifest.scala @@ -0,0 +1,106 @@ +package mill.bun + +import java.util.jar.JarFile + +/** Bun dependency manifest embedded in published JARs. + * + * When a Scala.js library declares JS package dependencies via `npmDeps`, + * this manifest is generated and included in the JAR at publish time. + * Consumer builds scan classpath JARs for these manifests and merge them + * into their `package.json`, making JS deps transitive — just like + * Coursier resolves JVM deps from POMs. + * + * Layout inside JAR: + * {{{ + * META-INF/bun/bun-dependencies.json — dependency manifest + * META-INF/bun/bun.lock — lockfile (optional, for deterministic resolution) + * }}} + */ +final case class BunManifest( + dependencies: Map[String, String], + devDependencies: Map[String, String], + optionalDependencies: Map[String, String] +) + +object BunManifest: + val ManifestPath = "META-INF/bun/bun-dependencies.json" + val LockfilePath = "META-INF/bun/bun.lock" + + val empty: BunManifest = BunManifest(Map.empty, Map.empty, Map.empty) + + /** Serialize manifest to JSON. */ + def toJson(manifest: BunManifest): ujson.Obj = + 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))) + ) + if manifest.optionalDependencies.nonEmpty then + obj("optionalDependencies") = ujson.Obj.from( + manifest.optionalDependencies.map((k, v) => k -> ujson.Str(v)) + ) + obj + + /** Deserialize manifest from JSON. */ + def fromJson(json: ujson.Value): BunManifest = + val obj = json.obj + def readDeps(key: String): Map[String, String] = + obj.get(key).map(_.obj.map((k, v) => k -> v.str).toMap).getOrElse(Map.empty) + BunManifest( + dependencies = readDeps("dependencies"), + devDependencies = readDeps("devDependencies"), + optionalDependencies = readDeps("optionalDependencies") + ) + + /** Read a manifest from inside a JAR file. Returns None if no manifest is present. */ + def readFromJar(jarPath: os.Path): Option[BunManifest] = + if !os.exists(jarPath) then return None + val jar = new JarFile(jarPath.toIO) + try + val entry = jar.getEntry(ManifestPath) + if entry == null then None + else + 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 + else None + + /** Extract the embedded lockfile from a JAR to a destination directory. + * Returns the path to the extracted lockfile, or None if no lockfile is embedded. + */ + def extractLockfile(jarPath: os.Path, destDir: os.Path): Option[os.Path] = + if !os.exists(jarPath) then return None + val jar = new JarFile(jarPath.toIO) + try + val entry = jar.getEntry(LockfilePath) + if entry == null then None + else + // Use JAR filename to namespace lockfiles from different libraries + val namespace = jarPath.last.stripSuffix(".jar") + val dest = destDir / namespace / "bun.lock" + os.makeDir.all(dest / os.up) + val is = jar.getInputStream(entry) + try + os.write.over(dest, is) + Some(dest) + finally is.close() + catch case _: Exception => None + finally jar.close() + + /** Merge multiple manifests into one. Later entries override earlier ones for the same package. */ + 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 + ) + } diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 34ad939..e2092eb 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -4,7 +4,7 @@ package bun import mill.* import mill.api.BuildCtx import mill.api.JsonFormatters.given -import mill.bun.BunToolchainModule +import mill.bun.{BunManifest, BunToolchainModule} import mill.javalib.JavaModule import mill.scalajslib.* import mill.scalajslib.api.* @@ -47,17 +47,94 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } def transitiveNpmDeps: T[Seq[String]] = Task { - Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten ++ npmDeps() + val moduleDeps = Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten + val jarDeps = classpathBunDeps() + moduleDeps ++ jarDeps ++ npmDeps() } def transitiveNpmDevDeps: T[Seq[String]] = Task { - Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten ++ npmDevDeps() + val moduleDeps = Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten + val jarDevDeps = classpathBunDevDeps() + moduleDeps ++ jarDevDeps ++ npmDevDeps() } def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task { Task.traverse(recursiveBunModuleDeps)(_.unmanagedDeps)().flatten ++ unmanagedDeps() } + /** Optional JS packages — installed if available, not fatal if missing. */ + def bunOptionalDeps: T[Seq[String]] = Task { Seq.empty } + + // --------------------------------------------------------------------------- + // Classpath manifest scanning — reads bun-dependencies.json from dependency JARs + // --------------------------------------------------------------------------- + + /** Scan classpath JARs for embedded bun dependency manifests. */ + def classpathBunDeps: T[Seq[String]] = Task { + classpathBunManifests().flatMap(_.dependencies).map { case (name, version) => s"$name@$version" } + } + + /** Scan classpath JARs for embedded bun dev-dependency manifests. */ + def classpathBunDevDeps: T[Seq[String]] = Task { + classpathBunManifests().flatMap(_.devDependencies).map { case (name, version) => s"$name@$version" } + } + + private def classpathBunManifests: Task[Seq[BunManifest]] = Task.Anon { + runClasspath().flatMap { ref => + val path = ref.path + if os.exists(path) && path.ext == "jar" then BunManifest.readFromJar(path).toSeq + else if os.isDir(path) then BunManifest.readFromDir(path).toSeq + else Nil + } + } + + /** Extract embedded lockfiles from classpath JARs for deterministic resolution. */ + def classpathBunLockfiles: T[Seq[PathRef]] = Task { + runClasspath().flatMap { ref => + val path = ref.path + if os.exists(path) && path.ext == "jar" then + BunManifest.extractLockfile(path, Task.dest).map(PathRef(_)).toSeq + else Nil + } + } + + // --------------------------------------------------------------------------- + // Manifest generation — for embedding in published JARs + // --------------------------------------------------------------------------- + + /** Generate bun dependency manifest + lockfile for inclusion in published JARs. + * + * The manifest declares this library's JS package requirements so that + * consumers automatically get them via `classpathBunDeps`. The lockfile + * is embedded alongside for deterministic resolution seeding. + */ + def bunDependencyManifest: T[PathRef] = Task { + val deps = npmDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val devDeps = npmDevDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val manifest = BunManifest(deps, devDeps, optDeps) + val metaDir = Task.dest / "META-INF" / "bun" + os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) + + // Embed the lockfile if available + val installDir = bunInstall().path + bunLockfiles().foreach { name => + val lockfile = installDir / name + if os.exists(lockfile) then os.copy.over(lockfile, metaDir / name, createFolders = true) + } + + PathRef(Task.dest) + } + + /** Resource paths that include the bun dependency manifest. + * Libraries should include this in their `resources` to ship manifests in published JARs. + */ + def bunDependencyManifestResources: T[Seq[PathRef]] = Task { + if npmDeps().nonEmpty || npmDevDeps().nonEmpty || bunOptionalDeps().nonEmpty then + Seq(bunDependencyManifest()) + else Seq.empty + } + /** Extra package.json fields not modeled by this scaffold. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } @@ -100,6 +177,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out private def mkBunPackageJson: Task[Unit] = Task.Anon { val dest = Task.dest + val allOptional = bunOptionalDeps().map(BunToolchainModule.splitDep) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, @@ -107,6 +185,8 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out "dependencies" -> ujson.Obj.from(transitiveNpmDeps().map(BunToolchainModule.splitDep)), "devDependencies" -> ujson.Obj.from(transitiveNpmDevDeps().map(BunToolchainModule.splitDep)) ) + if allOptional.nonEmpty then + base("optionalDependencies") = ujson.Obj.from(allOptional) val packageType = moduleKind() match { @@ -132,6 +212,14 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out mkBunPackageJson() + // Seed lockfile from classpath JARs if no local lockfile exists yet. + // This gives bun a known-good resolution baseline from library authors. + val localLockExists = bunLockfiles().exists(name => os.exists(dest / name)) + if !localLockExists then + classpathBunLockfiles().headOption.foreach { ref => + os.copy.over(ref.path, dest / "bun.lock", createFolders = true) + } + runBun( bunExecutable(), Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), diff --git a/millbun/test/src/mill/bun/BunManifestTests.scala b/millbun/test/src/mill/bun/BunManifestTests.scala new file mode 100644 index 0000000..756d2e4 --- /dev/null +++ b/millbun/test/src/mill/bun/BunManifestTests.scala @@ -0,0 +1,155 @@ +package mill.bun + +import utest._ + +object BunManifestTests extends TestSuite { + def tests: Tests = Tests { + + test("empty manifest serialization") { + val json = BunManifest.toJson(BunManifest.empty) + val parsed = BunManifest.fromJson(json) + assert(parsed.dependencies.isEmpty) + assert(parsed.devDependencies.isEmpty) + assert(parsed.optionalDependencies.isEmpty) + } + + test("round-trip with 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 + ) + val json = BunManifest.toJson(manifest) + val parsed = BunManifest.fromJson(json) + assert(parsed.dependencies == manifest.dependencies) + assert(parsed.devDependencies == manifest.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") + ) + val json = BunManifest.toJson(manifest) + val parsed = BunManifest.fromJson(json) + assert(parsed.optionalDependencies == manifest.optionalDependencies) + } + + 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.devDependencies.isEmpty) + assert(parsed.optionalDependencies.isEmpty) + } + + test("merge combines manifests") { + val m1 = BunManifest( + Map("react" -> "^19.0.0"), + Map("typescript" -> "^5.0.0"), + Map.empty + ) + val m2 = BunManifest( + Map("zod" -> "^4.0.0"), + Map.empty, + Map("lodash" -> "^4.17.0") + ) + 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.optionalDependencies == Map("lodash" -> "^4.17.0")) + } + + test("merge later entries override earlier") { + 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") + } + + test("readFromDir returns None for missing directory") { + val result = BunManifest.readFromDir(os.temp.dir() / "nonexistent") + assert(result.isEmpty) + } + + test("readFromDir reads manifest from unpacked classes") { + val dir = os.temp.dir() + val metaDir = dir / "META-INF" / "bun" + os.makeDir.all(metaDir) + val manifest = BunManifest(Map("react" -> "^19.0.0"), Map.empty, Map.empty) + os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render()) + val result = BunManifest.readFromDir(dir) + assert(result.isDefined) + assert(result.get.dependencies("react") == "^19.0.0") + } + + test("JAR round-trip: write manifest and lockfile, read back") { + val tmpDir = os.temp.dir() + + // Create a JAR with manifest and lockfile + val jarPath = tmpDir / "test-lib.jar" + val manifestContent = BunManifest.toJson( + BunManifest(Map("react" -> "^19.0.0"), Map.empty, Map.empty) + ).render() + val lockContent = "# bun lockfile\nreact@^19.0.0: resolved=19.1.0" + + val jarOut = new java.util.jar.JarOutputStream( + new java.io.FileOutputStream(jarPath.toIO) + ) + try { + // Write manifest entry + jarOut.putNextEntry(new java.util.jar.JarEntry(BunManifest.ManifestPath)) + jarOut.write(manifestContent.getBytes("UTF-8")) + jarOut.closeEntry() + + // Write lockfile entry + jarOut.putNextEntry(new java.util.jar.JarEntry(BunManifest.LockfilePath)) + jarOut.write(lockContent.getBytes("UTF-8")) + jarOut.closeEntry() + } finally jarOut.close() + + // Read manifest back + val manifest = BunManifest.readFromJar(jarPath) + assert(manifest.isDefined) + assert(manifest.get.dependencies("react") == "^19.0.0") + + // Extract lockfile + val extractDir = tmpDir / "extract" + os.makeDir.all(extractDir) + val lockfile = BunManifest.extractLockfile(jarPath, extractDir) + assert(lockfile.isDefined) + assert(os.exists(lockfile.get)) + val content = os.read(lockfile.get) + assert(content.contains("react")) + } + + test("readFromJar returns None for JAR without manifest") { + val tmpDir = os.temp.dir() + val jarPath = tmpDir / "empty-lib.jar" + + val jarOut = new java.util.jar.JarOutputStream( + new java.io.FileOutputStream(jarPath.toIO) + ) + try { + jarOut.putNextEntry(new java.util.jar.JarEntry("com/example/Foo.class")) + jarOut.write("fake class".getBytes("UTF-8")) + jarOut.closeEntry() + } finally jarOut.close() + + val manifest = BunManifest.readFromJar(jarPath) + assert(manifest.isEmpty) + + val lockfile = BunManifest.extractLockfile(jarPath, tmpDir / "extract") + assert(lockfile.isEmpty) + } + + test("readFromJar returns None for nonexistent path") { + val result = BunManifest.readFromJar(os.Path("/nonexistent/lib.jar")) + assert(result.isEmpty) + } + } +} From 0ab6709f9afa298600cac774e4722c6fb2e9eaf8 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Fri, 3 Apr 2026 16:53:47 -0400 Subject: [PATCH 2/5] feat: bun"..." string interpolator, bunDeps alias, auto-resource inclusion - bun"pkg@version" compile-time validated string interpolator validates package name format at compile time, returns plain String - bunDeps / bunDevDeps as primary API (npmDeps delegates to bunDeps) - bunOptionalDeps for packages that won't fail if unavailable - Auto-resource inclusion: resources automatically includes the bun dependency manifest when bunDeps is non-empty. Zero manual wiring. - 8 new interpolator tests, 30 total tests passing Library authors just declare deps: def bunDeps = Task { Seq(bun"react@^19.0.0") } // Manifest + lockfile auto-embedded in JAR. Consumers get them free. Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunDep.scala | 67 +++++++++++++++++++ .../scalajslib/bun/BunScalaJSModule.scala | 45 +++++++++++-- millbun/test/src/mill/bun/BunDepTests.scala | 59 ++++++++++++++++ 3 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 millbun/src/mill/bun/BunDep.scala create mode 100644 millbun/test/src/mill/bun/BunDepTests.scala diff --git a/millbun/src/mill/bun/BunDep.scala b/millbun/src/mill/bun/BunDep.scala new file mode 100644 index 0000000..8aca3a5 --- /dev/null +++ b/millbun/src/mill/bun/BunDep.scala @@ -0,0 +1,67 @@ +package mill.bun + +/** String interpolator for Bun package dependencies. + * + * Validates the `name@version` format at compile time: + * {{{ + * import mill.bun.bun + * + * bun"react@^19.0.0" // ok + * bun"@anthropic-ai/claude-agent-sdk@^0.2.90" // ok (scoped) + * bun"zod@^4.0.0" // ok + * bun"react" // ok (latest) + * bun"" // compile error + * }}} + * + * Returns a plain `String` so it's fully backward compatible with + * `npmDeps` / `bunDeps` declarations. + */ +extension (sc: StringContext) + inline def bun(inline args: Any*): String = + ${ BunDepMacro.validateImpl('sc, 'args) } + +private object BunDepMacro: + import scala.quoted.* + + def validateImpl(sc: Expr[StringContext], args: Expr[Seq[Any]])(using Quotes): Expr[String] = + import quotes.reflect.* + + sc match + case '{ StringContext(${ Varargs(parts) }*) } => + // For a no-interpolation literal like bun"react@19.0.0", parts has 1 element + parts match + case Seq(Expr(literal: String)) if args.matches('{ Seq() }) || args.matches('{ Nil }) => + validateLiteral(literal) + Expr(literal) + case _ => + // Has interpolated parts — build at runtime, skip compile-time validation + '{ $sc.s($args*) } + case _ => + // Inside another macro (e.g., utest's Tests{}) the pattern may not match. + // Fall back to runtime string construction. + '{ $sc.s($args*) } + + 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 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 diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index e2092eb..784cb8c 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -13,11 +13,34 @@ import mill.scalajslib.config.ScalaJSConfigModule import scala.annotation.tailrec trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { outer => - /** JS packages needed by linked Scala.js output, e.g. packages referenced by @JSImport. */ - def npmDeps: T[Seq[String]] = Task { Seq.empty } + /** JS packages needed by linked Scala.js output, e.g. packages referenced by @JSImport. + * + * Preferred: use `bunDeps` with the `bun"pkg@version"` interpolator. + * This alias exists for backward compatibility. + */ + def npmDeps: T[Seq[String]] = Task { bunDeps() } + + /** Dev-only JS packages for bundling or local tooling. + * + * Preferred: use `bunDevDeps` with the `bun"pkg@version"` interpolator. + * This alias exists for backward compatibility. + */ + def npmDevDeps: T[Seq[String]] = Task { bunDevDeps() } + + /** JS packages needed by linked Scala.js output. + * + * Use the `bun"pkg@version"` string interpolator for compile-time validation: + * {{{ + * def bunDeps = Task { Seq( + * bun"@anthropic-ai/claude-agent-sdk@^0.2.90", + * bun"zod@^4.0.0" + * )} + * }}} + */ + def bunDeps: T[Seq[String]] = Task { Seq.empty } /** Dev-only JS packages for bundling or local tooling. */ - def npmDevDeps: T[Seq[String]] = Task { Seq.empty } + def bunDevDeps: T[Seq[String]] = Task { Seq.empty } /** Local tarballs / package directories. */ def unmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } @@ -127,14 +150,26 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } /** Resource paths that include the bun dependency manifest. - * Libraries should include this in their `resources` to ship manifests in published JARs. + * + * Automatically appended to `resources` — library authors don't need to + * wire this manually. When `bunDeps`/`npmDeps` is non-empty, the manifest + * and lockfile are embedded in the published JAR. */ def bunDependencyManifestResources: T[Seq[PathRef]] = Task { - if npmDeps().nonEmpty || npmDevDeps().nonEmpty || bunOptionalDeps().nonEmpty then + if npmDeps().nonEmpty || bunOptionalDeps().nonEmpty then Seq(bunDependencyManifest()) else Seq.empty } + /** Auto-include bun dependency manifest in JAR resources. + * + * No manual wiring needed. If this module declares `bunDeps` / `npmDeps`, + * the manifest and lockfile are automatically embedded when the JAR is built. + */ + override def resources: T[Seq[PathRef]] = Task { + super.resources() ++ bunDependencyManifestResources() + } + /** Extra package.json fields not modeled by this scaffold. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } diff --git a/millbun/test/src/mill/bun/BunDepTests.scala b/millbun/test/src/mill/bun/BunDepTests.scala new file mode 100644 index 0000000..94ae94a --- /dev/null +++ b/millbun/test/src/mill/bun/BunDepTests.scala @@ -0,0 +1,59 @@ +package mill.bun + +import utest._ + +object BunDepTests extends TestSuite { + def tests: Tests = Tests { + + test("simple package with version") { + val dep = bun"react@^19.0.0" + assert(dep == "react@^19.0.0") + } + + test("scoped package with version") { + val dep = bun"@anthropic-ai/claude-agent-sdk@^0.2.90" + assert(dep == "@anthropic-ai/claude-agent-sdk@^0.2.90") + } + + test("package without version (latest)") { + val dep = bun"zod" + assert(dep == "zod") + } + + test("scoped package without version") { + val dep = bun"@types/node" + assert(dep == "@types/node") + } + + test("package with exact version") { + val dep = bun"react@19.1.1" + assert(dep == "react@19.1.1") + } + + test("package with tilde range") { + val dep = bun"lodash@~4.17.0" + assert(dep == "lodash@~4.17.0") + } + + test("returns plain String type") { + val dep: String = bun"react@19.0.0" + assert(dep.isInstanceOf[String]) + } + + test("works in Seq for bunDeps") { + val deps: Seq[String] = Seq( + bun"@anthropic-ai/claude-agent-sdk@^0.2.90", + bun"@openai/codex-sdk@^0.118.0", + bun"zod@^4.0.0" + ) + assert(deps.length == 3) + assert(deps.head.startsWith("@anthropic-ai")) + } + + // Note: compile-time validation errors can't be tested at runtime. + // The following would fail to compile: + // bun"" // empty + // bun"@/bad" // empty name after scope + // bun"@noSlash" // missing slash in scoped name + } +} From 11dc9afe5755ae243a86a27a5b3430e6fb7c927c Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 6 Apr 2026 17:03:54 -0400 Subject: [PATCH 3/5] fix: decouple npmDeps/bunDeps and make manifest embedding opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npmDeps and bunDeps are now independent (both default to Seq.empty), merged at the transitive level — eliminates circular delegation footgun - Extract manifest generation into BunPublishModule opt-in trait so existing users don't get unexpected JAR content changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scalajslib/bun/BunPublishModule.scala | 58 +++++++++++++ .../scalajslib/bun/BunScalaJSModule.scala | 83 ++++--------------- 2 files changed, 75 insertions(+), 66 deletions(-) create mode 100644 millbun/src/mill/scalajslib/bun/BunPublishModule.scala diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala new file mode 100644 index 0000000..3c819dd --- /dev/null +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -0,0 +1,58 @@ +package mill.scalajslib +package bun + +import mill.* +import mill.bun.{BunManifest, BunToolchainModule} + +/** Opt-in trait for Scala.js libraries that publish JARs with embedded bun dependency manifests. + * + * Mix this into modules whose JARs should carry `META-INF/bun/bun-dependencies.json` + * so that consumers automatically resolve JS package dependencies via `classpathBunDeps`. + * + * {{{ + * object myLib extends BunScalaJSModule with BunPublishModule { + * def bunDeps = Task { Seq(bun"react@^19.0.0") } + * } + * }}} + */ +trait BunPublishModule extends BunScalaJSModule { + + /** Generate bun dependency manifest + lockfile for inclusion in published JARs. + * + * The manifest declares this library's JS package requirements so that + * consumers automatically get them via `classpathBunDeps`. The lockfile + * is embedded alongside for deterministic resolution seeding. + */ + def bunDependencyManifest: T[PathRef] = Task { + val allDeps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val allDevDeps = (npmDevDeps() ++ bunDevDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val manifest = BunManifest(allDeps, allDevDeps, optDeps) + val metaDir = Task.dest / "META-INF" / "bun" + os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) + + // Embed the lockfile if available + val installDir = bunInstall().path + bunLockfiles().foreach { name => + val lockfile = installDir / name + if os.exists(lockfile) then os.copy.over(lockfile, metaDir / name, createFolders = true) + } + + PathRef(Task.dest) + } + + /** Resource paths that include the bun dependency manifest. + * + * When this module declares any JS deps, the manifest and lockfile + * are embedded in the published JAR. + */ + def bunDependencyManifestResources: T[Seq[PathRef]] = Task { + if npmDeps().nonEmpty || bunDeps().nonEmpty || bunOptionalDeps().nonEmpty then + Seq(bunDependencyManifest()) + else Seq.empty + } + + override def resources: T[Seq[PathRef]] = Task { + super.resources() ++ bunDependencyManifestResources() + } +} diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 784cb8c..5fbfadb 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -13,19 +13,11 @@ import mill.scalajslib.config.ScalaJSConfigModule import scala.annotation.tailrec trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { outer => - /** JS packages needed by linked Scala.js output, e.g. packages referenced by @JSImport. - * - * Preferred: use `bunDeps` with the `bun"pkg@version"` interpolator. - * This alias exists for backward compatibility. - */ - def npmDeps: T[Seq[String]] = Task { bunDeps() } + /** JS packages needed by linked Scala.js output, e.g. packages referenced by @JSImport. */ + def npmDeps: T[Seq[String]] = Task { Seq.empty } - /** Dev-only JS packages for bundling or local tooling. - * - * Preferred: use `bunDevDeps` with the `bun"pkg@version"` interpolator. - * This alias exists for backward compatibility. - */ - def npmDevDeps: T[Seq[String]] = Task { bunDevDeps() } + /** Dev-only JS packages for bundling or local tooling. */ + def npmDevDeps: T[Seq[String]] = Task { Seq.empty } /** JS packages needed by linked Scala.js output. * @@ -36,10 +28,16 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out * bun"zod@^4.0.0" * )} * }}} + * + * Both `bunDeps` and `npmDeps` are merged into `transitiveNpmDeps` — + * use whichever you prefer. They are independent (no delegation). */ def bunDeps: T[Seq[String]] = Task { Seq.empty } - /** Dev-only JS packages for bundling or local tooling. */ + /** Dev-only JS packages for bundling or local tooling. + * + * Independent of `npmDevDeps` — both are merged into `transitiveNpmDevDeps`. + */ def bunDevDeps: T[Seq[String]] = Task { Seq.empty } /** Local tarballs / package directories. */ @@ -70,15 +68,17 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } def transitiveNpmDeps: T[Seq[String]] = Task { - val moduleDeps = Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten + val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten + val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDeps)().flatten val jarDeps = classpathBunDeps() - moduleDeps ++ jarDeps ++ npmDeps() + moduleNpm ++ moduleBun ++ jarDeps ++ npmDeps() ++ bunDeps() } def transitiveNpmDevDeps: T[Seq[String]] = Task { - val moduleDeps = Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten + val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten + val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDevDeps)().flatten val jarDevDeps = classpathBunDevDeps() - moduleDeps ++ jarDevDeps ++ npmDevDeps() + moduleNpm ++ moduleBun ++ jarDevDeps ++ npmDevDeps() ++ bunDevDeps() } def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task { @@ -121,55 +121,6 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } } - // --------------------------------------------------------------------------- - // Manifest generation — for embedding in published JARs - // --------------------------------------------------------------------------- - - /** Generate bun dependency manifest + lockfile for inclusion in published JARs. - * - * The manifest declares this library's JS package requirements so that - * consumers automatically get them via `classpathBunDeps`. The lockfile - * is embedded alongside for deterministic resolution seeding. - */ - def bunDependencyManifest: T[PathRef] = Task { - val deps = npmDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val devDeps = npmDevDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val manifest = BunManifest(deps, devDeps, optDeps) - val metaDir = Task.dest / "META-INF" / "bun" - os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) - - // Embed the lockfile if available - val installDir = bunInstall().path - bunLockfiles().foreach { name => - val lockfile = installDir / name - if os.exists(lockfile) then os.copy.over(lockfile, metaDir / name, createFolders = true) - } - - PathRef(Task.dest) - } - - /** Resource paths that include the bun dependency manifest. - * - * Automatically appended to `resources` — library authors don't need to - * wire this manually. When `bunDeps`/`npmDeps` is non-empty, the manifest - * and lockfile are embedded in the published JAR. - */ - def bunDependencyManifestResources: T[Seq[PathRef]] = Task { - if npmDeps().nonEmpty || bunOptionalDeps().nonEmpty then - Seq(bunDependencyManifest()) - else Seq.empty - } - - /** Auto-include bun dependency manifest in JAR resources. - * - * No manual wiring needed. If this module declares `bunDeps` / `npmDeps`, - * the manifest and lockfile are automatically embedded when the JAR is built. - */ - override def resources: T[Seq[PathRef]] = Task { - super.resources() ++ bunDependencyManifestResources() - } - /** Extra package.json fields not modeled by this scaffold. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } From 96281ff996652b70deb949a9cb87591966a87432 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 6 Apr 2026 17:43:22 -0400 Subject: [PATCH 4/5] refactor: remove lockfile embedding from JAR manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lockfiles are application-level artifacts, not library-level. Embedding them in JARs had fundamental issues: no merge strategy across libraries, arbitrary winner selection, and bun.lockb never actually extracted. Strip lockfile embedding from BunPublishModule, classpath lockfile scanning, and seeding logic from bunInstall. The manifest system (bun-dependencies.json) remains — it correctly handles transitive dep discovery. Project-level lockfile management will follow in a separate PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunManifest.scala | 24 ------------------- .../scalajslib/bun/BunPublishModule.scala | 8 ------- .../scalajslib/bun/BunScalaJSModule.scala | 18 -------------- .../test/src/mill/bun/BunManifestTests.scala | 23 +----------------- 4 files changed, 1 insertion(+), 72 deletions(-) diff --git a/millbun/src/mill/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala index 7dc6446..2e8680b 100644 --- a/millbun/src/mill/bun/BunManifest.scala +++ b/millbun/src/mill/bun/BunManifest.scala @@ -13,7 +13,6 @@ import java.util.jar.JarFile * Layout inside JAR: * {{{ * META-INF/bun/bun-dependencies.json — dependency manifest - * META-INF/bun/bun.lock — lockfile (optional, for deterministic resolution) * }}} */ final case class BunManifest( @@ -24,7 +23,6 @@ final case class BunManifest( object BunManifest: val ManifestPath = "META-INF/bun/bun-dependencies.json" - val LockfilePath = "META-INF/bun/bun.lock" val empty: BunManifest = BunManifest(Map.empty, Map.empty, Map.empty) @@ -73,28 +71,6 @@ object BunManifest: catch case _: Exception => None else None - /** Extract the embedded lockfile from a JAR to a destination directory. - * Returns the path to the extracted lockfile, or None if no lockfile is embedded. - */ - def extractLockfile(jarPath: os.Path, destDir: os.Path): Option[os.Path] = - if !os.exists(jarPath) then return None - val jar = new JarFile(jarPath.toIO) - try - val entry = jar.getEntry(LockfilePath) - if entry == null then None - else - // Use JAR filename to namespace lockfiles from different libraries - val namespace = jarPath.last.stripSuffix(".jar") - val dest = destDir / namespace / "bun.lock" - os.makeDir.all(dest / os.up) - val is = jar.getInputStream(entry) - try - os.write.over(dest, is) - Some(dest) - finally is.close() - catch case _: Exception => None - finally jar.close() - /** Merge multiple manifests into one. Later entries override earlier ones for the same package. */ def merge(manifests: Seq[BunManifest]): BunManifest = manifests.foldLeft(empty) { (acc, m) => diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 3c819dd..14dc55d 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -30,14 +30,6 @@ trait BunPublishModule extends BunScalaJSModule { val manifest = BunManifest(allDeps, allDevDeps, optDeps) val metaDir = Task.dest / "META-INF" / "bun" os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) - - // Embed the lockfile if available - val installDir = bunInstall().path - bunLockfiles().foreach { name => - val lockfile = installDir / name - if os.exists(lockfile) then os.copy.over(lockfile, metaDir / name, createFolders = true) - } - PathRef(Task.dest) } diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 5fbfadb..e09d81e 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -111,16 +111,6 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } } - /** Extract embedded lockfiles from classpath JARs for deterministic resolution. */ - def classpathBunLockfiles: T[Seq[PathRef]] = Task { - runClasspath().flatMap { ref => - val path = ref.path - if os.exists(path) && path.ext == "jar" then - BunManifest.extractLockfile(path, Task.dest).map(PathRef(_)).toSeq - else Nil - } - } - /** Extra package.json fields not modeled by this scaffold. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } @@ -198,14 +188,6 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out mkBunPackageJson() - // Seed lockfile from classpath JARs if no local lockfile exists yet. - // This gives bun a known-good resolution baseline from library authors. - val localLockExists = bunLockfiles().exists(name => os.exists(dest / name)) - if !localLockExists then - classpathBunLockfiles().headOption.foreach { ref => - os.copy.over(ref.path, dest / "bun.lock", createFolders = true) - } - runBun( bunExecutable(), Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), diff --git a/millbun/test/src/mill/bun/BunManifestTests.scala b/millbun/test/src/mill/bun/BunManifestTests.scala index 756d2e4..41d1e4a 100644 --- a/millbun/test/src/mill/bun/BunManifestTests.scala +++ b/millbun/test/src/mill/bun/BunManifestTests.scala @@ -87,44 +87,26 @@ object BunManifestTests extends TestSuite { assert(result.get.dependencies("react") == "^19.0.0") } - test("JAR round-trip: write manifest and lockfile, read back") { + test("JAR round-trip: write manifest, read back") { val tmpDir = os.temp.dir() - // Create a JAR with manifest and lockfile val jarPath = tmpDir / "test-lib.jar" val manifestContent = BunManifest.toJson( BunManifest(Map("react" -> "^19.0.0"), Map.empty, Map.empty) ).render() - val lockContent = "# bun lockfile\nreact@^19.0.0: resolved=19.1.0" val jarOut = new java.util.jar.JarOutputStream( new java.io.FileOutputStream(jarPath.toIO) ) try { - // Write manifest entry jarOut.putNextEntry(new java.util.jar.JarEntry(BunManifest.ManifestPath)) jarOut.write(manifestContent.getBytes("UTF-8")) jarOut.closeEntry() - - // Write lockfile entry - jarOut.putNextEntry(new java.util.jar.JarEntry(BunManifest.LockfilePath)) - jarOut.write(lockContent.getBytes("UTF-8")) - jarOut.closeEntry() } finally jarOut.close() - // Read manifest back val manifest = BunManifest.readFromJar(jarPath) assert(manifest.isDefined) assert(manifest.get.dependencies("react") == "^19.0.0") - - // Extract lockfile - val extractDir = tmpDir / "extract" - os.makeDir.all(extractDir) - val lockfile = BunManifest.extractLockfile(jarPath, extractDir) - assert(lockfile.isDefined) - assert(os.exists(lockfile.get)) - val content = os.read(lockfile.get) - assert(content.contains("react")) } test("readFromJar returns None for JAR without manifest") { @@ -142,9 +124,6 @@ object BunManifestTests extends TestSuite { val manifest = BunManifest.readFromJar(jarPath) assert(manifest.isEmpty) - - val lockfile = BunManifest.extractLockfile(jarPath, tmpDir / "extract") - assert(lockfile.isEmpty) } test("readFromJar returns None for nonexistent path") { From 8fef2ce0c35464803390ad5b123b1d78f6325b7f Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 6 Apr 2026 18:10:11 -0400 Subject: [PATCH 5/5] feat: transitive optional deps and symmetric classpath scanning - Add classpathBunOptionalDeps + transitiveBunOptionalDeps so optional deps flow through JAR manifests like regular and dev deps - mkBunPackageJson uses transitive optional deps instead of local-only - BunPublishModule triggers manifest on any dep type (including devDeps) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mill/scalajslib/bun/BunPublishModule.scala | 9 ++++++--- .../src/mill/scalajslib/bun/BunScalaJSModule.scala | 13 ++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 14dc55d..b5f49a3 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -35,11 +35,14 @@ trait BunPublishModule extends BunScalaJSModule { /** Resource paths that include the bun dependency manifest. * - * When this module declares any JS deps, the manifest and lockfile - * are embedded in the published JAR. + * When this module declares any JS deps, the manifest + * is embedded in the published JAR. */ def bunDependencyManifestResources: T[Seq[PathRef]] = Task { - if npmDeps().nonEmpty || bunDeps().nonEmpty || bunOptionalDeps().nonEmpty then + if npmDeps().nonEmpty || bunDeps().nonEmpty || + npmDevDeps().nonEmpty || bunDevDeps().nonEmpty || + bunOptionalDeps().nonEmpty + 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 e09d81e..3ac29ff 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -102,6 +102,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out classpathBunManifests().flatMap(_.devDependencies).map { case (name, version) => s"$name@$version" } } + /** Scan classpath JARs for embedded bun optional-dependency manifests. */ + def classpathBunOptionalDeps: T[Seq[String]] = Task { + classpathBunManifests().flatMap(_.optionalDependencies).map { case (name, version) => s"$name@$version" } + } + private def classpathBunManifests: Task[Seq[BunManifest]] = Task.Anon { runClasspath().flatMap { ref => val path = ref.path @@ -151,9 +156,15 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out if (name.nonEmpty) name.split('.').last.replace('.', '-') else "app" } + def transitiveBunOptionalDeps: T[Seq[String]] = Task { + val moduleOptional = Task.traverse(recursiveBunModuleDeps)(_.bunOptionalDeps)().flatten + val jarOptional = classpathBunOptionalDeps() + moduleOptional ++ jarOptional ++ bunOptionalDeps() + } + private def mkBunPackageJson: Task[Unit] = Task.Anon { val dest = Task.dest - val allOptional = bunOptionalDeps().map(BunToolchainModule.splitDep) + val allOptional = transitiveBunOptionalDeps().map(BunToolchainModule.splitDep) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true,