From e03febfef5fe57b10170e7270c34d3b4b6845fc0 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 30 Mar 2026 11:18:58 -0400 Subject: [PATCH 1/3] Fix workspace gaps: test deps, bunfig propagation, TSX entrypoints, Windows PATH - Merge test-side npm/tsDeps into devDependencies (not dependencies) in BunTypeScriptTests.npmInstall, matching upstream Mill's test dep handling - Propagate bunfig.toml and .npmrc into compile, bundle, run, and test workspaces on both TypeScript and Scala.js sides - Add .tsx fallback entrypoints (main.tsx, index.tsx) for React-style projects - Make findOnPath respect PATHEXT on Windows (bun.exe, bun.cmd) Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunToolchainModule.scala | 13 +++- .../bun/BunTypeScriptModule.scala | 61 ++++++++++++++++--- .../scalajslib/bun/BunScalaJSModule.scala | 16 ++++- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 3f74c63..fdaf668 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -16,11 +16,20 @@ object BunToolchainModule { (parts(0), ujson.Str(parts.lift(1).getOrElse(""))) } - /** Resolve an executable name from PATH. */ + /** Resolve an executable name from PATH, respecting PATHEXT on Windows. */ def findOnPath(name: String): Option[os.Path] = { val pathDirs = sys.env.getOrElse("PATH", "").split(java.io.File.pathSeparator) + val extensions = sys.env.getOrElse("PATHEXT", "") + .split(java.io.File.pathSeparator) + .filter(_.nonEmpty) + + val candidates = if (extensions.nonEmpty) + Seq(name) ++ extensions.map(ext => name + ext.toLowerCase) + else + Seq(name) + pathDirs.iterator - .map(dir => os.Path(dir) / name) + .flatMap(dir => candidates.iterator.map(c => os.Path(dir) / c)) .find(os.exists(_)) } diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index febc5bd..eb23d73 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -84,15 +84,16 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) } + private def resolvedBunfigs: Task[Seq[PathRef]] = Task.Anon { + bunfigFiles() + } + private def copyBunWorkspaceConfigs: Task[Unit] = Task.Anon { - val dest = Task.dest + // Install workspaces need both .npmrc (registry auth) and bunfig 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) + os.copy.over(npmRc().path, Task.dest / ".npmrc", createFolders = true) } + BunTypeScriptModule.copyBunfigsTo(Task.dest, bunfigFiles()) } private def ensureInstallArtifacts(dest: os.Path, installRoot: os.Path, lockfiles: Seq[String]): Unit = { @@ -137,6 +138,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out tscCopyGenSources() tscLinkResources() ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) + BunTypeScriptModule.copyBunfigsTo(Task.dest, resolvedBunfigs()) mkTsconfig() runBun( @@ -181,9 +183,13 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out val candidates = Seq( configured, compileDir / "src" / "main.ts", + compileDir / "src" / "main.tsx", compileDir / "src" / "index.ts", + compileDir / "src" / "index.tsx", compileDir / "main.ts", - compileDir / "index.ts" + compileDir / "main.tsx", + compileDir / "index.ts", + compileDir / "index.tsx" ).distinct candidates.find(os.exists).getOrElse(configured) @@ -199,6 +205,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out else Task.dest / s"$moduleName.js" BunToolchainModule.copyWorkspace(compileDir, buildDir) + BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) if (bunCompileExecutable()) copyCompileResources(bunCompileResources(), buildDir) val packagesExternal = if (bunBundlePackagesExternal()) Seq("--packages", "external") else Nil @@ -237,6 +244,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out val buildDir = Task.dest / "workspace" val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString BunToolchainModule.copyWorkspace(compileDir, buildDir) + BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) copyCompileResources(bunCompileResources(), buildDir) val packagesExternal = if (bunBundlePackagesExternal()) Seq("--packages", "external") else Nil @@ -286,12 +294,36 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out override def npmInstall: T[PathRef] = Task { val dest = Task.dest os.makeDir.all(dest) - outer.mkBunPackageJson() + + // 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`. + val user = outer.packageJson() + val outerDeps = outer.transitiveNpmDeps().map(BunToolchainModule.splitDep) + val outerDevDeps = (outer.transitiveNpmDevDeps() ++ outer.tsDeps()).map(BunToolchainModule.splitDep) + // 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 resolved = ujson.Obj.from( + user.copy( + name = if (user.name.nonEmpty) user.name else outer.moduleName, + version = if (user.version.nonEmpty) user.version else "1.0.0", + `type` = if (outer.enableEsm()) "module" else user.`type`, + dependencies = ujson.Obj.from(outerDeps), + devDependencies = ujson.Obj.from(outerDevDeps ++ testDevDeps) + ).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) + outer.copyBunWorkspaceConfigs() runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), + Seq("install") ++ bunInstallArgs() ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), cwd = dest, env = outer.bunToolEnv() ) @@ -303,6 +335,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out val dest = Task.dest BunToolchainModule.copyWorkspace(this.compile().path, dest) outer.ensureInstallArtifacts(dest, npmInstall().path, bunLockfiles()) + BunTypeScriptModule.copyBunfigsTo(dest, outer.resolvedBunfigs()) PathRef(dest) } @@ -394,3 +427,13 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out } } } + +object BunTypeScriptModule { + + /** Copy bunfig files into a workspace directory. Does NOT copy .npmrc — that belongs only in install workspaces. */ + def copyBunfigsTo(dest: os.Path, bunfigConfigs: Seq[PathRef]): Unit = { + bunfigConfigs.foreach { cfg => + os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) + } + } +} diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 72b96ad..fb50130 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -141,7 +141,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(dest) } - private def ensureLinkedWorkspace(report: Report, installDir: os.Path, lockfiles: Seq[String]): Unit = { + private def resolvedBunConfigs: Task[Seq[PathRef]] = Task.Anon { + bunfigFiles() + } + + private def ensureLinkedWorkspace(report: Report, installDir: os.Path, lockfiles: Seq[String], bunConfigs: Seq[PathRef]): Unit = { val linkedDir = report.dest.path os.copy.over(installDir / "package.json", linkedDir / "package.json", createFolders = true) @@ -156,6 +160,10 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out os.symlink(linkedDir / name, src) } } + + bunConfigs.foreach { cfg => + os.copy.over(cfg.path, linkedDir / cfg.path.last, createFolders = true) + } } override def jsEnvConfig: T[JsEnvConfig] = Task { @@ -189,7 +197,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out override protected def linkTask(isFullLinkJS: Boolean, forceOutJs: Boolean): Task[Report] = Task.Anon { val linked = super.linkTask(isFullLinkJS, forceOutJs)() - ensureLinkedWorkspace(linked, bunInstall().path, bunLockfiles()) + ensureLinkedWorkspace(linked, bunInstall().path, bunLockfiles(), resolvedBunConfigs()) linked } @@ -281,6 +289,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out val linked = fullLinkJS() 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() @@ -314,6 +323,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out val linked = fullLinkJS() 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 entry = primaryEntrypoint(linked).relativeTo(linked.dest.path).toString @@ -370,7 +380,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out importMap = scalaJSImportMap(), config = linkConfig ).map { linked => - outer.ensureLinkedWorkspace(linked, outer.bunInstall().path, outer.bunLockfiles()) + outer.ensureLinkedWorkspace(linked, outer.bunInstall().path, outer.bunLockfiles(), outer.resolvedBunConfigs()) linked } } From 155beddd673049d57dc523eff32c69aa4eef0c8a Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 30 Mar 2026 12:08:29 -0400 Subject: [PATCH 2/3] Add regression tests for workspace gap fixes - Unit tests: PATHEXT executable candidate generation (4 cases) - Integration test: test-only deps land in devDependencies, not dependencies - Integration test: bunfig.toml propagates to compile workspace - Integration test: TSX entrypoint fallback (.tsx resolved when no .ts entry) - Fix bunfigFiles to use Task.Input for sandbox-safe workspace root access Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/typescript-bunfig/.npmrc | 2 + .../resources/typescript-bunfig/build.mill | 13 +++++ .../resources/typescript-bunfig/bunfig.toml | 3 ++ .../resources/typescript-bunfig/src/main.ts | 1 + .../resources/typescript-test-deps/build.mill | 21 +++++++++ .../typescript-test-deps/src/main.ts | 7 +++ .../typescript-test-deps/test/main.test.ts | 12 +++++ .../resources/typescript-tsx/build.mill | 14 ++++++ .../resources/typescript-tsx/src/helper.ts | 3 ++ .../resources/typescript-tsx/src/main.tsx | 4 ++ .../bun/BunTypeScriptIntegrationTests.scala | 47 +++++++++++++++++++ millbun/src/mill/bun/BunToolchainModule.scala | 22 +++++---- .../mill/bun/ExecutableCandidatesTests.scala | 28 +++++++++++ 13 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 millbun/integration/resources/typescript-bunfig/.npmrc create mode 100644 millbun/integration/resources/typescript-bunfig/build.mill create mode 100644 millbun/integration/resources/typescript-bunfig/bunfig.toml create mode 100644 millbun/integration/resources/typescript-bunfig/src/main.ts create mode 100644 millbun/integration/resources/typescript-test-deps/build.mill create mode 100644 millbun/integration/resources/typescript-test-deps/src/main.ts create mode 100644 millbun/integration/resources/typescript-test-deps/test/main.test.ts create mode 100644 millbun/integration/resources/typescript-tsx/build.mill create mode 100644 millbun/integration/resources/typescript-tsx/src/helper.ts create mode 100644 millbun/integration/resources/typescript-tsx/src/main.tsx create mode 100644 millbun/test/src/mill/bun/ExecutableCandidatesTests.scala diff --git a/millbun/integration/resources/typescript-bunfig/.npmrc b/millbun/integration/resources/typescript-bunfig/.npmrc new file mode 100644 index 0000000..41f8712 --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/.npmrc @@ -0,0 +1,2 @@ +# Marker .npmrc for integration test — should NOT propagate beyond install +registry=https://registry.npmjs.org/ diff --git a/millbun/integration/resources/typescript-bunfig/build.mill b/millbun/integration/resources/typescript-bunfig/build.mill new file mode 100644 index 0000000..cb6ca8f --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/build.mill @@ -0,0 +1,13 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.1.0-SNAPSHOT + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir +} diff --git a/millbun/integration/resources/typescript-bunfig/bunfig.toml b/millbun/integration/resources/typescript-bunfig/bunfig.toml new file mode 100644 index 0000000..5e084d2 --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/bunfig.toml @@ -0,0 +1,3 @@ +# Marker bunfig for integration test — verifies propagation to workspaces +[install] +auto = "disable" diff --git a/millbun/integration/resources/typescript-bunfig/src/main.ts b/millbun/integration/resources/typescript-bunfig/src/main.ts new file mode 100644 index 0000000..3129afc --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/src/main.ts @@ -0,0 +1 @@ +console.log("Hello from bunfig test"); diff --git a/millbun/integration/resources/typescript-test-deps/build.mill b/millbun/integration/resources/typescript-test-deps/build.mill new file mode 100644 index 0000000..f4d234e --- /dev/null +++ b/millbun/integration/resources/typescript-test-deps/build.mill @@ -0,0 +1,21 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.1.0-SNAPSHOT + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + + // 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 + override def npmDeps = Task { Seq("is-odd@3.0.1") } + } +} diff --git a/millbun/integration/resources/typescript-test-deps/src/main.ts b/millbun/integration/resources/typescript-test-deps/src/main.ts new file mode 100644 index 0000000..0e584f3 --- /dev/null +++ b/millbun/integration/resources/typescript-test-deps/src/main.ts @@ -0,0 +1,7 @@ +import isEven from "is-even"; + +export function checkEven(n: number): boolean { + return isEven(n); +} + +console.log(`4 is even: ${checkEven(4)}`); diff --git a/millbun/integration/resources/typescript-test-deps/test/main.test.ts b/millbun/integration/resources/typescript-test-deps/test/main.test.ts new file mode 100644 index 0000000..806fdf2 --- /dev/null +++ b/millbun/integration/resources/typescript-test-deps/test/main.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import isOdd from "is-odd"; +import { checkEven } from "../src/main"; + +test("checkEven", () => { + expect(checkEven(4)).toBe(true); + expect(checkEven(3)).toBe(false); +}); + +test("test-only dep is available", () => { + expect(isOdd(3)).toBe(true); +}); diff --git a/millbun/integration/resources/typescript-tsx/build.mill b/millbun/integration/resources/typescript-tsx/build.mill new file mode 100644 index 0000000..4dd7555 --- /dev/null +++ b/millbun/integration/resources/typescript-tsx/build.mill @@ -0,0 +1,14 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.1.0-SNAPSHOT + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def bunBundleTarget = Task { "bun" } +} diff --git a/millbun/integration/resources/typescript-tsx/src/helper.ts b/millbun/integration/resources/typescript-tsx/src/helper.ts new file mode 100644 index 0000000..2db3ac7 --- /dev/null +++ b/millbun/integration/resources/typescript-tsx/src/helper.ts @@ -0,0 +1,3 @@ +export function label(framework: string): string { + return `Hello from ${framework}`; +} diff --git a/millbun/integration/resources/typescript-tsx/src/main.tsx b/millbun/integration/resources/typescript-tsx/src/main.tsx new file mode 100644 index 0000000..8191d98 --- /dev/null +++ b/millbun/integration/resources/typescript-tsx/src/main.tsx @@ -0,0 +1,4 @@ +// TSX entrypoint — verifies .tsx fallback resolution works with Bun's native JSX. +import { label } from "./helper"; + +console.log(label("TSX")); diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 158ab63..182b134 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -123,6 +123,53 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(betaRun.out.text().trim == "Beta worker") } + test("tsx entrypoint fallback") { + val tester = this.tester("typescript-tsx") + val res = tester.eval("app.run") + assert(res.isSuccess) + + val log = os.read(commandLogPath(tester, "app.run")).trim + assert(log.contains("Hello from TSX")) + } + + test("bunfig propagates to compile workspace") { + val tester = this.tester("typescript-bunfig") + val res = tester.eval("app.compile") + assert(res.isSuccess) + + val installDir = tester.workspacePath / "out" / "app" / "npmInstall.dest" + val compileDir = tester.workspacePath / "out" / "app" / "compile.dest" + + // bunfig should be in the install workspace + assert(os.exists(installDir / "bunfig.toml")) + // bunfig should propagate to compile workspace + assert(os.exists(compileDir / "bunfig.toml")) + } + + test("test deps are devDependencies") { + val tester = this.tester("typescript-test-deps") + + // Outer module should have is-even in dependencies + val outerRes = tester.eval("app.npmInstall") + assert(outerRes.isSuccess) + val outerPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.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") + assert(testRes.isSuccess) + val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "npmInstall.dest" / "package.json")) + assert(testPkg("devDependencies").obj.contains("is-odd")) + assert(!testPkg("dependencies").obj.contains("is-odd")) + // Outer deps should also be present + assert(testPkg("dependencies").obj.contains("is-even")) + + // Tests should actually run (both deps available) + val runRes = tester.eval("app.test.test") + assert(runRes.isSuccess) + } + test("bunEnv") { val tester = this.tester("typescript-env") val res = tester.eval("app.bundle") diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index fdaf668..8551d0f 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -16,17 +16,18 @@ object BunToolchainModule { (parts(0), ujson.Str(parts.lift(1).getOrElse(""))) } + /** Build candidate executable names from a base name and PATHEXT extensions. + * PATHEXT is Windows-specific and always semicolon-delimited regardless of platform. */ + def executableCandidates(name: String, pathExt: String): Seq[String] = { + val extensions = pathExt.split(";").filter(_.nonEmpty) + if (extensions.nonEmpty) Seq(name) ++ extensions.map(ext => name + ext.toLowerCase) + else Seq(name) + } + /** Resolve an executable name from PATH, respecting PATHEXT on Windows. */ def findOnPath(name: String): Option[os.Path] = { val pathDirs = sys.env.getOrElse("PATH", "").split(java.io.File.pathSeparator) - val extensions = sys.env.getOrElse("PATHEXT", "") - .split(java.io.File.pathSeparator) - .filter(_.nonEmpty) - - val candidates = if (extensions.nonEmpty) - Seq(name) ++ extensions.map(ext => name + ext.toLowerCase) - else - Seq(name) + val candidates = executableCandidates(name, sys.env.getOrElse("PATHEXT", "")) pathDirs.iterator .flatMap(dir => candidates.iterator.map(c => os.Path(dir) / c)) @@ -97,8 +98,11 @@ trait BunToolchainModule extends Module { * * Bun works without a bunfig, but copying root configs makes the generated * task workspaces closer to the source workspace. + * + * Declared as Task.Input so Mill's sandbox checker allows reading from the + * workspace root and re-evaluates when the files change. */ - def bunfigFiles: T[Seq[PathRef]] = Task { + def bunfigFiles: T[Seq[PathRef]] = Task.Input { Seq(BuildCtx.workspaceRoot / "bunfig.toml", BuildCtx.workspaceRoot / ".bunfig.toml") .filter(os.exists) .map(PathRef(_)) diff --git a/millbun/test/src/mill/bun/ExecutableCandidatesTests.scala b/millbun/test/src/mill/bun/ExecutableCandidatesTests.scala new file mode 100644 index 0000000..19ac2f0 --- /dev/null +++ b/millbun/test/src/mill/bun/ExecutableCandidatesTests.scala @@ -0,0 +1,28 @@ +package mill.bun + +import utest._ + +object ExecutableCandidatesTests extends TestSuite { + def tests: Tests = Tests { + + test("no PATHEXT returns bare name only") { + val candidates = BunToolchainModule.executableCandidates("bun", "") + assert(candidates == Seq("bun")) + } + + test("PATHEXT adds lowercase extensions after bare name") { + val candidates = BunToolchainModule.executableCandidates("bun", ".COM;.EXE;.CMD") + assert(candidates == Seq("bun", "bun.com", "bun.exe", "bun.cmd")) + } + + test("single PATHEXT entry") { + val candidates = BunToolchainModule.executableCandidates("bun", ".EXE") + assert(candidates == Seq("bun", "bun.exe")) + } + + test("PATHEXT with trailing separator is ignored") { + val candidates = BunToolchainModule.executableCandidates("bun", ".EXE;") + assert(candidates == Seq("bun", "bun.exe")) + } + } +} From d74cceab7973e394c30b447fdf1191cae7394d25 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 30 Mar 2026 12:36:14 -0400 Subject: [PATCH 3/3] Harden .npmrc removal and add Scala.js bunfig coverage - Add removeInstallOnlyConfigs to strip .npmrc from compile/bundle/test workspaces after tscCopySources copies it from the source tree - Deduplicate test devDeps against outer package names to avoid version conflicts in the merged package.json - Use Task.Source for npmRc in BunScalaJSModule for sandbox-safe access - Add Scala.js bunfig propagation integration test covering linked, compile executable, and test workspaces - Separate typescript-bunfig moduleDir from workspace root to prove explicit propagation (not source tree walk) - Assert is-even stays out of test devDependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/scalajs-bunfig/.npmrc | 2 ++ .../resources/scalajs-bunfig/build.mill | 28 ++++++++++++++++++ .../resources/scalajs-bunfig/bunfig.toml | 3 ++ .../resources/scalajs-bunfig/src/Main.scala | 3 ++ .../resources/scalajs-bunfig/src/Words.scala | 3 ++ .../scalajs-bunfig/test/src/WordsTests.scala | 9 ++++++ .../typescript-bunfig/app/src/main.ts | 1 + .../resources/typescript-bunfig/build.mill | 3 +- .../resources/typescript-bunfig/src/main.ts | 1 - .../mill/bun/BunScalaJSIntegrationTests.scala | 29 +++++++++++++++++++ .../bun/BunTypeScriptIntegrationTests.scala | 9 ++++-- .../bun/BunTypeScriptModule.scala | 15 +++++++++- .../scalajslib/bun/BunScalaJSModule.scala | 5 ++-- 13 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 millbun/integration/resources/scalajs-bunfig/.npmrc create mode 100644 millbun/integration/resources/scalajs-bunfig/build.mill create mode 100644 millbun/integration/resources/scalajs-bunfig/bunfig.toml create mode 100644 millbun/integration/resources/scalajs-bunfig/src/Main.scala create mode 100644 millbun/integration/resources/scalajs-bunfig/src/Words.scala create mode 100644 millbun/integration/resources/scalajs-bunfig/test/src/WordsTests.scala create mode 100644 millbun/integration/resources/typescript-bunfig/app/src/main.ts delete mode 100644 millbun/integration/resources/typescript-bunfig/src/main.ts diff --git a/millbun/integration/resources/scalajs-bunfig/.npmrc b/millbun/integration/resources/scalajs-bunfig/.npmrc new file mode 100644 index 0000000..41f8712 --- /dev/null +++ b/millbun/integration/resources/scalajs-bunfig/.npmrc @@ -0,0 +1,2 @@ +# Marker .npmrc for integration test — should NOT propagate beyond install +registry=https://registry.npmjs.org/ diff --git a/millbun/integration/resources/scalajs-bunfig/build.mill b/millbun/integration/resources/scalajs-bunfig/build.mill new file mode 100644 index 0000000..0084ae5 --- /dev/null +++ b/millbun/integration/resources/scalajs-bunfig/build.mill @@ -0,0 +1,28 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.1.0-SNAPSHOT + +package build + +import mill.* +import mill.scalalib.* +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" + + override def mainClass = Some("Main") + override def moduleKind = Task { ModuleKind.ESModule } + override def bunBundleTarget = Task { "bun" } + + object test extends BunScalaJSTests, TestModule.Utest { + def mvnDeps = Seq( + mvn"com.lihaoyi::utest::0.8.5" + ) + def testFramework = "utest.runner.Framework" + } +} diff --git a/millbun/integration/resources/scalajs-bunfig/bunfig.toml b/millbun/integration/resources/scalajs-bunfig/bunfig.toml new file mode 100644 index 0000000..e98e7f1 --- /dev/null +++ b/millbun/integration/resources/scalajs-bunfig/bunfig.toml @@ -0,0 +1,3 @@ +# Marker bunfig for integration test — verifies propagation to Scala.js workspaces +[install] +auto = "disable" diff --git a/millbun/integration/resources/scalajs-bunfig/src/Main.scala b/millbun/integration/resources/scalajs-bunfig/src/Main.scala new file mode 100644 index 0000000..f3f9500 --- /dev/null +++ b/millbun/integration/resources/scalajs-bunfig/src/Main.scala @@ -0,0 +1,3 @@ +object Main extends App { + println("Hello from scala.js bunfig") +} diff --git a/millbun/integration/resources/scalajs-bunfig/src/Words.scala b/millbun/integration/resources/scalajs-bunfig/src/Words.scala new file mode 100644 index 0000000..34f49d8 --- /dev/null +++ b/millbun/integration/resources/scalajs-bunfig/src/Words.scala @@ -0,0 +1,3 @@ +object Words { + def greeting(name: String): String = s"Hello, $name!" +} diff --git a/millbun/integration/resources/scalajs-bunfig/test/src/WordsTests.scala b/millbun/integration/resources/scalajs-bunfig/test/src/WordsTests.scala new file mode 100644 index 0000000..2973922 --- /dev/null +++ b/millbun/integration/resources/scalajs-bunfig/test/src/WordsTests.scala @@ -0,0 +1,9 @@ +import utest.* + +object WordsTests extends TestSuite { + val tests: Tests = Tests { + test("greeting") { + assert(Words.greeting("Bun") == "Hello, Bun!") + } + } +} diff --git a/millbun/integration/resources/typescript-bunfig/app/src/main.ts b/millbun/integration/resources/typescript-bunfig/app/src/main.ts new file mode 100644 index 0000000..d077e8c --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/app/src/main.ts @@ -0,0 +1 @@ +console.log("Hello from Bun config propagation!"); diff --git a/millbun/integration/resources/typescript-bunfig/build.mill b/millbun/integration/resources/typescript-bunfig/build.mill index cb6ca8f..5f29a6e 100644 --- a/millbun/integration/resources/typescript-bunfig/build.mill +++ b/millbun/integration/resources/typescript-bunfig/build.mill @@ -9,5 +9,6 @@ import mill.* import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { - override def moduleDir = build.moduleDir + // Keep bunfig/.npmrc at the workspace root so the test proves explicit propagation. + override def moduleDir = build.moduleDir / "app" } diff --git a/millbun/integration/resources/typescript-bunfig/src/main.ts b/millbun/integration/resources/typescript-bunfig/src/main.ts deleted file mode 100644 index 3129afc..0000000 --- a/millbun/integration/resources/typescript-bunfig/src/main.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello from bunfig test"); diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index dc18e86..2138810 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -95,5 +95,34 @@ object BunScalaJSIntegrationTests extends TestSuite { val res = tester.eval("app.test.bunTest") assert(res.isSuccess) } + + test("bunfig propagates to Scala.js workspaces without leaking .npmrc") { + val tester = this.tester("scalajs-bunfig") + + val installRes = tester.eval("app.bunInstall") + assert(installRes.isSuccess) + val installDir = tester.workspacePath / "out" / "app" / "bunInstall.dest" + assert(os.exists(installDir / ".npmrc")) + assert(os.exists(installDir / "bunfig.toml")) + + val linkRes = tester.eval("app.fastLinkJS") + assert(linkRes.isSuccess) + val linkedDir = tester.workspacePath / "out" / "app" / "fastLinkJS.dest" + assert(os.exists(linkedDir / "bunfig.toml")) + assert(!os.exists(linkedDir / ".npmrc")) + + val compileRes = tester.eval("app.bunCompileExecutable") + assert(compileRes.isSuccess) + val compileWorkspace = tester.workspacePath / "out" / "app" / "bunCompileExecutable.dest" / "workspace" + assert(os.exists(compileWorkspace / "bunfig.toml")) + assert(!os.exists(compileWorkspace / ".npmrc")) + + val testRes = tester.eval("app.test.bunTest") + assert(testRes.isSuccess) + val testRoot = tester.workspacePath / "out" / "app" / "test" + assert(os.exists(testRoot)) + assert(os.walk(testRoot).exists(_.last == "bunfig.toml")) + assert(!os.walk(testRoot).exists(_.last == ".npmrc")) + } } } diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 182b134..d7194d9 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -132,7 +132,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(log.contains("Hello from TSX")) } - test("bunfig propagates to compile workspace") { + test("bunfig propagates to compile workspace without leaking .npmrc") { val tester = this.tester("typescript-bunfig") val res = tester.eval("app.compile") assert(res.isSuccess) @@ -140,10 +140,12 @@ object BunTypeScriptIntegrationTests extends TestSuite { val installDir = tester.workspacePath / "out" / "app" / "npmInstall.dest" val compileDir = tester.workspacePath / "out" / "app" / "compile.dest" - // bunfig should be in the install workspace + // Install workspace keeps both configs. + assert(os.exists(installDir / ".npmrc")) assert(os.exists(installDir / "bunfig.toml")) - // bunfig should propagate to compile workspace + // Compile workspace should only get bunfig. assert(os.exists(compileDir / "bunfig.toml")) + assert(!os.exists(compileDir / ".npmrc")) } test("test deps are devDependencies") { @@ -162,6 +164,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "npmInstall.dest" / "package.json")) assert(testPkg("devDependencies").obj.contains("is-odd")) assert(!testPkg("dependencies").obj.contains("is-odd")) + assert(!testPkg("devDependencies").obj.contains("is-even")) // Outer deps should also be present assert(testPkg("dependencies").obj.contains("is-even")) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index eb23d73..c1983ab 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -137,6 +137,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out tscCopyModDeps() tscCopyGenSources() tscLinkResources() + BunTypeScriptModule.removeInstallOnlyConfigs(Task.dest) ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) BunTypeScriptModule.copyBunfigsTo(Task.dest, resolvedBunfigs()) mkTsconfig() @@ -205,6 +206,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out else Task.dest / s"$moduleName.js" BunToolchainModule.copyWorkspace(compileDir, buildDir) + BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) if (bunCompileExecutable()) copyCompileResources(bunCompileResources(), buildDir) @@ -244,6 +246,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out val buildDir = Task.dest / "workspace" val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString BunToolchainModule.copyWorkspace(compileDir, buildDir) + BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) copyCompileResources(bunCompileResources(), buildDir) @@ -302,9 +305,12 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out val user = outer.packageJson() val outerDeps = outer.transitiveNpmDeps().map(BunToolchainModule.splitDep) val outerDevDeps = (outer.transitiveNpmDevDeps() ++ outer.tsDeps()).map(BunToolchainModule.splitDep) + 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 = (transitiveNpmDeps() ++ transitiveNpmDevDeps() ++ this.tsDeps()) + .map(BunToolchainModule.splitDep) + .filterNot { case (name, _) => outerPackageNames.contains(name) } val resolved = ujson.Obj.from( user.copy( @@ -334,6 +340,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out 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()) BunTypeScriptModule.copyBunfigsTo(dest, outer.resolvedBunfigs()) PathRef(dest) @@ -436,4 +443,10 @@ object BunTypeScriptModule { os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } } + + /** Generated Bun run/build/test workspaces should not retain install-only auth config. */ + def removeInstallOnlyConfigs(dest: os.Path): Unit = { + val npmrc = dest / ".npmrc" + if (os.exists(npmrc)) os.remove(npmrc) + } } diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index fb50130..34ad939 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -22,6 +22,8 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out /** Local tarballs / package directories. */ def unmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } + private def npmRc = Task.Source(BuildCtx.workspaceRoot / ".npmrc") + private def recursiveBunModuleDeps: Seq[BunScalaJSModule] = { @tailrec def loop( @@ -122,8 +124,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out val dest = Task.dest os.makeDir.all(dest) - val npmrc = BuildCtx.workspaceRoot / ".npmrc" - if (os.exists(npmrc)) os.copy.over(npmrc, dest / ".npmrc", createFolders = true) + 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)