diff --git a/README.md b/README.md index 141a6be..a88bb24 100644 --- a/README.md +++ b/README.md @@ -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 ` | all | Use a specific JDK for JRE classpath | -| `-r`, `--repository ` | external commands | Extra Maven repository URL (repeatable); must be an `http://`, `https://` or `file://` URL | +| `-r`, `--repository ` | external commands | Extra Maven repository URL (repeatable), appended to the configured `maven.repositories`; must be an `http://`, `https://` or `file://` URL | | `-l`, `--limit ` | `list`, `list-external`, `search`, `search-external` | Max results (default: 50) | | `-l`, `--limit ` | `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 | @@ -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 @@ -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 diff --git a/cli/src/cellar/cli/CellarApp.scala b/cli/src/cellar/cli/CellarApp.scala index bd4acfd..f651599 100644 --- a/cli/src/cellar/cli/CellarApp.scala +++ b/cli/src/cellar/cli/CellarApp.scala @@ -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))) diff --git a/lib/resources/reference.conf b/lib/resources/reference.conf index 39f1e62..9e94930 100644 --- a/lib/resources/reference.conf +++ b/lib/resources/reference.conf @@ -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" diff --git a/lib/src/cellar/Config.scala b/lib/src/cellar/Config.scala index 664d223..e0e7e74 100644 --- a/lib/src/cellar/Config.scala +++ b/lib/src/cellar/Config.scala @@ -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, @@ -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() diff --git a/lib/src/cellar/ExtraRepositories.scala b/lib/src/cellar/ExtraRepositories.scala new file mode 100644 index 0000000..714bbee --- /dev/null +++ b/lib/src/cellar/ExtraRepositories.scala @@ -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("/")) diff --git a/lib/test/src/cellar/ExtraRepositoriesTest.scala b/lib/test/src/cellar/ExtraRepositoriesTest.scala new file mode 100644 index 0000000..68e8065 --- /dev/null +++ b/lib/test/src/cellar/ExtraRepositoriesTest.scala @@ -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")) + } diff --git a/skills/cellar/SKILL.md b/skills/cellar/SKILL.md index 2c4c49e..b66f22a 100644 --- a/skills/cellar/SKILL.md +++ b/skills/cellar/SKILL.md @@ -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 `: extra Maven repository (repeatable) +- `-r`, `--repository `: extra Maven repository (repeatable), appended to the repositories + configured under `maven.repositories` in `~/.cellar/cellar.conf` or `.cellar/cellar.conf` ## Workflow