Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions millbun/integration/resources/mixed-workspace/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { shared: true };
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "shared-local",
"version": "1.0.0",
"main": "index.js"
}
16 changes: 16 additions & 0 deletions millbun/integration/resources/typescript-unmanaged/build.mill
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare function greet(): string;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { greet: () => "hello from local-lib" };
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "local-lib",
"version": "1.0.0",
"main": "index.js",
"types": "index.d.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { greet } from "local-lib";

console.log(greet());
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions millbun/src/mill/bun/BunToolchainModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`) 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
Expand Down
10 changes: 6 additions & 4 deletions millbun/src/mill/bun/BunWorkspaceModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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(
Expand Down Expand Up @@ -122,7 +125,7 @@ trait BunWorkspaceModule extends BunToolchainModule:
bunInstallExtraArgs(),
lockfile.nonEmpty,
updateLockfile = false
) ++ packages.flatMap(_._3).map(_.path.toString),
),
cwd = Task.dest,
env = bunEnv()
)
Expand All @@ -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)
Expand All @@ -143,7 +145,7 @@ trait BunWorkspaceModule extends BunToolchainModule:
bunInstallExtraArgs(),
bunLockfile().nonEmpty,
updateLockfile = true
) ++ packages.flatMap(_._3).map(_.path.toString),
),
cwd = Task.dest,
env = bunEnv()
)
Expand Down
30 changes: 22 additions & 8 deletions millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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(),
Expand All @@ -171,7 +175,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with
bunInstallExtraArgs(),
lockfile.nonEmpty,
updateLockfile = false
) ++ transitiveUnmanagedDeps().map(_.path.toString),
),
cwd = dest,
env = bunToolEnv()
)
Expand All @@ -188,6 +192,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with
mkBunPackageJson()
copyBunWorkspaceConfigs()
copyBunLockfile(bunLockfile(), dest)
BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest)

runBun(
bunExecutable(),
Expand All @@ -196,7 +201,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with
bunInstallExtraArgs(),
bunLockfile().nonEmpty,
updateLockfile = true
) ++ transitiveUnmanagedDeps().map(_.path.toString),
),
cwd = dest,
env = bunToolEnv()
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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()
)
Expand All @@ -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(),
Expand All @@ -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()
)
Expand Down
8 changes: 6 additions & 2 deletions millbun/src/mill/scalajslib/bun/BunPublishModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -85,14 +88,15 @@ trait BunPublishModule extends BunScalaJSModule {
requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile())
copyBunLockfile(lockfile, dest)
if hasRuntimeInputs then
BunToolchainModule.stageUnmanagedDeps(unmanagedDeps(), dest)
runBun(
bunExecutable(),
Seq("install") ++ resolvedBunInstallArgs(
bunInstallArgs(),
bunInstallExtraArgs(),
lockfile.nonEmpty,
updateLockfile = false
) ++ unmanagedDeps().map(_.path.toString),
),
cwd = dest,
env = bunEnv()
)
Expand Down
Loading
Loading