Initializing the ShortForm project.

ShortForm is an essay oriented discussion platform.
This commit is contained in:
Pat Garrity 2023-11-26 10:19:48 -06:00 committed by Pat Garrity
commit 937f0fe10c
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
16 changed files with 821 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
project/project
project/target
target/
dist/

11
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,11 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
# - repo: https://git.garrity.co/gs/gs-pre-commit-scala
# rev: v0.1.2
# hooks:
# - id: scalafmt

71
.scalafmt.conf Normal file
View file

@ -0,0 +1,71 @@
// See: https://github.com/scalameta/scalafmt/tags for the latest tags.
version = 3.7.14
runner.dialect = scala3
maxColumn = 80
rewrite {
rules = [RedundantBraces, RedundantParens, Imports, SortModifiers]
imports.expand = true
imports.sort = scalastyle
redundantBraces.ifElseExpressions = true
redundantBraces.stringInterpolation = true
}
indent {
main = 2
callSite = 2
defnSite = 2
extendSite = 4
withSiteRelativeToExtends = 2
commaSiteRelativeToExtends = 2
}
align {
preset = more
openParenCallSite = false
openParenDefnSite = false
}
newlines {
implicitParamListModifierForce = [before,after]
topLevelStatementBlankLines = [
{
blanks = 1
}
]
afterCurlyLambdaParams = squash
}
danglingParentheses {
defnSite = true
callSite = true
ctrlSite = true
exclude = []
}
verticalMultiline {
atDefnSite = true
arityThreshold = 2
newlineAfterOpenParen = true
}
comments {
wrap = standalone
}
docstrings {
style = "SpaceAsterisk"
oneline = unfold
wrap = yes
forceBlankLineBefore = true
}
project {
excludePaths = [
"glob:**target/**",
"glob:**.metals/**",
"glob:**.bloop/**",
"glob:**.bsp/**",
"glob:**metals.sbt"
]
}

38
Makefile Normal file
View file

@ -0,0 +1,38 @@
SHA = $(shell git rev-parse --short=7 HEAD)
VERSION = $(shell echo "$$(date +'%Y.%-m.%-d')-$(SHA)")
default: test
# Emit the calculated shortform version. Note that this version does not account
# for pre-release suffixes (-SNAPSHOT) -- it's the "raw" version.
version:
@echo $(VERSION)
# Clean up the shortform build. Note that this does NOT delete any releases from
# the `dist` directory. That should be managed manually.
clean:
sbt clean
# Compile all shortform source code.
compile:
sbt -Dversion="$(VERSION)" compile
# Run all unit tests.
test:
sbt -Dversion="$(VERSION)" test
# Run all integration tests.
integration:
sbt -Dversion="$(VERSION)" "db-integration-tests/test"
# Create a new release distribution of the shortform API.
# On disk, a versioned, compressed tarball will be produced in the `dist`
# directory.
api:
@echo "Releasing shortform-api: $(VERSION)"
@mkdir -p dist
sbt -Drelease=true -Dversion="$(VERSION)" "api / Universal / packageZipTarball"
@cp ./modules/api/target/universal/shortform-api-$(VERSION).tgz dist/
# TODO: Build container image using buildkit.
release: api

5
README.md Normal file
View file

@ -0,0 +1,5 @@
# Short Form
The Short Form (`shortform`) project is a web application designed for
presenting and facilitating discussion for essays, articles, or general user to
user interactions.

220
build.sbt Normal file
View file

@ -0,0 +1,220 @@
import java.time.LocalDate
val scala3: String = "3.3.1"
ThisBuild / organizationName := "garrity software"
ThisBuild / organization := "gs"
ThisBuild / organizationHomepage := Some(url("https://garrity.co/"))
ThisBuild / scalaVersion := scala3
//externalResolvers := Seq(
// "Garrity Software Releases" at "https://artifacts.garrity.co/releases"
//)
val ProjectName: String = "shortform"
val Description: String = "Presentation and discussion platform."
// Helper for getting properties from `-Dprop=value`.
def getProp[A](
name: String,
conv: String => A
): Option[A] =
Option(System.getProperty(name)).map(conv)
// Use `sbt -Dversion=<version>` to provide the version, minus the SNAPSHOT
// modifier. This is the typical approach for producing releases.
val VersionProperty: String = "version"
def getVersion(): Option[String] =
getProp(VersionProperty, identity)
// Use `sbt -Drelease=true` to trigger a release build.
val ReleaseProperty: String = "release"
def getModifier(): String =
if (getProp(ReleaseProperty, _.toBoolean).getOrElse(false)) ""
else "-SNAPSHOT"
// Use `sbt -Dsha=<commit>` to provide a commit SHA.
val ShaProperty: String = "sha"
def getSha(): String =
getProp(ShaProperty, identity).map(sha => s"-$sha").getOrElse("")
// Basis of CalVer, used if version is not supplied.
val Today: LocalDate = LocalDate.now()
// This is the base version of the published artifact. If the build is not a
// release, "-SNAPSHOT" will be appended.
val shortformVersion: String =
getVersion()
.map(v => s"$v${getModifier()}")
.getOrElse(
s"${Today.getYear()}.${Today.getMonthValue()}.${Today
.getDayOfMonth()}${getSha()}${getModifier()}"
)
// shortform does not publish any code artifacts.
val sharedSettings = Seq(
scalaVersion := scala3,
version := shortformVersion,
publish / skip := true,
publishLocal / skip := true,
publishArtifact := false
)
// All tests fork. These settings define all shared test dependencies.
lazy val testSettings = Seq(
Test / fork := true,
libraryDependencies ++= Seq(
"org.scalameta" %% "munit" % "1.0.0-M10" % Test
)
)
lazy val deps = new {
val SkunkCore: ModuleID =
"org.tpolecat" %% "skunk-core" % "1.0.0-M1"
val ScalacCompatAnnotation: ModuleID =
"org.typelevel" %% "scalac-compat-annotation" % "0.1.2"
val CatsEffect: ModuleID =
"org.typelevel" %% "cats-effect" % "3.5.2"
}
lazy val testDeps = new {
val TestContainersMunit: ModuleID =
"com.dimafeng" %% "testcontainers-scala-munit" % "0.41.0" % Test
val TestContainersPostgresql: ModuleID =
"com.dimafeng" %% "testcontainers-scala-postgresql" % "0.41.0" % Test
val Liquibase: ModuleID =
"org.liquibase" % "liquibase-core" % "4.24.0" % Test
val Postgresql: ModuleID =
"org.postgresql" % "postgresql" % "42.6.0" % Test
}
lazy val shortform = (project in file("."))
.aggregate(
uuid,
error,
crypto,
model,
db,
api
)
.settings(sharedSettings)
.settings(name := "shortform")
lazy val uuid = project
.in(file("modules/uuid"))
.settings(name := s"$ProjectName-uuid")
.settings(sharedSettings)
.settings(testSettings)
.settings(
libraryDependencies ++= Seq(
"com.fasterxml.uuid" % "java-uuid-generator" % "4.1.1"
)
)
lazy val error = project
.in(file("modules/error"))
.settings(name := s"$ProjectName-error")
.settings(sharedSettings)
.settings(testSettings)
.settings(
libraryDependencies ++= Seq(
)
)
lazy val crypto = project
.in(file("modules/crypto"))
.dependsOn(uuid, error)
.settings(name := s"$ProjectName-crypto")
.settings(sharedSettings)
.settings(testSettings)
.settings(
libraryDependencies ++= Seq(
)
)
lazy val model = project
.in(file("modules/model"))
.dependsOn(uuid, error, crypto)
.settings(name := s"$ProjectName-model")
.settings(sharedSettings)
.settings(testSettings)
.settings(
libraryDependencies ++= Seq(
)
)
lazy val db = project
.in(file("modules/db"))
.dependsOn(uuid, error, crypto, model)
.settings(name := s"$ProjectName-db")
.settings(sharedSettings)
.settings(testSettings)
.settings(
libraryDependencies ++= Seq(
deps.SkunkCore,
deps.ScalacCompatAnnotation
)
)
// Note: This task should NOT be aggregated at the top level. All integration
// tests should be manually invoked.
lazy val `db-integration-tests` = project
.in(file("modules/db-integration-tests"))
.dependsOn(db)
.settings(name := s"$ProjectName-db-integration-tests")
.settings(sharedSettings)
.settings(testSettings)
.settings(
scalacOptions := integrationScalacOptions,
libraryDependencies ++= Seq(
testDeps.TestContainersMunit,
testDeps.TestContainersPostgresql,
testDeps.Liquibase,
testDeps.Postgresql
)
)
lazy val api = project
.in(file("modules/api"))
.enablePlugins(JavaServerAppPackaging)
.dependsOn(uuid, error, crypto, model, db)
.settings(name := s"$ProjectName-api")
.settings(sharedSettings)
.settings(testSettings)
.settings(
run / fork := true,
libraryDependencies ++= Seq(
deps.CatsEffect
)
)
// Set Scala compiler option defaults.
ThisBuild / scalacOptions ++= allScalacOptions
lazy val allScalacOptions: Seq[String] = Seq(
"-encoding",
"utf8", // Set source file character encoding.
"-deprecation", // Emit warning and location for usages of deprecated APIs.
"-feature", // Emit warning and location for usages of features that should be imported explicitly.
"-explain", // Explain errors in more detail.
"-unchecked", // Enable additional warnings where generated code depends on assumptions.
"-explain-types", // Explain type errors in more detail.
"-Xfatal-warnings", // Fail the compilation if there are any warnings.
"-language:strictEquality", // Enable multiversal equality (require CanEqual)
"-Wunused:implicits", // Warn if an implicit parameter is unused.
"-Wunused:explicits", // Warn if an explicit parameter is unused.
"-Wunused:imports", // Warn if an import selector is not referenced.
"-Wunused:locals", // Warn if a local definition is unused.
"-Wunused:privates", // Warn if a private member is unused.
"-Ysafe-init" // Enable the experimental safe initialization check.
)
lazy val integrationScalacOptions = allScalacOptions
.filterNot(_ == "-Ysafe-init")

View file

@ -0,0 +1,19 @@
package gs.shortform.api
import cats.effect.ExitCode
import cats.effect.IO
import cats.effect.IOApp
object ApiMain extends IOApp:
/** Run ShortForm.
*
* @param args
* Command line arguments.
* @return
* 0 if successful, an integer error code otherwise.
*/
def run(args: List[String]): IO[ExitCode] =
IO(println("Running ShortForm API")).as(ExitCode.Success)
end ApiMain

View file

@ -0,0 +1,37 @@
package gs.shortform.crypto
import java.util.Base64
/** Represents any hashed value. All hashed values are Base64 encoded.
*
* @param value
* The Base64 encoded value of the hash.
*/
opaque type Hash = String
object Hash:
/** Base64 encode some raw hashed data and store it as a `Hash`.
*
* @param raw
* The raw data.
* @return
*/
def encode(raw: Array[Byte]): Hash =
Base64.getEncoder().encodeToString(raw)
/** Base64 decode some encoded, hashed data and return the raw hashed bytes.
*
* @param hash
* The hash to decode.
* @return
* The raw hashed bytes.
*/
def decode(hash: Hash): Array[Byte] =
Base64.getDecoder().decode(hash)
given CanEqual[Hash, Hash] = CanEqual.derived
extension (hash: Hash) def str: String = hash
end Hash

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
xmlns:pro="http://www.liquibase.org/xml/ns/pro"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd
http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd">
<include file="./gs/shortform/schema/1.sql"/>
</databaseChangeLog>

View file

@ -0,0 +1,9 @@
--liquibase formatted sql
--changeset pfm:1
--CREATE TABLE projects(
-- id BIGSERIAL PRIMARY KEY,
-- unique_slug TEXT NOT NULL UNIQUE,
-- display_name TEXT NOT NULL
--);
--rollback DROP TABLE IF EXISTS projects;

View file

@ -0,0 +1,5 @@
package gs.shortform.error
/** Base trait for all errors in ShortForm.
*/
trait ShortFormError

View file

@ -0,0 +1,216 @@
package gs.uuid;
import java.util.UUID;
import java.util.Arrays;
/**
* UUID serialization and deserialization. This is a direct copy of Jackson
* Databind (also under the Apache 2.0 license at time of writing) with
* extremely minor modifications to remove dashes from the output and to
* likewise support parsing with/without dashes.
*/
public final class UUIDFormat {
private UUIDFormat() {}
private final static char[] HEX_CHARS = "0123456789abcdef".toCharArray();
private final static int[] HEX_DIGITS = new int[127];
static {
Arrays.fill(HEX_DIGITS, -1);
for (int i = 0; i < 10; ++i) { HEX_DIGITS['0' + i] = i; }
for (int i = 0; i < 6; ++i) {
HEX_DIGITS['a' + i] = 10 + i;
HEX_DIGITS['A' + i] = 10 + i;
}
}
/**
* <p>Render the given UUID as a 32-character string using lowercase
* hexadecimal without dashes.</p>
*
* @param uuid The UUID to render.
* @return Hexadecimal representation of the UUID.
*/
public static String toHex(final UUID uuid) {
final char[] ch = new char[32];
// Example:
// 9bbe7b63-7928-49c8-a14f-67098b6e4642
final long msb = uuid.getMostSignificantBits();
// Handle the first 8 characters (9bbe7b63)
_appendInt((int) (msb >> 32), ch, 0);
int i = (int) msb;
// Handle the next 4 characters (7928) (Section 2)
_appendShort(i >>> 16, ch, 8);
// Handle the next 4 characters (49c8) (Section 3)
_appendShort(i, ch, 12);
final long lsb = uuid.getLeastSignificantBits();
// Handle the next 4 characters (a14ff) (Section 4)
_appendShort((int) (lsb >>> 48), ch, 16);
// Handle the next 4 characters (6709) (Section 5)
_appendShort((int) (lsb >>> 32), ch, 20);
// Handle the final 8 characters (8b6e4642) (Section 5)
_appendInt((int) lsb, ch, 24);
return new String(ch, 0, 32);
}
/**
* <p>Render the given UUID as a 16-byte array.</p>
*
* @param uuid The UUID to render.
* @return 16-byte array.
*/
public static byte[] toBytes(final UUID uuid) {
return _asBytes(uuid);
}
/**
* <p>Parse the given hexadecimal string as a UUID. This method supports
* both 32-character (no dash) and 36-character (dashes) representations,
* and will automatically choose based on input length.</p>
*
* @param id The string representation to parse.
* @return The parsed UUID.
*/
public static UUID fromHex(final String id) {
final int len = id.length();
if (len == 32) {
// Deserialize without dashes.
// Get the first 8 characters from index 0
long l1 = intFromChars(id, 0);
l1 <<= 32;
// Get the second 4 characters from index 8
long l2 = ((long) shortFromChars(id, 8)) << 16;
// Get the third 4 characters from index 12
l2 |= shortFromChars(id, 12);
long hi = l1 + l2;
// Get the next two sets of 4 characters from indexes 16 and 20
// respectively.
int i1 = (shortFromChars(id, 16) << 16) | shortFromChars(id, 20);
l1 = i1;
l1 <<= 32;
// Get the final 8 characters from index 24
l2 = intFromChars(id, 24);
l2 = (l2 << 32) >>> 32;
long lo = l1 | l2;
return new UUID(hi, lo);
} else if (len == 36) {
// Deserialize with dashes.
if ((id.charAt(8) != '-') || (id.charAt(13) != '-')
|| (id.charAt(18) != '-') || (id.charAt(23) != '-')) {
throw new IllegalArgumentException("Malformed UUID: 36-character representation does not contain correct dashes.");
}
long l1 = intFromChars(id, 0);
l1 <<= 32;
long l2 = ((long) shortFromChars(id, 9)) << 16;
l2 |= shortFromChars(id, 14);
long hi = l1 + l2;
int i1 = (shortFromChars(id, 19) << 16) | shortFromChars(id, 24);
l1 = i1;
l1 <<= 32;
l2 = intFromChars(id, 28);
l2 = (l2 << 32) >>> 32; // sign removal, Java-style. Ugh. [Note: Retained this comment from Jackson :) ]
long lo = l1 | l2;
return new UUID(hi, lo);
} else {
throw new IllegalArgumentException("UUID hexadecimal strings must be either 32 characters or 36 characters long.");
}
}
public static UUID fromBytes(final byte[] bytes) {
return _fromBytes(bytes);
}
private static void _appendShort(int bits, char[] ch, int offset) {
ch[offset] = HEX_CHARS[(bits >> 12) & 0xF];
ch[++offset] = HEX_CHARS[(bits >> 8) & 0xF];
ch[++offset] = HEX_CHARS[(bits >> 4) & 0xF];
ch[++offset] = HEX_CHARS[bits & 0xF];
}
private static void _appendInt(int bits, char[] ch, int offset) {
_appendShort(bits >> 16, ch, offset);
_appendShort(bits, ch, offset+4);
}
private final static void _appendInt(int value, byte[] buffer, int offset) {
buffer[offset] = (byte) (value >> 24);
buffer[++offset] = (byte) (value >> 16);
buffer[++offset] = (byte) (value >> 8);
buffer[++offset] = (byte) value;
}
private final static byte[] _asBytes(UUID uuid) {
byte[] buffer = new byte[16];
long hi = uuid.getMostSignificantBits();
long lo = uuid.getLeastSignificantBits();
_appendInt((int) (hi >> 32), buffer, 0);
_appendInt((int) hi, buffer, 4);
_appendInt((int) (lo >> 32), buffer, 8);
_appendInt((int) lo, buffer, 12);
return buffer;
}
private static int intFromChars(String str, int index) {
return (byteFromChars(str, index) << 24)
+ (byteFromChars(str, index+2) << 16)
+ (byteFromChars(str, index+4) << 8)
+ byteFromChars(str, index+6);
}
private static int shortFromChars(String str, int index) {
return (byteFromChars(str, index) << 8) + byteFromChars(str, index+2);
}
private static int byteFromChars(String str, int index) {
final char c1 = str.charAt(index);
final char c2 = str.charAt(index+1);
if (c1 <= 127 && c2 <= 127) {
int hex = (HEX_DIGITS[c1] << 4) | HEX_DIGITS[c2];
if (hex >= 0) {
return hex;
}
}
throw new IllegalArgumentException("Invalid hexadecimal character detected in byte at index " + index);
}
private static UUID _fromBytes(byte[] bytes) {
if (bytes.length != 16) {
throw new IllegalArgumentException("Can only construct UUIDs from byte[16]; got " + bytes.length + " bytes");
}
return new UUID(_long(bytes, 0), _long(bytes, 8));
}
private static long _long(byte[] b, int offset) {
long l1 = ((long) _int(b, offset)) << 32;
long l2 = _int(b, offset+4);
// faster to just do it than check if it has sign
l2 = (l2 << 32) >>> 32; // to get rid of sign
return l1 | l2;
}
private static int _int(byte[] b, int offset) {
return (b[offset] << 24) | ((b[offset+1] & 0xFF) << 16) | ((b[offset+2] & 0xFF) << 8) | (b[offset+3] & 0xFF);
}
}

View file

@ -0,0 +1,114 @@
package gs.uuid
import com.fasterxml.uuid.Generators
/** Alias for the `java.util.UUID` type, which represents a 128-bit value.
*
* ## ID Generation
*
* This library provides generator implementations for the following types of
* UUID:
*
* - Type 4
* - Type 7
*
* These implementations are provided by JUG.
*
* ## Serialization
*
* This library uses a custom variant of the JDK 17 implementation that removes
* dashes from the output and is likewise capable of parsing those values.
*
* {{{
* val example: UUID = UUID(java.util.UUID.randomUUID())
* val serialized = example.str() // or example.withoutDashes()
* // example value = 899efa6f40ed45189efa6f40ed9518ed
* }}}
*/
opaque type UUID = java.util.UUID
object UUID:
/** Express any `java.util.UUID` as a Meager UUID.
*
* @param uuid
* The input UUID.
* @return
* The aliased value.
*/
def apply(uuid: java.util.UUID): UUID = uuid
given CanEqual[UUID, UUID] = CanEqual.derived
/** Generate a new UUID.
*
* @param G
* The [[Generator]] type class instance.
* @return
* The new UUID.
*/
def generate(
)(
using
G: Generator
): UUID = G.next()
/** Parse the given string as a UUID.
*
* @param str
* The UUID, which is expected to be in a hexadecimal format with no
* dashes.
* @return
* The parsed UUID value, or `None` if the value does not represent a UUID.
*/
def parse(str: String): Option[UUID] = fromString(str)
def fromString(str: String): Option[UUID] =
scala.util
.Try(UUIDFormat.fromHex(str))
.map(uuid => Some(apply(uuid)))
.getOrElse(None)
extension (uid: UUID)
def toUUID(): java.util.UUID = uid
def str(): String = withoutDashes()
def withoutDashes(): String = UUIDFormat.toHex(uid)
def lsb(): Long = uid.getLeastSignificantBits()
def msb(): Long = uid.getMostSignificantBits()
def isZero(): Boolean = lsb() == 0L && msb() == 0L
/** Type class for UUID generation.
*/
trait Generator:
/** Generate a new UUID.
*/
def next(): UUID
object Generator:
/** Instantiate a new Type 4 generator.
*/
def version4: Generator = new Version4
/** Instantiate a new Type 7 generator.
*/
def version7: Generator = new Version7
/** Type 4 (Random) implementation of a UUID generator.
*/
final class Version4 extends Generator:
private val gen = Generators.randomBasedGenerator()
override def next(): UUID = gen.generate()
/** Type 7 (Unix Epoch Time + Random) implementation of a UUID generator.
* Consider using this rather than Type 1 or Type 6.
*
* This type is defined in [IETF New UUID Formats
* Draft](https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7)
*/
final class Version7 extends Generator:
private val gen = Generators.timeBasedEpochGenerator()
override def next(): UUID = gen.generate()

View file

@ -0,0 +1,57 @@
package gs.uuid
class UUIDTests extends munit.FunSuite:
private val v4 = UUID.Generator.version4
private val v7 = UUID.Generator.version7
given CanEqual[java.util.UUID, java.util.UUID] = CanEqual.derived
test(
"should instantiate a type 4 UUID, serialize it, and parse the result"
) {
val base = v4.next()
val str = base.str()
val parsed = UUID.parse(str)
assert(parsed == Some(base))
}
test(
"should instantiate a type 7 UUID, serialize it, and parse the result"
) {
val base = v7.next()
val str = base.str()
val parsed = UUID.parse(str)
assert(parsed == Some(base))
}
test("should instantiate from any java.util.UUID") {
val raw = java.util.UUID.randomUUID()
val base = UUID(raw)
val str = base.str()
val parsed = UUID.fromString(str)
assert(parsed == Some(base))
assert(parsed.map(_.toUUID()) == Some(raw))
}
test("should successfully parse a UUID with dashes") {
val base = java.util.UUID.randomUUID()
assert(UUID.parse(base.toString()) == Some(UUID(base)))
}
test("should fail to parse a non-hex string") {
val input = "ghijklmnoped45189efa6f40ed9518ed"
assert(UUID.parse(input) == None)
}
test("should generate using an available type class instance") {
given UUID.Generator = v7
val base = doGen
val str = base.str()
val parsed = UUID.parse(str)
assert(parsed == Some(base))
}
private def doGen(
using
UUID.Generator
): UUID =
UUID.generate()

1
project/build.properties Normal file
View file

@ -0,0 +1 @@
sbt.version=1.9.7

2
project/plugins.sbt Normal file
View file

@ -0,0 +1,2 @@
//addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9")
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4")