From 6c44f17c53b868b584895fcff14e4184dd01ef15 Mon Sep 17 00:00:00 2001 From: Alexandre Archambault Date: Wed, 18 May 2022 23:43:11 +0200 Subject: [PATCH] Add "config" command Allowing to persist some configuration values in a secured file under the user home directory, to be used by other Scala CLI commands. --- .../main/scala/scala/build/Directories.scala | 5 + .../cli/commands/config/ConfigOptions.scala | 25 +++ .../scala/scala/cli/ScalaCliCommands.scala | 2 + .../scala/cli/commands/Directories.scala | 1 + .../scala/cli/commands/config/Config.scala | 96 +++++++++++ .../scala/scala/cli/config/ConfigDb.scala | 163 ++++++++++++++++++ .../main/scala/scala/cli/config/Entries.scala | 32 ++++ .../main/scala/scala/cli/config/Entry.scala | 117 +++++++++++++ website/docs/commands/misc/config.md | 50 ++++++ website/docs/commands/misc/default-file.md | 2 +- website/docs/reference/cli-options.md | 19 ++ website/docs/reference/commands.md | 9 + 12 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 modules/cli-options/src/main/scala/scala/cli/commands/config/ConfigOptions.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/config/Config.scala create mode 100644 modules/cli/src/main/scala/scala/cli/config/ConfigDb.scala create mode 100644 modules/cli/src/main/scala/scala/cli/config/Entries.scala create mode 100644 modules/cli/src/main/scala/scala/cli/config/Entry.scala create mode 100644 website/docs/commands/misc/config.md diff --git a/modules/build/src/main/scala/scala/build/Directories.scala b/modules/build/src/main/scala/scala/build/Directories.scala index de56888dc8..a13a902a3d 100644 --- a/modules/build/src/main/scala/scala/build/Directories.scala +++ b/modules/build/src/main/scala/scala/build/Directories.scala @@ -13,6 +13,7 @@ trait Directories { def bspSocketDir: os.Path def bloopDaemonDir: os.Path def bloopWorkingDir: os.Path + def secretsDir: os.Path } object Directories { @@ -38,6 +39,8 @@ object Directories { else projDirs.dataLocalDir os.Path(baseDir, Os.pwd) / "bloop" } + lazy val secretsDir: os.Path = + os.Path(projDirs.dataLocalDir, Os.pwd) / "secrets" } final case class SubDir(dir: os.Path) extends Directories { @@ -55,6 +58,8 @@ object Directories { bloopWorkingDir / "daemon" lazy val bloopWorkingDir: os.Path = dir / "data-local" / "bloop" + lazy val secretsDir: os.Path = + dir / "data-local" / "secrets" } def default(): Directories = { diff --git a/modules/cli-options/src/main/scala/scala/cli/commands/config/ConfigOptions.scala b/modules/cli-options/src/main/scala/scala/cli/commands/config/ConfigOptions.scala new file mode 100644 index 0000000000..03dd7f64be --- /dev/null +++ b/modules/cli-options/src/main/scala/scala/cli/commands/config/ConfigOptions.scala @@ -0,0 +1,25 @@ +package scala.cli.commands.config + +import caseapp._ + +import scala.cli.commands.{CoursierOptions, LoggingOptions, SharedDirectoriesOptions} + +// format: off +final case class ConfigOptions( + @Recurse + logging: LoggingOptions = LoggingOptions(), + @Recurse + directories: SharedDirectoriesOptions = SharedDirectoriesOptions(), + @Recurse + coursier: CoursierOptions = CoursierOptions(), + @Hidden + dump: Boolean = false, + createKey: Boolean = false, + password: Boolean = false +) +// format: on + +object ConfigOptions { + implicit lazy val parser: Parser[ConfigOptions] = Parser.derive + implicit lazy val help: Help[ConfigOptions] = 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 3b0bd6c7a8..48d81f68be 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.config.Config import scala.cli.commands.default.DefaultFile import scala.cli.commands.github.{SecretCreate, SecretList} import scala.cli.commands.pgp.{PgpCommands, PgpCommandsSubst, PgpPull, PgpPush} @@ -34,6 +35,7 @@ class ScalaCliCommands( Bsp, Clean, Compile, + Config, DefaultFile, Directories, Doc, diff --git a/modules/cli/src/main/scala/scala/cli/commands/Directories.scala b/modules/cli/src/main/scala/scala/cli/commands/Directories.scala index 0ec2b0bf81..a642e591b9 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Directories.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Directories.scala @@ -23,5 +23,6 @@ object Directories extends ScalaCommand[DirectoriesOptions] { println("Virtual projects: " + directories.virtualProjectsDir) println("BSP sockets: " + directories.bspSocketDir) println("Bloop daemon directory: " + directories.bloopDaemonDir) + println("Secrets directory: " + directories.secretsDir) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala b/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala new file mode 100644 index 0000000000..33ff18a1c6 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala @@ -0,0 +1,96 @@ +package scala.cli.commands.config + +import caseapp.core.RemainingArgs + +import java.util.Base64 + +import scala.cli.commands.ScalaCommand +import scala.cli.commands.util.CommonOps._ +import scala.cli.config.{ConfigDb, Entries} +import scala.cli.signing.shared.PasswordOption + +object Config extends ScalaCommand[ConfigOptions] { + override def hidden = true + override def inSipScala = false + + def run(options: ConfigOptions, args: RemainingArgs): Unit = { + + val logger = options.logging.logger + val directories = options.directories.directories + + if (options.dump) { + val path = ConfigDb.dbPath(directories) + val content = os.read.bytes(path) + System.out.write(content) + } + else { + val db = ConfigDb.open(directories) + .orExit(logger) + + def unrecognizedKey(key: String): Nothing = { + System.err.println(s"Error: unrecognized key $key") + sys.exit(1) + } + + args.all match { + case Seq() => + if (options.createKey) { + val coursierCache = options.coursier.coursierCache(logger.coursierLogger("")) + val secKeyEntry = Entries.pgpSecretKey + val secKeyPasswordEntry = Entries.pgpSecretKeyPassword + val pubKeyEntry = Entries.pgpPublicKey + + val mail = db.get(Entries.userEmail).orExit(logger) + .getOrElse { + System.err.println("Error: user.email not set (required to generate PGP key)") + sys.exit(1) + } + + val password = ThrowawayPgpSecret.pgpPassPhrase() + val (pgpPublic, pgpSecret0) = + ThrowawayPgpSecret.pgpSecret(mail, password, logger, coursierCache) + .orExit(logger) + val pgpSecretBase64 = pgpSecret0.map(Base64.getEncoder.encodeToString) + + db.set(secKeyEntry, PasswordOption.Value(pgpSecretBase64)) + db.set(secKeyPasswordEntry, PasswordOption.Value(password)) + db.set(pubKeyEntry, PasswordOption.Value(pgpPublic)) + db.save(directories) + } + else { + System.err.println("No argument passed") + sys.exit(1) + } + case Seq(name, values @ _*) => + Entries.map.get(name) match { + case None => unrecognizedKey(name) + case Some(entry) => + if (values.isEmpty) { + val valueOpt = db.getAsString(entry).orExit(logger) + valueOpt match { + case Some(value) => + for (v <- value) + if (options.password && entry.isPasswordOption) + PasswordOption.parse(v) match { + case Left(err) => + System.err.println(err) + sys.exit(1) + case Right(passwordOption) => + val password = passwordOption.getBytes() + System.out.write(password.value) + } + else + println(v) + case None => + logger.debug(s"No value found for $name") + } + } + else { + db.setFromString(entry, values).orExit(logger) + db.save(directories) + } + } + } + } + } +} diff --git a/modules/cli/src/main/scala/scala/cli/config/ConfigDb.scala b/modules/cli/src/main/scala/scala/cli/config/ConfigDb.scala new file mode 100644 index 0000000000..f960847d63 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/config/ConfigDb.scala @@ -0,0 +1,163 @@ +package scala.cli.config + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ +import coursier.parse.RawJson + +import java.nio.file.attribute.PosixFilePermission + +import scala.build.Directories +import scala.build.errors.BuildException +import scala.collection.immutable.ListMap + +final class ConfigDb private ( + var rawEntries: Map[String, Array[Byte]] +) { + + def get[T](entry: Entry[T]): Either[ConfigDb.ConfigDbFormatError, Option[T]] = + rawEntries.get(entry.fullName) match { + case None => Right(None) + case Some(rawEntryContent) => + entry.parse(rawEntryContent) + .left.map { e => + new ConfigDb.ConfigDbFormatError(s"Error parsing ${entry.fullName} value", Some(e)) + } + .map(Some(_)) + } + + def set[T](entry: Entry[T], value: T): this.type = { + val b = entry.write(value) + rawEntries += entry.fullName -> b + this + } + + def getAsString[T](entry: Entry[T]): Either[ConfigDb.ConfigDbFormatError, Option[Seq[String]]] = + get(entry).map(_.map(entry.asString)) + + def setFromString[T]( + entry: Entry[T], + values: Seq[String] + ): Either[Entry.MalformedEntry, this.type] = + entry.fromString(values).map { typedValue => + set(entry, typedValue) + } + + def dump: Array[Byte] = { + + def serializeMap(m: Map[String, Array[Byte]]): Array[Byte] = { + val keyValues = m + .groupBy(_._1.split("\\.", 2).apply(0)) + .toVector + .sortBy(_._1) + .map { + case (k, v) => + val v0 = v.map { + case (k1, v1) => + (k1.stripPrefix(k).stripPrefix("."), v1) + } + (k, serialize(v0)) + } + val sortedMap: Map[String, RawJson] = ListMap.from(keyValues) + writeToArray(sortedMap)(ConfigDb.codec) + } + + def serialize(m: Map[String, Array[Byte]]): RawJson = + m.get("") match { + case Some(value) => + if (m.size == 1) + RawJson(value) + else + sys.error(s"Inconsistent keys: ${m.keySet.toVector.sorted}") + case None => + RawJson(serializeMap(m)) + } + + serializeMap(rawEntries) + } + + def saveUnsafe(path: os.Path): Either[ConfigDb.ConfigDbPermissionsError, Unit] = { + val dir = path / os.up + if (!os.exists(dir)) + os.makeDir.all(dir, "rwx------") + val dirPerms = os.perms(dir) + val hasWrongPerms = + dirPerms.contains(PosixFilePermission.GROUP_READ) || + dirPerms.contains(PosixFilePermission.GROUP_WRITE) || + dirPerms.contains(PosixFilePermission.GROUP_EXECUTE) || + dirPerms.contains(PosixFilePermission.OTHERS_READ) || + dirPerms.contains(PosixFilePermission.OTHERS_WRITE) || + dirPerms.contains(PosixFilePermission.OTHERS_EXECUTE) + if (hasWrongPerms) + Left(new ConfigDb.ConfigDbPermissionsError(path, dirPerms)) + else { + os.write.over(path, dump, perms = "rw-------", createFolders = false) + Right(()) + } + } + def save(directories: Directories): Either[BuildException, Unit] = { + // file locks… + val path = ConfigDb.dbPath(directories) + saveUnsafe(path) + } +} + +object ConfigDb { + + def dbPath(directories: Directories): os.Path = + directories.secretsDir / defaultDbFileName + + final class ConfigDbFormatError( + message: String, + causeOpt: Option[Throwable] = None + ) extends BuildException(message, cause = causeOpt.orNull) + + final class ConfigDbPermissionsError(path: os.Path, perms: os.PermSet) + extends BuildException(s"$path has wrong permissions $perms (expected rwx------)") + + private val codec: JsonValueCodec[Map[String, RawJson]] = JsonCodecMaker.make + + def apply( + dbContent: Array[Byte], + printablePath: Option[String] = None + ): Either[ConfigDbFormatError, ConfigDb] = { + + def flatten(map: Map[String, RawJson]): Map[String, Array[Byte]] = + map.flatMap { + case (k, v) => + try { + val subMap = flatten(readFromArray(v.value)(codec)) + subMap.toSeq.map { + case (k0, v0) => + (k + "." + k0, v0) + } + } + catch { + case _: JsonReaderException => + Seq(k -> v.value) + } + } + + val maybeRawEntries = + try Right(flatten(readFromArray(dbContent)(codec))) + catch { + case e: JsonReaderException => + Left(new ConfigDbFormatError( + "Error parsing config DB" + printablePath.fold("")(" " + _), + Some(e) + )) + } + + maybeRawEntries.map(rawEntries => new ConfigDb(rawEntries)) + } + + def defaultDbFileName: String = + "config.json" + + def open(path: os.Path): Either[BuildException, ConfigDb] = + if (os.exists(path)) + apply(os.read.bytes(path), Some(path.toString)) + else + Right(new ConfigDb(Map())) + def open(directories: Directories): Either[BuildException, ConfigDb] = + open(dbPath(directories)) +} diff --git a/modules/cli/src/main/scala/scala/cli/config/Entries.scala b/modules/cli/src/main/scala/scala/cli/config/Entries.scala new file mode 100644 index 0000000000..6cdb4bbbec --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/config/Entries.scala @@ -0,0 +1,32 @@ +package scala.cli.config + +object Entries { + + val userName = new Entry.StringEntry(Seq("user"), "name") + val userEmail = new Entry.StringEntry(Seq("user"), "email") + val userUrl = new Entry.StringEntry(Seq("user"), "url") + + val ghToken = new Entry.PasswordEntry(Seq("github"), "token") + + val pgpSecretKey = new Entry.PasswordEntry(Seq("pgp"), "secret-key") + val pgpSecretKeyPassword = new Entry.PasswordEntry(Seq("pgp"), "secret-key-password") + val pgpPublicKey = new Entry.PasswordEntry(Seq("pgp"), "public-key") + + val sonatypeUser = new Entry.PasswordEntry(Seq("sonatype"), "user") + val sonatypePassword = new Entry.PasswordEntry(Seq("sonatype"), "password") + + def all = Seq[Entry[_]]( + userName, + userEmail, + userUrl, + ghToken, + pgpSecretKey, + pgpSecretKeyPassword, + pgpPublicKey, + sonatypeUser, + sonatypePassword + ) + + lazy val map = all.map(e => e.fullName -> e).toMap + +} diff --git a/modules/cli/src/main/scala/scala/cli/config/Entry.scala b/modules/cli/src/main/scala/scala/cli/config/Entry.scala new file mode 100644 index 0000000000..be42e934db --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/config/Entry.scala @@ -0,0 +1,117 @@ +package scala.cli.config + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ + +import scala.build.errors.BuildException +import scala.cli.signing.shared.PasswordOption + +sealed abstract class Entry[T] { + def prefix: Seq[String] + def name: String + + def parse(json: Array[Byte]): Either[Entry.EntryError, T] + def write(value: T): Array[Byte] + + def asString(value: T): Seq[String] + def fromString(values: Seq[String]): Either[Entry.MalformedEntry, T] + + final def fullName = (prefix :+ name).mkString(".") + + def isPasswordOption: Boolean = false +} + +object Entry { + + abstract class EntryError( + message: String, + causeOpt: Option[Throwable] = None + ) extends BuildException(message, cause = causeOpt.orNull) + + final class JsonReaderError(cause: JsonReaderException) + extends EntryError("Error parsing config JSON", Some(cause)) + + final class MalformedEntry( + entry: Entry[_], + input: Seq[String], + messageOrExpectedShape: Either[String, String], + cause: Option[Throwable] = None + ) extends EntryError( + s"Malformed values ${input.mkString(", ")} for ${entry.fullName}, " + + messageOrExpectedShape.fold(shape => s"expected $shape", identity), + cause + ) + + private val stringCodec: JsonValueCodec[String] = JsonCodecMaker.make + + final class StringEntry( + val prefix: Seq[String], + val name: String + ) extends Entry[String] { + def parse(json: Array[Byte]): Either[EntryError, String] = + try Right(readFromArray(json)(stringCodec)) + catch { + case e: JsonReaderException => + Left(new JsonReaderError(e)) + } + def write(value: String): Array[Byte] = + writeToArray(value)(stringCodec) + def asString(value: String): Seq[String] = + Seq(value) + def fromString(values: Seq[String]): Either[MalformedEntry, String] = + values match { + case Seq(value) => Right(value) + case _ => Left(new MalformedEntry(this, values, Left("value"))) + } + } + + final class PasswordEntry( + val prefix: Seq[String], + val name: String + ) extends Entry[PasswordOption] { + def parse(json: Array[Byte]): Either[EntryError, PasswordOption] = + try { + val str = readFromArray(json)(stringCodec) + PasswordOption.parse(str).left.map { e => + new MalformedEntry(this, Seq(str), Right(e)) + } + } + catch { + case e: JsonReaderException => + Left(new JsonReaderError(e)) + } + def write(value: PasswordOption): Array[Byte] = + writeToArray(value.asString.value)(stringCodec) + def asString(value: PasswordOption): Seq[String] = Seq(value.asString.value) + def fromString(values: Seq[String]): Either[MalformedEntry, PasswordOption] = + values match { + case Seq(value) => + PasswordOption.parse(value).left.map { err => + new MalformedEntry(this, values, Right(err)) + } + case _ => Left(new MalformedEntry(this, values, Left("value"))) + } + + override def isPasswordOption: Boolean = true + } + + private val stringListCodec: JsonValueCodec[List[String]] = JsonCodecMaker.make + + final class StringListEntry( + val prefix: Seq[String], + val name: String + ) extends Entry[List[String]] { + def parse(json: Array[Byte]): Either[EntryError, List[String]] = + try Right(readFromArray(json)(stringListCodec)) + catch { + case e: JsonReaderException => + Left(new JsonReaderError(e)) + } + def write(value: List[String]): Array[Byte] = + writeToArray(value)(stringListCodec) + def asString(value: List[String]): Seq[String] = value + def fromString(values: Seq[String]): Either[MalformedEntry, List[String]] = + Right(values.toList) + } + +} diff --git a/website/docs/commands/misc/config.md b/website/docs/commands/misc/config.md new file mode 100644 index 0000000000..5e5eec0dae --- /dev/null +++ b/website/docs/commands/misc/config.md @@ -0,0 +1,50 @@ +--- +title: Config +sidebar_position: 1 +--- + +The `config` sub-command allows to get and set various configuration values, used by +other Scala CLI sub-commands. + +Examples of use: +```text +$ scala-cli config user.name "Alex Me" +$ scala-cli config user.name +Alex Me +``` + +The `--dump` option allows to print all config entries in JSON format: +```text +$ scala-cli config --dump | jq +{ + "github": { + "token": "value:qWeRtYuIoP" + }, + "pgp": { + "public-key": "value:-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: BCPG v1.68\n\n…\n-----END PGP PUBLIC KEY BLOCK-----\n", + "secret-key": "value:…", + "secret-key-password": "value:1234" + }, + "user": { + "email": "alex@alex.me", + "name": "Alex Me", + "url": "https://alex.me" + } +} +``` + +Use `--password` to set an entry expecting a secret value: +```text +$ scala-cli config --password github.token "file:$HOME/.secrets/scala-cli-gh-token" +``` + +Use `--create-key` to create a PGP key pair, protected by a randomly-generated password, to +be used by the `publish setup` sub-command: +```text +$ scala-cli config --create-key +``` + +Secrets are stored in a directory under your home directory, with restricted permissions: +- on macOS: `~/Library/Application Support/ScalaCli/secrets/config.json` +- on Linux: `~/.config/scala-cli/secrets/config.json` +- on Windows: `%LOCALAPPDATA%\ScalaCli\secrets\config.json` (typically `C:\Users\username\AppData\Local\ScalaCli\secrets\config.json`) diff --git a/website/docs/commands/misc/default-file.md b/website/docs/commands/misc/default-file.md index 1975e8af68..1842c7843f 100644 --- a/website/docs/commands/misc/default-file.md +++ b/website/docs/commands/misc/default-file.md @@ -1,6 +1,6 @@ --- title: Default File -sidebar_position: 1 +sidebar_position: 2 --- The `default-file` sub-command provides sensible default content for files diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 7bf6536af9..c594fe63a6 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -217,6 +217,20 @@ Aliases: `-X` Cross-compile sources +## Config options + +Available in commands: +- [`config`](./commands.md#config) + + + + +#### `--dump` + +#### `--create-key` + +#### `--password` + ## Coursier options Available in commands: @@ -224,6 +238,7 @@ Available in commands: - [`bloop start`](./commands.md#bloop-start) - [`bsp`](./commands.md#bsp) - [`compile`](./commands.md#compile) +- [`config`](./commands.md#config) - [`doc`](./commands.md#doc) - [`export`](./commands.md#export) - [`fmt` / `format` / `scalafmt`](./commands.md#fmt) @@ -340,6 +355,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`config`](./commands.md#config) - [`directories`](./commands.md#directories) - [`doc`](./commands.md#doc) - [`export`](./commands.md#export) @@ -459,6 +475,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`config`](./commands.md#config) - [`default-file`](./commands.md#default-file) - [`directories`](./commands.md#directories) - [`doc`](./commands.md#doc) @@ -688,6 +705,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`config`](./commands.md#config) - [`default-file`](./commands.md#default-file) - [`doc`](./commands.md#doc) - [`export`](./commands.md#export) @@ -1596,6 +1614,7 @@ Available in commands: - [`bsp`](./commands.md#bsp) - [`clean`](./commands.md#clean) - [`compile`](./commands.md#compile) +- [`config`](./commands.md#config) - [`default-file`](./commands.md#default-file) - [`directories`](./commands.md#directories) - [`doc`](./commands.md#doc) diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index d29793fc9c..012951138f 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -444,6 +444,15 @@ Accepts options: - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) +### `config` + +Accepts options: +- [config](./cli-options.md#config-options) +- [coursier](./cli-options.md#coursier-options) +- [directories](./cli-options.md#directories-options) +- [logging](./cli-options.md#logging-options) +- [verbosity](./cli-options.md#verbosity-options) + ### `default-file` Accepts options: