diff --git a/build.sc b/build.sc index 97b64fcadf..aa66180607 100644 --- a/build.sc +++ b/build.sc @@ -21,6 +21,7 @@ import $file.project.deps, deps.customRepositories import $file.project.website import java.io.File +import java.net.URL import java.nio.charset.Charset import java.util.Locale @@ -29,7 +30,7 @@ import io.github.alexarchambault.millnativeimage.upload.Upload import mill._, scalalib.{publish => _, _} import mill.contrib.bloop.Bloop -import _root_.scala.util.Properties +import _root_.scala.util.{Properties, Using} // Tell mill modules are under modules/ implicit def millModuleBasePath: define.BasePath = @@ -644,6 +645,7 @@ trait Cli extends SbtModule with ProtoBuildModule with CliLaunchers |/** Build-time constants. Generated by mill. */ |object Constants { | def launcherTypeResourcePath = "${launcherTypeResourcePath.toString}" + | def defaultFilesResourcePath = "$defaultFilesResourcePath" |} |""".stripMargin if (!os.isFile(dest) || os.read(dest) != code) @@ -652,6 +654,25 @@ trait Cli extends SbtModule with ProtoBuildModule with CliLaunchers } def generatedSources = super.generatedSources() ++ Seq(constantsFile()) + def defaultFilesResources = T.persistent { + val dir = T.dest / "resources" + val resources = Seq( + "https://raw.githubusercontent.com/scala-cli/default-workflow/main/.github/workflows/ci.yml" -> (os.sub / "workflows" / "default.yml"), + "https://raw.githubusercontent.com/scala-cli/default-workflow/main/.gitignore" -> (os.sub / "gitignore") + ) + for ((srcUrl, destRelPath) <- resources) { + val dest = dir / defaultFilesResourcePath / destRelPath + if (!os.isFile(dest)) { + val content = Using.resource(new URL(srcUrl).openStream())(_.readAllBytes()) + os.write(dest, content, createFolders = true) + } + } + PathRef(dir) + } + override def resources = T.sources { + super.resources() ++ Seq(defaultFilesResources()) + } + def myScalaVersion: String def scalaVersion = T(myScalaVersion) diff --git a/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala b/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala index 726ff3283a..5dd9f58674 100644 --- a/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala +++ b/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala @@ -13,6 +13,7 @@ class PersistentDiagnosticLogger(parent: Logger) extends Logger { def diagnostics = diagBuilder.result() + def error(message: String): Unit = parent.error(message) // TODO Use macros for log and debug calls to have zero cost when verbosity <= 0 def message(message: => String): Unit = parent.message(message) def log(s: => String): Unit = parent.log(s) diff --git a/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala b/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala index 564a63fd47..30265924f8 100644 --- a/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala @@ -26,6 +26,8 @@ class BuildProjectTests extends munit.FunSuite { var diagnostics: List[Diagnostic] = Nil + override def error(message: String): Unit = ??? + override def message(message: => String): Unit = ??? override def log(s: => String): Unit = ??? diff --git a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala index 6d12838e76..6356028c7a 100644 --- a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala +++ b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala @@ -18,6 +18,8 @@ case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logg } } + def error(message: String): Unit = + System.err.println(message) def message(message: => String): Unit = if (info) System.err.println(message) diff --git a/modules/cli-options/src/main/scala/scala/cli/commands/default/DefaultFileOptions.scala b/modules/cli-options/src/main/scala/scala/cli/commands/default/DefaultFileOptions.scala new file mode 100644 index 0000000000..993e7b2711 --- /dev/null +++ b/modules/cli-options/src/main/scala/scala/cli/commands/default/DefaultFileOptions.scala @@ -0,0 +1,30 @@ +package scala.cli.commands.default + +import caseapp._ + +import scala.cli.commands.LoggingOptions + +// format: off +final case class DefaultFileOptions( + @Recurse + logging: LoggingOptions = LoggingOptions(), + @Group("Default") + @HelpMessage("Write result to files rather than to stdout") + write: Boolean = false, + @Group("Default") + @HelpMessage("List available default files") + list: Boolean = false, + @Group("Default") + @HelpMessage("List available default file ids") + listIds: Boolean = false, + @Group("Default") + @HelpMessage("Force overwriting destination files") + @ExtraName("f") + force: Boolean = false +) +// format: on + +object DefaultFileOptions { + implicit lazy val parser: Parser[DefaultFileOptions] = Parser.derive + implicit lazy val help: Help[DefaultFileOptions] = Help.derive +} diff --git a/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala b/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala index 2b1738ae7e..3b0bd6c7a8 100644 --- a/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala +++ b/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala @@ -7,6 +7,7 @@ import java.nio.file.InvalidPathException import scala.cli.commands._ import scala.cli.commands.bloop.BloopOutput +import scala.cli.commands.default.DefaultFile import scala.cli.commands.github.{SecretCreate, SecretList} import scala.cli.commands.pgp.{PgpCommands, PgpCommandsSubst, PgpPull, PgpPush} import scala.cli.commands.publish.{Publish, PublishLocal} @@ -33,6 +34,7 @@ class ScalaCliCommands( Bsp, Clean, Compile, + DefaultFile, Directories, Doc, Doctor, diff --git a/modules/cli/src/main/scala/scala/cli/commands/default/DefaultFile.scala b/modules/cli/src/main/scala/scala/cli/commands/default/DefaultFile.scala new file mode 100644 index 0000000000..e9c7fc52ce --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/default/DefaultFile.scala @@ -0,0 +1,111 @@ +package scala.cli.commands.default + +import caseapp.core.RemainingArgs + +import java.io.File + +import scala.build.Logger +import scala.cli.commands.ScalaCommand +import scala.cli.commands.util.CommonOps._ +import scala.cli.internal.Constants +import scala.util.Using + +object DefaultFile extends ScalaCommand[DefaultFileOptions] { + + override def hidden = true + override def inSipScala = false + + private def readDefaultFile(path: String): Array[Byte] = { + val resourcePath = Constants.defaultFilesResourcePath + "/" + path + val cl = Thread.currentThread().getContextClassLoader + val resUrl = cl.getResource(resourcePath) + if (resUrl == null) + sys.error(s"Should not happen - resource $resourcePath not found") + Using.resource(resUrl.openStream())(_.readAllBytes()) + } + + final case class DefaultFile( + path: os.SubPath, + content: () => Array[Byte] + ) { + def printablePath = path.segments.mkString(File.separator) + } + + def defaultWorkflow: Array[Byte] = + readDefaultFile("workflows/default.yml") + def defaultGitignore: Array[Byte] = + readDefaultFile("gitignore") + + val defaultFiles = Map( + "workflow" -> DefaultFile(os.sub / ".github" / "workflows" / "ci.yml", () => defaultWorkflow), + "gitignore" -> DefaultFile(os.sub / ".gitignore", () => defaultGitignore) + ) + val defaultFilesByRelPath = defaultFiles.flatMap { + case (_, d) => + // d.path.toString and d.printablePath differ on Windows (one uses '/', the other '\') + Seq( + d.path.toString -> d, + d.printablePath -> d + ) + } + + private def unrecognizedFile(name: String, logger: Logger): Nothing = { + logger.error( + s"Error: unrecognized default file $name (available: ${defaultFiles.keys.toVector.sorted.mkString(", ")})" + ) + sys.exit(1) + } + + def run(options: DefaultFileOptions, args: RemainingArgs): Unit = { + + val logger = options.logging.logger + + lazy val allArgs = { + val l = args.all + if (l.isEmpty) { + logger.error("No default file asked") + sys.exit(1) + } + l + } + + if (options.list || options.listIds) + for ((name, d) <- defaultFiles.toVector.sortBy(_._1)) { + if (options.listIds) + println(name) + if (options.list) + println(d.printablePath) + } + else if (options.write) + for (arg <- allArgs) + defaultFiles.get(arg).orElse(defaultFilesByRelPath.get(arg)) match { + case Some(f) => + val dest = os.pwd / f.path + if (!options.force && os.exists(dest)) { + logger.error( + s"Error: ${f.path} already exists. Pass --force to force erasing it." + ) + sys.exit(1) + } + if (options.force) + os.write.over(dest, f.content(), createFolders = true) + else + os.write(dest, f.content(), createFolders = true) + logger.message(s"Wrote ${f.path}") + case None => + unrecognizedFile(arg, logger) + } + else { + if (allArgs.length > 1) { + logger.error(s"Error: expected only one argument, got ${allArgs.length}") + sys.exit(1) + } + + val arg = allArgs.head + val f = defaultFiles.get(arg).orElse(defaultFilesByRelPath.get(arg)).getOrElse { + unrecognizedFile(arg, logger) + } + System.out.write(f.content()) + } + } +} diff --git a/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala b/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala index 9c1c0c93e3..d36ee00333 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala @@ -33,6 +33,8 @@ class CliLogger( } } + def error(message: String) = + out.println(message) def message(message: => String) = if (verbosity >= 0) out.println(message) diff --git a/modules/core/src/main/scala/scala/build/Logger.scala b/modules/core/src/main/scala/scala/build/Logger.scala index ec5d361d96..47bb1264dd 100644 --- a/modules/core/src/main/scala/scala/build/Logger.scala +++ b/modules/core/src/main/scala/scala/build/Logger.scala @@ -9,6 +9,7 @@ import scala.build.errors.{BuildException, Diagnostic, Severity} import scala.scalanative.{build => sn} trait Logger { + def error(message: String): Unit // TODO Use macros for log and debug calls to have zero cost when verbosity <= 0 def message(message: => String): Unit def log(s: => String): Unit @@ -39,6 +40,7 @@ trait Logger { object Logger { private class Nop extends Logger { + def error(message: String): Unit = () def message(message: => String): Unit = () def log(s: => String): Unit = () def log(s: => String, debug: => String): Unit = () diff --git a/modules/integration/src/test/scala/scala/cli/integration/DefaultFileTests.scala b/modules/integration/src/test/scala/scala/cli/integration/DefaultFileTests.scala new file mode 100644 index 0000000000..f0f9fa99c9 --- /dev/null +++ b/modules/integration/src/test/scala/scala/cli/integration/DefaultFileTests.scala @@ -0,0 +1,25 @@ +package scala.cli.integration + +import com.eed3si9n.expecty.Expecty.expect + +class DefaultFileTests extends munit.FunSuite { + + test("Print .gitignore") { + val res = os.proc(TestUtil.cli, "default-file", ".gitignore") + .call() + val output = res.out.text() + expect(output.linesIterator.toVector.contains("/.scala-build/")) + } + + test("Write .gitignore") { + TestInputs(Nil).fromRoot { root => + os.proc(TestUtil.cli, "default-file", ".gitignore", "--write") + .call(cwd = root, stdout = os.Inherit) + val dest = root / ".gitignore" + expect(os.isFile(dest)) + val content = os.read(dest) + expect(content.linesIterator.toVector.contains("/.scala-build/")) + } + } + +} diff --git a/project/settings.sc b/project/settings.sc index ed7cb42737..49eeb8c590 100644 --- a/project/settings.sc +++ b/project/settings.sc @@ -141,6 +141,7 @@ def getGhToken(): String = trait CliLaunchers extends SbtModule { self => def launcherTypeResourcePath = os.rel / "scala" / "cli" / "internal" / "launcher-type.txt" + def defaultFilesResourcePath = os.rel / "scala" / "cli" / "commands" / "publish" trait CliNativeImage extends NativeImage { def launcherKind: String @@ -155,6 +156,7 @@ trait CliLaunchers extends SbtModule { self => Seq( s"-H:IncludeResources=$localRepoResourcePath", s"-H:IncludeResources=$launcherTypeResourcePath", + s"-H:IncludeResources=$defaultFilesResourcePath/.*", "-H:-ParseRuntimeOptions", s"-H:CLibraryPath=$cLibPath" ) @@ -730,10 +732,10 @@ private def doFormatNativeImageConf(dir: os.Path, format: Boolean): List[os.Path trait FormatNativeImageConf extends JavaModule { def nativeImageConfDirs = T { resources() - .map(_.path) + .map(_.path / "META-INF" / "native-image") .filter(os.exists(_)) .flatMap { path => - os.walk(path / "META-INF" / "native-image") + os.walk(path) .filter(_.last.endsWith("-config.json")) .filter(os.isFile(_)) .map(_ / os.up) diff --git a/website/docs/commands/misc/_category_.json b/website/docs/commands/misc/_category_.json new file mode 100644 index 0000000000..21661c4f68 --- /dev/null +++ b/website/docs/commands/misc/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Miscellaneous", + "position": 17 +} diff --git a/website/docs/commands/misc/default-file.md b/website/docs/commands/misc/default-file.md new file mode 100644 index 0000000000..1975e8af68 --- /dev/null +++ b/website/docs/commands/misc/default-file.md @@ -0,0 +1,28 @@ +--- +title: Default File +sidebar_position: 1 +--- + +The `default-file` sub-command provides sensible default content for files +such as `.gitignore` or for GitHub actions workflows, for Scala CLI projects. + +To list the available files, pass it `--list`: +```text +$ scala-cli default-file --list +.gitignore +.github/workflows/ci.yml +``` + +Get the content of a default file with +```text +$ scala-cli default-file .gitignore +/.bsp/ +/.scala-build/ +``` + +Optionally, write the content of one or more default files by passing `--write`: +```text +$ scala-cli default-file --write .gitignore .github/workflows/ci.yml +Wrote .gitignore +Wrote .github/workflows/ci.yml +``` diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index c0095cf7f0..7bf6536af9 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -266,6 +266,32 @@ Available in commands: Aliases: `-X` +## Default file options + +Available in commands: +- [`default-file`](./commands.md#default-file) + + + + +#### `--write` + +Write result to files rather than to stdout + +#### `--list` + +List available default files + +#### `--list-ids` + +List available default file ids + +#### `--force` + +Aliases: `-f` + +Force overwriting destination files + ## Dependency options Available in commands: @@ -433,6 +459,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`default-file`](./commands.md#default-file) - [`directories`](./commands.md#directories) - [`doc`](./commands.md#doc) - [`doctor`](./commands.md#doctor) @@ -661,6 +688,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`default-file`](./commands.md#default-file) - [`doc`](./commands.md#doc) - [`export`](./commands.md#export) - [`fmt` / `format` / `scalafmt`](./commands.md#fmt) @@ -1568,6 +1596,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`default-file`](./commands.md#default-file) - [`directories`](./commands.md#directories) - [`doc`](./commands.md#doc) - [`doctor`](./commands.md#doctor) diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 0cf0290be2..d29793fc9c 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -444,6 +444,13 @@ Accepts options: - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) +### `default-file` + +Accepts options: +- [default file](./cli-options.md#default-file-options) +- [logging](./cli-options.md#logging-options) +- [verbosity](./cli-options.md#verbosity-options) + ### `directories` Prints directories used by `scala-cli`