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/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala new file mode 100644 index 0000000..2e8680b --- /dev/null +++ b/millbun/src/mill/bun/BunManifest.scala @@ -0,0 +1,82 @@ +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 + * }}} + */ +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 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 + + /** 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/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala new file mode 100644 index 0000000..b5f49a3 --- /dev/null +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -0,0 +1,53 @@ +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) + PathRef(Task.dest) + } + + /** Resource paths that include the bun dependency manifest. + * + * 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 || + npmDevDeps().nonEmpty || bunDevDeps().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 34ad939..3ac29ff 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.* @@ -19,6 +19,27 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out /** 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. + * + * 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" + * )} + * }}} + * + * 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. + * + * Independent of `npmDevDeps` — both are merged into `transitiveNpmDevDeps`. + */ + def bunDevDeps: T[Seq[String]] = Task { Seq.empty } + /** Local tarballs / package directories. */ def unmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } @@ -47,17 +68,54 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } def transitiveNpmDeps: T[Seq[String]] = Task { - Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten ++ npmDeps() + val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten + val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDeps)().flatten + val jarDeps = classpathBunDeps() + moduleNpm ++ moduleBun ++ jarDeps ++ npmDeps() ++ bunDeps() } def transitiveNpmDevDeps: T[Seq[String]] = Task { - Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten ++ npmDevDeps() + val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten + val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDevDeps)().flatten + val jarDevDeps = classpathBunDevDeps() + moduleNpm ++ moduleBun ++ jarDevDeps ++ npmDevDeps() ++ bunDevDeps() } 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" } + } + + /** 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 + if os.exists(path) && path.ext == "jar" then BunManifest.readFromJar(path).toSeq + else if os.isDir(path) then BunManifest.readFromDir(path).toSeq + else Nil + } + } + /** Extra package.json fields not modeled by this scaffold. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } @@ -98,8 +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 = transitiveBunOptionalDeps().map(BunToolchainModule.splitDep) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, @@ -107,6 +172,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 { 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 + } +} diff --git a/millbun/test/src/mill/bun/BunManifestTests.scala b/millbun/test/src/mill/bun/BunManifestTests.scala new file mode 100644 index 0000000..41d1e4a --- /dev/null +++ b/millbun/test/src/mill/bun/BunManifestTests.scala @@ -0,0 +1,134 @@ +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, read back") { + val tmpDir = os.temp.dir() + + val jarPath = tmpDir / "test-lib.jar" + val manifestContent = BunManifest.toJson( + BunManifest(Map("react" -> "^19.0.0"), Map.empty, Map.empty) + ).render() + + 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(manifestContent.getBytes("UTF-8")) + jarOut.closeEntry() + } finally jarOut.close() + + val manifest = BunManifest.readFromJar(jarPath) + assert(manifest.isDefined) + assert(manifest.get.dependencies("react") == "^19.0.0") + } + + 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) + } + + test("readFromJar returns None for nonexistent path") { + val result = BunManifest.readFromJar(os.Path("/nonexistent/lib.jar")) + assert(result.isEmpty) + } + } +}