From 1270081cd98277e708e6121519a98c11893447ca Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:07:20 -0400 Subject: [PATCH] Deliver unmanagedDeps through package.json, not positional install args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local paths were passed as positional arguments to bun install, which turns the invocation into bun add — unconditionally rejected by --frozen-lockfile. unmanagedDeps therefore never worked against a lockfile, in any module type, and a lock produced by bunLock recorded the absolute checkout path. Each entry is now staged into vendor/ beside the generated package.json and declared as file:./vendor/, so the lock records the checkout-independent file:vendor/. Validated against real Bun 1.4.0: the lock survives relocating the repository and frozen installs resolve the package. The staging excludes the local package's own node_modules; bun resolves its declared dependencies through the lockfile instead. Applied uniformly: Scala.js and TypeScript installs and bunLock, the TypeScript test trait (union of outer and test-side deps, preserving the bare-module reuse equality), the publish module's vendored runtime install, and Bun workspaces — where vendor trees are staged in the layout beside each member's package.json. A name declared both as an npm dependency and an unmanaged dependency is rejected, as are two unmanaged directories claiming the same package name. Published manifests are unaffected: they are built from the npm dependency lists, so file: specifiers cannot leak to consumers. This is the first coverage unmanagedDeps has ever had. The new typescript-unmanaged fixture locks, frozen-installs, bundles, and runs against a local package with bunRequireLockfile enabled; the mixed-workspace fixture asserts the member manifest and staged vendor tree and that no positional args reach the stubbed install. Both fail on the parent commit — the TypeScript lock never contains file:vendor/local-lib, and the workspace member manifest has no shared-local entry. Co-Authored-By: Claude Fable 5 --- .../resources/mixed-workspace/build.mill | 1 + .../scalaApp/shared-local/index.js | 1 + .../scalaApp/shared-local/package.json | 5 ++ .../resources/typescript-unmanaged/build.mill | 16 +++++ .../typescript-unmanaged/local-lib/index.d.ts | 1 + .../typescript-unmanaged/local-lib/index.js | 1 + .../local-lib/package.json | 6 ++ .../typescript-unmanaged/src/main.ts | 3 + .../bun/BunTypeScriptIntegrationTests.scala | 20 ++++++ .../bun/BunWorkspaceIntegrationTests.scala | 8 +++ millbun/src/mill/bun/BunToolchainModule.scala | 72 +++++++++++++++++++ millbun/src/mill/bun/BunWorkspaceModule.scala | 10 +-- .../bun/BunTypeScriptModule.scala | 30 +++++--- .../scalajslib/bun/BunPublishModule.scala | 8 ++- .../scalajslib/bun/BunScalaJSModule.scala | 19 +++-- .../test/src/mill/bun/BunToolchainTests.scala | 41 +++++++++++ 16 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js create mode 100644 millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json create mode 100644 millbun/integration/resources/typescript-unmanaged/build.mill create mode 100644 millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts create mode 100644 millbun/integration/resources/typescript-unmanaged/local-lib/index.js create mode 100644 millbun/integration/resources/typescript-unmanaged/local-lib/package.json create mode 100644 millbun/integration/resources/typescript-unmanaged/src/main.ts diff --git a/millbun/integration/resources/mixed-workspace/build.mill b/millbun/integration/resources/mixed-workspace/build.mill index d066eab..d158b78 100644 --- a/millbun/integration/resources/mixed-workspace/build.mill +++ b/millbun/integration/resources/mixed-workspace/build.mill @@ -16,6 +16,7 @@ object scalaApp extends BunScalaJSModule { def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } override def npmDeps = Task { Seq("is-even@1.0.0") } + override def unmanagedDeps = Task.Sources(moduleDir / "shared-local") override def classpathBunDeps = Task { Seq.empty } override def classpathBunOptionalDeps = Task { Seq.empty } override def classpathBunPeerDeps = Task { Seq.empty } diff --git a/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js new file mode 100644 index 0000000..db04e4a --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/index.js @@ -0,0 +1 @@ +module.exports = { shared: true }; diff --git a/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json new file mode 100644 index 0000000..9946b8c --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/scalaApp/shared-local/package.json @@ -0,0 +1,5 @@ +{ + "name": "shared-local", + "version": "1.0.0", + "main": "index.js" +} diff --git a/millbun/integration/resources/typescript-unmanaged/build.mill b/millbun/integration/resources/typescript-unmanaged/build.mill new file mode 100644 index 0000000..1333f47 --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/build.mill @@ -0,0 +1,16 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def unmanagedDeps = Task.Sources(moduleDir / "local-lib") + // The regression this fixture guards: local packages must install against a frozen lockfile. + override def bunRequireLockfile = Task { true } +} diff --git a/millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts b/millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts new file mode 100644 index 0000000..a5e4638 --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/local-lib/index.d.ts @@ -0,0 +1 @@ +export declare function greet(): string; diff --git a/millbun/integration/resources/typescript-unmanaged/local-lib/index.js b/millbun/integration/resources/typescript-unmanaged/local-lib/index.js new file mode 100644 index 0000000..f3232ef --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/local-lib/index.js @@ -0,0 +1 @@ +module.exports = { greet: () => "hello from local-lib" }; diff --git a/millbun/integration/resources/typescript-unmanaged/local-lib/package.json b/millbun/integration/resources/typescript-unmanaged/local-lib/package.json new file mode 100644 index 0000000..027fb76 --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/local-lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "local-lib", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/millbun/integration/resources/typescript-unmanaged/src/main.ts b/millbun/integration/resources/typescript-unmanaged/src/main.ts new file mode 100644 index 0000000..a668f3c --- /dev/null +++ b/millbun/integration/resources/typescript-unmanaged/src/main.ts @@ -0,0 +1,3 @@ +import { greet } from "local-lib"; + +console.log(greet()); diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 45eb959..f4854a8 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -238,6 +238,26 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(tester.eval("app.test.test").isSuccess) } + test("unmanaged local packages install under a frozen lockfile") { + // Positional install paths turned `bun install` into `bun add`, which --frozen-lockfile + // unconditionally rejects — unmanagedDeps never worked against a lockfile at all. + val tester = this.tester("typescript-unmanaged") + assert(tester.eval("app.bunLock").isSuccess) + val lock = os.read(tester.workspacePath / "bun.lock") + assert(lock.contains("file:vendor/local-lib")) + // The lock must not record where this repository happens to be checked out. + assert(!lock.contains(tester.workspacePath.toString)) + + assert(tester.eval("app.npmInstall").isSuccess) + val installed = outputPath(tester, "app.npmInstall") + assert(os.exists(installed / "node_modules" / "local-lib" / "package.json")) + + assert(tester.eval("app.bundle").isSuccess) + val bundle = outputPath(tester, "app.bundle") + val run = os.call(Seq("bun", bundle.toString)) + assert(run.out.text().contains("hello from local-lib")) + } + test("test modules adding nothing reuse the outer install") { // A bare test module must not demand a second lockfile. val tester = this.tester("typescript-tests") diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 2d0d6c9..51aee0f 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -39,6 +39,14 @@ object BunWorkspaceIntegrationTests extends TestSuite: assert(os.exists(workspaceInstall / "node_modules" / "is-even" / "package.json")) assert(os.exists(workspaceInstall / "node_modules" / "is-odd" / "package.json")) + // Unmanaged local packages arrive as file: specifiers with vendor trees staged beside the + // member's package.json — never as positional install args, which turn `bun install` into + // `bun add` and are unconditionally rejected by --frozen-lockfile. + val scalaJson = ujson.read(os.read(workspaceInstall / "packages" / "scalaApp" / "package.json")) + assert(scalaJson("dependencies").obj("shared-local").str == "file:./vendor/shared-local") + assert(os.exists(workspaceInstall / "packages" / "scalaApp" / "vendor" / "shared-local" / "package.json")) + assert(!os.read(workspaceInstall / ".workspace-installed").contains("shared-local")) + val scalaResult = tester.eval("scalaApp.bunInstall") val typescriptResult = tester.eval("typescriptApp.npmInstall") assert(scalaResult.isSuccess) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 980d333..5cb1f34 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -301,6 +301,78 @@ object BunToolchainModule { } } + /** + * npm dependency pairs plus `file:` pairs for local (unmanaged) packages. + * + * Local paths must arrive through the generated package.json, never as positional + * `bun install` arguments: a positional path turns the invocation into `bun add`, which + * `--frozen-lockfile` unconditionally rejects — so unmanaged deps could never install against + * a lockfile at all. The specifier points under `vendor/` beside the package.json (staged by + * [[stageUnmanagedDeps]]), so the recorded lock entry (`file:vendor/`) is independent + * of where the repository is checked out. + */ + private[mill] def dependencyPairsWithUnmanaged( + npm: Seq[(String, ujson.Str)], + unmanaged: Seq[PathRef] + ): Seq[(String, ujson.Str)] = { + val filePairs = unmanagedDependencyPairs(unmanaged) + val collisions = npm.map(_._1).toSet.intersect(filePairs.map(_._1).toSet).toSeq.sorted + if (collisions.nonEmpty) { + throw new IllegalArgumentException( + s"Declared both as an npm dependency and in unmanagedDeps: ${collisions.mkString(", ")}. " + + "A package can be resolved from the registry or from a local directory, not both." + ) + } + npm ++ filePairs + } + + private[bun] def unmanagedDependencyPairs(deps: Seq[PathRef]): Seq[(String, ujson.Str)] = { + val named = deps.map(_.path).distinct.map(path => unmanagedPackageName(path) -> path) + val duplicates = named.groupBy(_._1).collect { + case (name, entries) if entries.map(_._2).distinct.size > 1 => + s"$name (${entries.map(_._2).distinct.mkString(", ")})" + }.toSeq.sorted + if (duplicates.nonEmpty) { + throw new IllegalArgumentException( + s"Multiple unmanagedDeps declare the same package name: ${duplicates.mkString("; ")}." + ) + } + named.distinctBy(_._1).sortBy(_._1).map { case (name, _) => + name -> ujson.Str(s"file:./vendor/${vendorDirectoryName(name)}") + } + } + + private[bun] def unmanagedPackageName(source: os.Path): String = { + if (!os.isDir(source)) { + throw new IllegalArgumentException( + s"Unmanaged Bun dependency $source is not a directory. Point unmanagedDeps at unpacked " + + "package directories containing a package.json." + ) + } + val packageJson = source / "package.json" + if (!os.exists(packageJson)) { + throw new IllegalArgumentException(s"Unmanaged Bun dependency $source has no package.json.") + } + ujson.read(os.read(packageJson)).obj.get("name") match { + case Some(ujson.Str(name)) if name.nonEmpty => name + case _ => + throw new IllegalArgumentException(s"$packageJson does not declare a package name.") + } + } + + /** Scoped names need one path segment: `@scope/pkg` becomes `scope+pkg`, as in Bun workspaces. */ + private[bun] def vendorDirectoryName(name: String): String = + name.stripPrefix("@").replace('/', '+') + + /** Copy each unmanaged package into `vendor/` beside the generated package.json. */ + private[mill] def stageUnmanagedDeps(deps: Seq[PathRef], installRoot: os.Path): Unit = + deps.map(_.path).distinct.foreach { source => + val name = unmanagedPackageName(source) + // A local package's own node_modules is development debris; bun resolves the package's + // declared dependencies through the lockfile instead. + copyTree(source, installRoot / "vendor" / vendorDirectoryName(name), exclude = Set("node_modules")) + } + /** Add unmodeled package.json fields without allowing typed dependency data to be replaced. */ def mergePackageJson(base: ujson.Obj, extras: ujson.Obj): ujson.Obj = { val conflicts = extras.value.keySet.intersect(ModeledPackageJsonFields).toSeq.sorted diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index a651969..fd5b4d5 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -67,13 +67,16 @@ trait BunWorkspaceModule extends BunToolchainModule: if duplicateDirectories.nonEmpty then Task.fail(s"Bun workspace package names map to duplicate directories: ${duplicateDirectories.mkString(", ")}") - packages.foreach { case (name, json, _) => + packages.foreach { case (name, json, unmanaged) => val directory = packageDirectory(name) os.write.over( Task.dest / "packages" / directory / "package.json", json.render(indent = 2), createFolders = true ) + // Members declare local packages as `file:./vendor/` relative to their own + // package.json, so their vendor trees live beside it in the layout. + BunToolchainModule.stageUnmanagedDeps(unmanaged, Task.dest / "packages" / directory) } val root = ujson.Obj( @@ -122,7 +125,7 @@ trait BunWorkspaceModule extends BunToolchainModule: bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ packages.flatMap(_._3).map(_.path.toString), + ), cwd = Task.dest, env = bunEnv() ) @@ -131,7 +134,6 @@ trait BunWorkspaceModule extends BunToolchainModule: /** Resolve the full workspace and update its source-controlled `bun.lock`. */ def bunLock(): Command[PathRef] = Task.Command { - val packages = resolvedPackages() BunToolchainModule.copyWorkspace(bunWorkspaceLayout().path, Task.dest) copyConfigs(Task.dest, npmRc().path, bunfigFiles()) copyBunLockfile(bunLockfile(), Task.dest) @@ -143,7 +145,7 @@ trait BunWorkspaceModule extends BunToolchainModule: bunInstallExtraArgs(), bunLockfile().nonEmpty, updateLockfile = true - ) ++ packages.flatMap(_._3).map(_.path.toString), + ), cwd = Task.dest, env = bunEnv() ) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 6ff9dbc..6fcedde 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -93,7 +93,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with name = if (user.name.nonEmpty) user.name else moduleName, version = if (user.version.nonEmpty) user.version else "1.0.0", `type` = if (enableEsm()) "module" else user.`type`, - dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), + transitiveUnmanagedDeps() + )), devDependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps() ++ tsDeps(), overrides)) ).cleanJson.obj.toSeq ) @@ -163,6 +166,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val lockfile = bunLockfile() requireBunLockfile(true, lockfile, bunRequireLockfile()) copyBunLockfile(lockfile, dest) + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), @@ -171,7 +175,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunToolEnv() ) @@ -188,6 +192,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with mkBunPackageJson() copyBunWorkspaceConfigs() copyBunLockfile(bunLockfile(), dest) + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), @@ -196,7 +201,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunInstallExtraArgs(), bunLockfile().nonEmpty, updateLockfile = true - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunToolEnv() ) @@ -442,7 +447,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with def bunTestPackageJson: T[ujson.Obj] = Task { val user = outer.packageJson() val overrides = outer.npmOverrides() - val outerDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides) + val outerDeps = BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides), + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct + ) val outerDevDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDevDeps() ++ outer.tsDeps(), overrides) val outerPackageNames = (outerDeps.iterator ++ outerDevDeps.iterator).map(_._1).toSet @@ -511,6 +519,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with lockfilePath = moduleDir / "bun.lock" ) outer.copyBunLockfile(lockfile, dest) + BunToolchainModule.stageUnmanagedDeps( + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct, + dest + ) outer.runBun( outer.bunExecutable(), @@ -519,8 +531,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with outer.bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()) - .distinct.map(_.path.toString), + ), cwd = dest, env = outer.bunToolEnv() ) @@ -545,6 +556,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with ) outer.copyBunWorkspaceConfigs() outer.copyBunLockfile(this.bunLockfile(), dest) + BunToolchainModule.stageUnmanagedDeps( + (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()).distinct, + dest + ) outer.runBun( outer.bunExecutable(), @@ -553,8 +568,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with outer.bunInstallExtraArgs(), this.bunLockfile().nonEmpty, updateLockfile = true - ) ++ (outer.transitiveUnmanagedDeps() ++ this.transitiveUnmanagedDeps()) - .distinct.map(_.path.toString), + ), cwd = dest, env = outer.bunToolEnv() ) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index e8a1ecb..74025de 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -62,7 +62,10 @@ trait BunPublishModule extends BunScalaJSModule { os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } - val deps = BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()) + val deps = BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()), + unmanagedDeps() + ) val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps() ++ bunOptionalDeps(), npmOverrides()) val base = ujson.Obj( "name" -> defaultPackageName, @@ -85,6 +88,7 @@ trait BunPublishModule extends BunScalaJSModule { requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile()) copyBunLockfile(lockfile, dest) if hasRuntimeInputs then + BunToolchainModule.stageUnmanagedDeps(unmanagedDeps(), dest) runBun( bunExecutable(), Seq("install") ++ resolvedBunInstallArgs( @@ -92,7 +96,7 @@ trait BunPublishModule extends BunScalaJSModule { bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ unmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunEnv() ) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index fbb8800..18316ba 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -39,7 +39,13 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac */ def bunDevDeps: T[Seq[String]] = Task { Seq.empty } - /** Local tarballs / package directories. */ + /** + * Local package directories, each containing a `package.json` with a name. + * + * Every entry is staged into `vendor/` beside the generated package.json and declared as a + * `file:./vendor/` dependency, so the recorded lockfile entry stays independent of the + * checkout path and frozen installs work. Tarballs are not supported — unpack them. + */ def unmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } private def npmRc = Task.Source(BuildCtx.workspaceRoot / ".npmrc") @@ -200,7 +206,10 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac "name" -> defaultPackageName, "private" -> true, "version" -> "0.0.0", - "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), + transitiveUnmanagedDeps() + )), "devDependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps(), overrides)) ) if allOptional.nonEmpty then @@ -282,6 +291,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac copyBunLockfile(lockfile, dest) if hasInstallInputs then + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) runBun( bunExecutable(), Seq("install") ++ resolvedBunInstallArgs( @@ -289,7 +299,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bunInstallExtraArgs(), lockfile.nonEmpty, updateLockfile = false - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunEnv() ) @@ -310,6 +320,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } mkBunPackageJson() + BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) copyBunLockfile(bunLockfile(), dest) runBun( @@ -319,7 +330,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bunInstallExtraArgs(), bunLockfile().nonEmpty, updateLockfile = true - ) ++ transitiveUnmanagedDeps().map(_.path.toString), + ), cwd = dest, env = bunEnv() ) diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index 98bddf3..94d9c12 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -151,6 +151,47 @@ object BunToolchainTests extends TestSuite: assert(os.read(cached) == "bun-binary") assert(os.list(cached / os.up) == Seq(cached)) + test("unmanaged deps become file: specifiers under vendor"): + val dep = os.temp.dir() / "local-lib" + os.write(dep / "package.json", """{"name":"local-lib","version":"1.0.0"}""", createFolders = true) + val scoped = os.temp.dir() / "scoped" + os.write(scoped / "package.json", """{"name":"@acme/util","version":"2.0.0"}""", createFolders = true) + + val pairs = BunToolchainModule.unmanagedDependencyPairs(Seq(mill.api.PathRef(dep), mill.api.PathRef(scoped))) + assert(pairs == Seq( + "@acme/util" -> ujson.Str("file:./vendor/acme+util"), + "local-lib" -> ujson.Str("file:./vendor/local-lib") + )) + + test("unmanaged dep without a package.json fails with guidance"): + val dep = os.temp.dir() / "raw" + os.makeDir.all(dep) + val err = intercept[IllegalArgumentException] { + BunToolchainModule.unmanagedDependencyPairs(Seq(mill.api.PathRef(dep))) + } + assert(err.getMessage.contains("package.json")) + + test("a name declared both as npm dep and unmanaged dep is rejected"): + val dep = os.temp.dir() / "local-react" + os.write(dep / "package.json", """{"name":"react","version":"1.0.0"}""", createFolders = true) + val err = intercept[IllegalArgumentException] { + BunToolchainModule.dependencyPairsWithUnmanaged( + BunToolchainModule.dependencyPairs(Seq("react@^19.0.0")), + Seq(mill.api.PathRef(dep)) + ) + } + assert(err.getMessage.contains("react")) + + test("staging copies packages into vendor and skips their node_modules"): + val dep = os.temp.dir() / "local-lib" + os.write(dep / "package.json", """{"name":"local-lib","version":"1.0.0"}""", createFolders = true) + os.write(dep / "node_modules" / "junk" / "package.json", "{}", createFolders = true) + + val root = os.temp.dir() + BunToolchainModule.stageUnmanagedDeps(Seq(mill.api.PathRef(dep)), root) + assert(os.exists(root / "vendor" / "local-lib" / "package.json")) + assert(!os.exists(root / "vendor" / "local-lib" / "node_modules")) + test("computes SHA-256"): val file = os.temp(contents = "hello") assert(