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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ cellar get-external org.typelevel:cats-core_3:latest cats.Monad
| `--no-cache` | project commands | Skip classpath cache, re-extract from build tool |
| `--test` | project commands | Use the test-scope classpath (sbt/scala-cli; not supported for Mill) |
| `--java-home <path>` | all | Use a specific JDK for JRE classpath |
| `-r`, `--repository <url>` | external commands | Extra Maven repository URL (repeatable); must be an `http://`, `https://` or `file://` URL |
| `-r`, `--repository <url>` | external commands | Extra Maven repository URL (repeatable), appended to the configured `maven.repositories`; must be an `http://`, `https://` or `file://` URL |
| `-l`, `--limit <N>` | `list`, `list-external`, `search`, `search-external` | Max results (default: 50) |
| `-l`, `--limit <N>` | `get`, `get-external` | Max members to display per section, including companion members (no default) |
| `--hide-inherited` | `get`, `get-external` | Show only members declared on the type itself |
Expand Down Expand Up @@ -234,6 +234,12 @@ Cellar loads configuration from HOCON files and environment variables. Files are
### Default config

```hocon
maven {
# Extra Maven repository URLs for external commands, added to Coursier's
# defaults (Maven Central, Ivy local)
repositories = []
}

mill {
# Binary to invoke when extracting Mill classpaths
binary = "./mill" # env: CELLAR_MILL_BINARY
Expand Down Expand Up @@ -283,6 +289,22 @@ Use a custom Mill wrapper:
mill { binary = "./millw" }
```

Always resolve external coordinates through an internal repository, so `-r` is no longer needed:

```hocon
maven {
repositories = [
"https://artifactory.company.com/maven",
"https://artifactory.company.com/maven-snapshots"
]
}
```

Maven Central and Ivy local stay available, and CLI `-r` values are appended to the configured list.
Lists replace rather than merge, so a project-level `maven.repositories` overrides the user-level
list and `repositories = []` clears it for that project. Configuration holds URLs only —
credentials come from coursier, as described above.

Or via environment: `CELLAR_SBT_BINARY=sbtn cellar get --module core cats.Monad`

## Telemetry
Expand Down
19 changes: 14 additions & 5 deletions cli/src/cellar/cli/CellarApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,20 @@ object CellarApp extends ProfilingIOApp:
Opts.option[Path]("java-home", "Use a specific JDK for JRE classpath").orNone

private val extraReposOpt: Opts[IO[List[Repository]]] =
Opts.options[String]("repository", "Extra Maven repository URL (repeatable)", short = "r", metavar = "url")
.orEmpty
.mapValidated(_.traverse { raw =>
RepositoryUrl.parse(raw).leftMap(reason => s"Invalid --repository: $reason").toValidatedNel
})
Opts.options[String](
"repository",
"Extra Maven repository URL, appended to configured maven.repositories (repeatable)",
short = "r",
metavar = "url"
).orEmpty
.mapValidated { commandLine =>
def validate(source: String)(raw: String) =
RepositoryUrl.parse(raw).leftMap(reason => s"Invalid $source: $reason").toValidatedNel
(
Config.global.maven.repositories.traverse(validate("maven.repositories entry")),
commandLine.traverse(validate("--repository"))
).mapN(ExtraRepositories.effective)
}
.map { repos =>
if repos.isEmpty then IO.pure(Nil)
else CoursierCredentials.load().map(creds => repos.map(CoursierCredentials.applyTo(_, creds)))
Expand Down
7 changes: 7 additions & 0 deletions lib/resources/reference.conf
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
maven {
# Extra Maven repository URLs used by every external command, in addition to
# Coursier's defaults (Maven Central, Ivy local). CLI -r/--repository values
# are appended to this list.
repositories = []
}

mill {
# When running mill, which binary to invoke
binary = "./mill"
Expand Down
11 changes: 9 additions & 2 deletions lib/src/cellar/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ case class ProfilingConfig(enabled: Boolean, pyroscopeEndpoint: String) derives

case class OtelConfig(enabled: Boolean, endpoint: String) derives ConfigReader

/** Extra Maven repository URLs added to Coursier's defaults for every external command. */
case class MavenConfig(repositories: List[String]) derives ConfigReader

case class Config(
maven: MavenConfig,
mill: MillConfig,
sbt: SbtConfig,
starvationChecks: StarvationChecksConfig,
Expand All @@ -28,13 +32,16 @@ object Config {
sys.props.get("user.home").map(Path(_).resolve(".cellar").resolve("cellar.conf"))
private[cellar] val defaultProjectPath: Path = Path(".cellar").resolve("cellar.conf")

private def load(): Config = {
val paths = defaultUserPath.toList ++ List(defaultProjectPath)
/** Loads with explicit file locations so tests never touch the real user configuration. */
private[cellar] def loadFrom(userPath: Option[Path], projectPath: Option[Path]): Config = {
val paths = userPath.toList ++ projectPath.toList
paths
.foldLeft(ConfigSource.default)((cs, p) => ConfigSource.file(p.toNioPath).optional.withFallback(cs))
.loadOrThrow[Config]
}

private def load(): Config = loadFrom(defaultUserPath, Some(defaultProjectPath))

lazy val global: Config = load()

def loadFresh(): Config = load()
Expand Down
12 changes: 12 additions & 0 deletions lib/src/cellar/ExtraRepositories.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package cellar

import coursierapi.{MavenRepository, Repository}

private[cellar] object ExtraRepositories:

/** Configured repositories first, command-line `-r` values appended.
*
* Duplicates collapse to their first occurrence, ignoring a trailing slash.
*/
def effective(configured: List[MavenRepository], commandLine: List[MavenRepository]): List[Repository] =
(configured ++ commandLine).distinctBy(_.getBase.stripSuffix("/"))
67 changes: 67 additions & 0 deletions lib/test/src/cellar/ExtraRepositoriesTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package cellar

import cats.effect.IO
import cats.syntax.all.*
import coursierapi.MavenRepository
import fs2.io.file.{Files => Fs2Files, Path}
import munit.CatsEffectSuite
import org.typelevel.otel4s.trace.Tracer.Implicits.noop

class ExtraRepositoriesTest extends CatsEffectSuite:

private def withConfigFiles(userConfig: Option[String], projectConfig: Option[String])(
test: Config => IO[Unit]
): IO[Unit] =
Fs2Files[IO].tempDirectory.use { dir =>
def write(name: String, contents: String): IO[Path] =
val file = dir.resolve(name)
fs2.Stream.emit(contents).through(Fs2Files[IO].writeUtf8(file)).compile.drain.as(file)

for
userPath <- userConfig.traverse(write("user.conf", _))
projectPath <- projectConfig.traverse(write("project.conf", _))
_ <- test(Config.loadFrom(userPath, projectPath))
yield ()
}

private def bases(configured: List[String], commandLine: List[String]): List[String] =
ExtraRepositories.effective(configured.map(MavenRepository.of), commandLine.map(MavenRepository.of)).map {
case maven: MavenRepository => maven.getBase
case other => fail(s"Expected a MavenRepository, got $other")
}

test("configured repositories come first, command-line values append"):
assertEquals(
bases(List("https://configured.example/maven"), List("https://cli.example/maven")),
List("https://configured.example/maven", "https://cli.example/maven")
)

test("duplicates collapse to their first occurrence, ignoring a trailing slash"):
assertEquals(
bases(List("https://repo.example/maven"), List("https://repo.example/maven/", "https://other.example/maven")),
List("https://repo.example/maven", "https://other.example/maven")
)

test("no configured repositories leaves command-line values untouched"):
assertEquals(bases(Nil, List("https://cli.example/maven")), List("https://cli.example/maven"))

test("default configuration has no extra repositories"):
withConfigFiles(None, None)(config => IO(assertEquals(config.maven.repositories, Nil)))

test("empty project list clears repositories inherited from the user config"):
withConfigFiles(
userConfig = Some("""maven.repositories = ["https://configured.example/maven"]"""),
projectConfig = Some("maven.repositories = []")
)(config => IO(assertEquals(config.maven.repositories, Nil)))

test("configured repository resolves a fixture artifact without a command-line repository"):
TestFixtures.assumeFixturesAvailable()
withConfigFiles(
userConfig = Some(s"""maven.repositories = ["file://${TestFixtures.localM2}"]"""),
projectConfig = None
) { config =>
val repositories = ExtraRepositories.effective(config.maven.repositories.map(MavenRepository.of), Nil)
CoursierFetchClient
.fetchClasspath(TestFixtures.scala3Coord, repositories)
.map(paths => assert(paths.nonEmpty, "Expected the fixture JAR to resolve through the configured repository"))
}
3 changes: 2 additions & 1 deletion skills/cellar/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Query any published artifact by explicit coordinate (`group:artifact:version`):
- For sbt plugins, use the full Scala and sbt suffix: `group:artifact_2.12_1.0:version` (e.g. `org.scala-native:sbt-scala-native_2.12_1.0:latest`)
- For compiler plugins and other artifacts with full Scala version suffixes, use the full version: `group:artifact_3.3.8:version`
- Use `latest` as the version to resolve the most recent release
- `-r`, `--repository <url>`: extra Maven repository (repeatable)
- `-r`, `--repository <url>`: extra Maven repository (repeatable), appended to the repositories
configured under `maven.repositories` in `~/.cellar/cellar.conf` or `.cellar/cellar.conf`

## Workflow

Expand Down