feat: CLI parser, restic discovery+download, pull/push via restic CLI
CI / build (push) Failing after 2s
CI / release (push) Has been skipped

- Args.kt: parses --url, --pack-folder, --token-file, --restic-binary,
    --no-download, -g/--no-gui. Inline and space-separated value forms.
  - Restic.kt: locates restic via 1) --restic-binary override, 2) cached
    <pack-folder>/.cloud-sync/restic-<ver>, 3) system PATH (version match),
    4) auto-download from github releases + sha256 verify against SHA256SUMS.
    bz2 decompression via commons-compress (bzcat fallback).
  - Scope.kt: per-distribution cloud-scope.json with sensible defaults
    (options.txt, config/, journeymap/data/, screenshots/). Auto-excludes
    .cloud-sync/ so we never leak our own credentials.
  - Sync.kt: pull = restic restore latest --target <pack-folder>;
    push = restic backup --files-from <generated> --exclude-file <generated>.
    Empty repos handled (pull is no-op when no snapshots yet).
  - 18 tests pass. Fat jar grew to 6 MB (commons-compress).
This commit is contained in:
2026-06-02 22:41:00 +02:00
parent df02f8a441
commit 31062e98b9
9 changed files with 731 additions and 16 deletions
+4
View File
@@ -21,6 +21,10 @@ dependencies {
// JSON via Kotlin's official lib; supports kotlin data classes natively.
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// bz2 decompression of restic release archives without shelling out to
// system bzcat (covers stripped-down containers).
implementation("org.apache.commons:commons-compress:1.27.1")
testImplementation(kotlin("test"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
@@ -0,0 +1,98 @@
package center.timemachine.cloud
import java.nio.file.Path
import java.nio.file.Paths
/**
* Parsed command-line arguments shared by pull + push subcommands.
*
* Flag style matches packwiz-installer-bootstrap (`--url=` or `--url value`,
* `-g` shorthand) so operators wiring Prism's PreLaunch/PostExit hooks
* don't have to relearn the surface.
*/
data class Args(
val url: String,
val packFolder: Path,
val tokenFile: Path,
val resticBinary: Path?, // null = auto-discover
val allowDownload: Boolean, // false = --no-download
val headless: Boolean,
)
/**
* Thrown when args fail validation. Message is shown to the user;
* the launcher caller sees a non-zero exit but the message is what
* tells the operator what to fix.
*/
class ArgParseException(message: String) : RuntimeException(message)
/**
* Parse argv for a sync subcommand. Caller already stripped the
* subcommand name (pull/push) — `args` is everything after.
*/
fun parseArgs(args: Array<String>): Args {
var url: String? = null
var packFolder: String? = null
var tokenFile: String? = null
var resticBinary: String? = null
var allowDownload = true
var headless = false
val iter = args.iterator()
while (iter.hasNext()) {
val raw = iter.next()
val (flag, valueInline) = splitFlag(raw)
when (flag) {
"--url" -> url = valueInline ?: takeValue(iter, flag)
"--pack-folder" -> packFolder = valueInline ?: takeValue(iter, flag)
"--token-file" -> tokenFile = valueInline ?: takeValue(iter, flag)
"--restic-binary" -> resticBinary = valueInline ?: takeValue(iter, flag)
"--no-download" -> {
rejectInlineValue(flag, valueInline)
allowDownload = false
}
"-g", "--no-gui" -> {
rejectInlineValue(flag, valueInline)
headless = true
}
else -> throw ArgParseException("unknown flag: $raw")
}
}
if (url.isNullOrBlank()) {
throw ArgParseException("--url is required (cloud-svc data plane URL, e.g. https://cloud.tm.center)")
}
val packPath: Path = packFolder?.let { Paths.get(it) } ?: Paths.get(".")
val tokenPath: Path = tokenFile?.let { Paths.get(it) }
?: packPath.resolve(".cloud-sync").resolve("token")
val resticPath: Path? = resticBinary?.let { Paths.get(it) }
return Args(
url = url,
packFolder = packPath.toAbsolutePath().normalize(),
tokenFile = tokenPath,
resticBinary = resticPath,
allowDownload = allowDownload,
headless = headless,
)
}
/** Split "--flag=value" into ("--flag", "value"). Returns (raw, null) when no '=' present. */
private fun splitFlag(raw: String): Pair<String, String?> {
val eq = raw.indexOf('=')
return if (eq == -1) raw to null else raw.substring(0, eq) to raw.substring(eq + 1)
}
private fun takeValue(iter: Iterator<String>, flag: String): String {
if (!iter.hasNext()) {
throw ArgParseException("$flag requires a value")
}
return iter.next()
}
private fun rejectInlineValue(flag: String, value: String?) {
if (value != null) {
throw ArgParseException("$flag does not take a value")
}
}
+18 -12
View File
@@ -1,20 +1,26 @@
package center.timemachine.cloud
/**
* Subcommand dispatchers. Skeleton — real arg parsing + sync logic lands
* in subsequent tasks. For now these print intent and exit cleanly so the
* fat jar can be smoke-tested end-to-end through Prism / frazclient.
* Subcommand dispatchers. Real work happens in Sync.kt; this layer
* parses args, surfaces ArgParseException as a clean error, and
* funnels through to pull/push.
*/
object Cli {
fun runPull(args: Array<String>): Int {
println("cloud-sync pull: ${args.joinToString(" ")}")
println("(not yet implemented; skeleton commit only)")
return 0
}
fun runPull(args: Array<String>): Int = run("pull", args, ::pull)
fun runPush(args: Array<String>): Int = run("push", args, ::push)
fun runPush(args: Array<String>): Int {
println("cloud-sync push: ${args.joinToString(" ")}")
println("(not yet implemented; skeleton commit only)")
return 0
private fun run(name: String, raw: Array<String>, action: (Args) -> Int): Int {
val parsed = try {
parseArgs(raw)
} catch (e: ArgParseException) {
System.err.println("cloud-sync $name: ${e.message}")
return 2
}
return try {
action(parsed)
} catch (e: Exception) {
System.err.println("cloud-sync $name: ${e.message ?: e.toString()}")
2
}
}
}
@@ -0,0 +1,246 @@
package center.timemachine.cloud
import java.io.BufferedInputStream
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE
import java.nio.file.attribute.PosixFilePermission.OWNER_READ
import java.nio.file.attribute.PosixFilePermission.OWNER_WRITE
import java.security.MessageDigest
import java.time.Duration
import java.util.concurrent.TimeUnit
import kotlin.io.path.exists
import kotlin.io.path.isExecutable
/**
* restic binary discovery + auto-download + invocation.
*
* Discovery order:
* 1. --restic-binary <path> (explicit override)
* 2. <pack-folder>/.cloud-sync/restic-<RESTIC_VERSION> (pinned cached copy)
* 3. system `restic` on PATH (only if version matches RESTIC_VERSION)
* 4. download from GitHub releases (unless --no-download disables)
*
* Pinning the version simplifies cross-platform behaviour — repos written by
* one version may have features another version can't read. Cache the pinned
* binary per-instance so removing the instance dir removes everything.
*/
object Restic {
const val RESTIC_VERSION = "0.18.0"
/** Tag of the GitHub release we download from. */
private const val RELEASE_TAG = "v$RESTIC_VERSION"
/**
* Resolve which restic binary to use, downloading if necessary. Throws
* IOException if discovery + download both fail.
*/
fun resolveBinary(args: Args): Path {
args.resticBinary?.let { p ->
require(p.exists()) { "--restic-binary path does not exist: $p" }
return p.toAbsolutePath()
}
val cacheDir = args.packFolder.resolve(".cloud-sync")
val expectedName = if (isWindows()) "restic-$RESTIC_VERSION.exe" else "restic-$RESTIC_VERSION"
val cached = cacheDir.resolve(expectedName)
if (cached.exists() && cached.isExecutable()) {
return cached
}
// Try $PATH only if its version matches the pinned one — different versions
// can produce incompatible repos, so we don't trust an "any restic on PATH" install.
findSystemBinaryMatchingVersion()?.let { return it }
if (!args.allowDownload) {
throw IOException(
"no usable restic binary found at $cached or on \$PATH, " +
"and --no-download disabled the fetch from upstream"
)
}
Files.createDirectories(cacheDir)
downloadResticTo(cached)
return cached
}
/**
* Run restic with the given args + env. Inherits stderr to the caller's
* terminal for live progress. Returns (exitCode, stdout).
*/
fun run(
binary: Path,
args: List<String>,
env: Map<String, String>,
cwd: Path? = null,
): Pair<Int, String> {
val pb = ProcessBuilder(listOf(binary.toString()) + args)
pb.redirectError(ProcessBuilder.Redirect.INHERIT)
cwd?.let { pb.directory(it.toFile()) }
// Carry over caller env; overlay our values
pb.environment().putAll(env)
val p = pb.start()
val out = p.inputStream.bufferedReader().use { it.readText() }
val ok = p.waitFor(15, TimeUnit.MINUTES)
if (!ok) {
p.destroyForcibly()
throw IOException("restic timed out after 15 minutes (cmd: ${args.joinToString(" ")})")
}
return p.exitValue() to out
}
// ----------------------------------------------------------------- internals
private fun isWindows() = System.getProperty("os.name").lowercase().contains("windows")
private fun osTag() = when {
isWindows() -> "windows"
System.getProperty("os.name").lowercase().contains("mac") -> "darwin"
else -> "linux"
}
private fun archTag() = when (val a = System.getProperty("os.arch").lowercase()) {
"amd64", "x86_64" -> "amd64"
"aarch64", "arm64" -> "arm64"
else -> error("unsupported architecture for restic auto-download: $a")
}
private fun findSystemBinaryMatchingVersion(): Path? {
val pathEnv = System.getenv("PATH") ?: return null
val name = if (isWindows()) "restic.exe" else "restic"
for (dir in pathEnv.split(System.getProperty("path.separator"))) {
val cand = Path.of(dir, name)
if (cand.exists() && cand.isExecutable()) {
if (resticVersion(cand) == RESTIC_VERSION) {
return cand.toAbsolutePath()
}
}
}
return null
}
private fun resticVersion(binary: Path): String? {
return try {
val p = ProcessBuilder(binary.toString(), "version").redirectErrorStream(true).start()
val out = p.inputStream.bufferedReader().use { it.readText() }
p.waitFor(5, TimeUnit.SECONDS)
// Output line: "restic 0.18.0 compiled with go..."
val regex = Regex("""restic\s+(\d+\.\d+\.\d+)""")
regex.find(out)?.groupValues?.get(1)
} catch (_: Exception) {
null
}
}
private fun downloadResticTo(target: Path) {
val ext = if (osTag() == "windows") "zip" else "bz2"
val asset = "restic_${RESTIC_VERSION}_${osTag()}_${archTag()}.$ext"
val url = "https://github.com/restic/restic/releases/download/$RELEASE_TAG/$asset"
System.err.println("cloud-sync: downloading restic $RESTIC_VERSION from $url")
val client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(15))
.build()
val tmp = Files.createTempFile("restic-dl-", ".$ext")
try {
val req = HttpRequest.newBuilder(URI.create(url)).GET().build()
val resp = client.send(req, HttpResponse.BodyHandlers.ofFile(tmp))
if (resp.statusCode() != 200) {
throw IOException("restic download failed: HTTP ${resp.statusCode()} for $url")
}
// Also fetch SHA256SUMS file to verify integrity
val sumsUrl = "https://github.com/restic/restic/releases/download/$RELEASE_TAG/SHA256SUMS"
val sumsReq = HttpRequest.newBuilder(URI.create(sumsUrl)).GET().build()
val sumsBody = client.send(sumsReq, HttpResponse.BodyHandlers.ofString()).body()
val expectedSha = sumsBody.lineSequence()
.map { it.trim() }
.firstOrNull { it.endsWith(asset) }
?.split(Regex("""\s+"""))
?.firstOrNull()
?: throw IOException("restic SHA256SUMS missing entry for $asset")
val actualSha = sha256OfFile(tmp)
if (actualSha != expectedSha.lowercase()) {
throw IOException("restic download sha mismatch: expected $expectedSha, got $actualSha")
}
decompressInto(tmp, target, ext)
makeExecutable(target)
} finally {
Files.deleteIfExists(tmp)
}
}
private fun sha256OfFile(path: Path): String {
val md = MessageDigest.getInstance("SHA-256")
BufferedInputStream(Files.newInputStream(path)).use { input ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n <= 0) break
md.update(buf, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it) }
}
private fun decompressInto(archive: Path, target: Path, ext: String) {
Files.createDirectories(target.parent)
when (ext) {
"bz2" -> decompressBzip2(archive, target)
"zip" -> decompressZip(archive, target)
else -> error("unsupported archive ext: $ext")
}
}
/** Minimal bz2 → file decoder using Apache Commons Compress's algorithm
* via JDK's built-in classes? No such thing exists in JDK; we shell out
* to bzcat instead, which is universally available on Linux/macOS. */
private fun decompressBzip2(archive: Path, target: Path) {
// Try Java decoder if Commons Compress shaded in; otherwise fall back.
try {
val cls = Class.forName("org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream")
val ctor = cls.getConstructor(java.io.InputStream::class.java)
Files.newInputStream(archive).use { src ->
val bzin = ctor.newInstance(src) as java.io.InputStream
Files.newOutputStream(target).use { out -> bzin.copyTo(out) }
}
return
} catch (_: ClassNotFoundException) {
// continue to fallback
}
val p = ProcessBuilder("bzcat", archive.toString()).redirectError(ProcessBuilder.Redirect.INHERIT).start()
Files.newOutputStream(target).use { out -> p.inputStream.copyTo(out) }
if (!p.waitFor(60, TimeUnit.SECONDS) || p.exitValue() != 0) {
throw IOException("bzcat failed to decompress restic; install commons-compress or bzip2")
}
}
private fun decompressZip(archive: Path, target: Path) {
java.util.zip.ZipInputStream(Files.newInputStream(archive)).use { zin ->
var e = zin.nextEntry
while (e != null) {
if (!e.isDirectory && e.name.endsWith("restic.exe")) {
Files.newOutputStream(target).use { out -> zin.copyTo(out) }
return
}
e = zin.nextEntry
}
}
throw IOException("restic.exe not found in downloaded zip")
}
private fun makeExecutable(path: Path) {
if (isWindows()) return
try {
val perms = setOf(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE)
Files.setPosixFilePermissions(path, perms)
} catch (_: UnsupportedOperationException) {
// non-POSIX FS — best effort
}
}
}
@@ -0,0 +1,80 @@
package center.timemachine.cloud
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
/**
* Per-distribution scope file. Each cloud-sync.jar deployment ships its
* own scope.json that picks which files participate in sync. Path under
* <pack-folder>/.cloud-sync/scope.json.
*
* Defaults are baked in so a fresh install with no scope.json still does
* something sensible.
*/
@Serializable
data class Scope(
val include: List<String> = DEFAULT_INCLUDE,
val exclude: List<String> = DEFAULT_EXCLUDE,
)
private val DEFAULT_INCLUDE = listOf(
"options.txt",
"optionsof.txt",
"optionsshaders.txt",
"config/",
"journeymap/data/",
"screenshots/",
)
private val DEFAULT_EXCLUDE = listOf(
".cloud-sync/", // never sync our own state dir
".cloud-token", // legacy location (frazclient pre-jar era)
"config/simple-mod-sync*",
"config/packwiz*",
"**/cache/",
"**/*.log",
"**/*.tmp",
)
object Scope_ {
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
fun load(packFolder: Path): Scope {
val path = packFolder.resolve(".cloud-sync").resolve("scope.json")
if (!path.exists()) return Scope()
return try {
json.decodeFromString(Scope.serializer(), Files.readString(path))
} catch (e: Exception) {
System.err.println("cloud-sync: invalid scope.json (${e.message}); using defaults")
Scope()
}
}
/**
* Write absolute paths of include + a restic --exclude-file alongside.
* restic's --files-from accepts ABSOLUTE OR relative paths but exclude
* patterns are matched against the file path being processed. Including
* directories in --files-from causes restic to recurse into them
* automatically.
*/
fun materializeForRestic(packFolder: Path, scope: Scope): Pair<Path, Path> {
val dir = packFolder.resolve(".cloud-sync")
Files.createDirectories(dir)
val filesFrom = dir.resolve("files-from.txt")
val excludeFrom = dir.resolve("exclude-from.txt")
Files.writeString(
filesFrom,
scope.include.joinToString(System.lineSeparator()) { trimTrailingSlash(it) },
)
Files.writeString(
excludeFrom,
scope.exclude.joinToString(System.lineSeparator()),
)
return filesFrom to excludeFrom
}
private fun trimTrailingSlash(s: String) = if (s.endsWith("/")) s.dropLast(1) else s
}
@@ -0,0 +1,131 @@
package center.timemachine.cloud
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
/**
* Read token-file. Format: "discord_id:password" on a single line. Whitespace
* tolerated. Returns (discordId, password). Throws if missing or malformed.
*
* The Discord ID is the URL path segment under cloud.tm.center/<id>/ that
* restic-rest-server's --private-repos enforces against the basic-auth
* username. The password is the bcrypt'd entry's plaintext.
*/
fun readCredentials(tokenFile: Path): Pair<String, String> {
if (!tokenFile.exists()) {
throw IOException(
"cloud-sync token not found at $tokenFile. " +
"After /register in Discord you should have received credentials; " +
"paste them into this file as 'discord_id:password' on one line."
)
}
val raw = Files.readString(tokenFile).trim()
val parts = raw.split(":", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
throw IOException("cloud-sync token at $tokenFile malformed (expected 'discord_id:password')")
}
return parts[0].trim() to parts[1].trim()
}
/** Build the restic --repo URL: rest:<base>/<discord_id>/ */
private fun resticRepo(baseUrl: String, discordId: String): String {
val base = baseUrl.trimEnd('/').let { if (it.startsWith("rest:")) it else "rest:$it" }
return "$base/$discordId/"
}
/** Common env applied to every restic invocation. */
private fun resticEnv(password: String): Map<String, String> = mapOf(
"RESTIC_PASSWORD" to password,
// Defensive: ensure restic doesn't try to be interactive about user prompts.
"RESTIC_PROGRESS_FPS" to "0",
)
/**
* pull: restore latest snapshot's tracked files into <pack-folder>.
*
* If the repo has no snapshots yet, this is a no-op (the user has never
* pushed; nothing to restore). We detect that via `restic snapshots --json`
* before attempting restore — restore on an empty repo errors out.
*/
fun pull(args: Args): Int {
val (discordId, password) = readCredentials(args.tokenFile)
val binary = Restic.resolveBinary(args)
val repo = resticRepo(args.url, discordId)
val env = resticEnv(password)
// Check whether any snapshots exist
val (snapCode, snapOut) = Restic.run(
binary,
listOf("-r", repo, "snapshots", "--json", "--latest", "1"),
env,
)
if (snapCode != 0) {
System.err.println("cloud-sync: failed to list snapshots (restic exit $snapCode)")
return 2
}
// restic returns "null" or "[]" when repo is empty
val empty = snapOut.trim().let { it.isEmpty() || it == "null" || it == "[]" }
if (empty) {
println("cloud-sync: no snapshots yet for this user (first run on this machine?); nothing to pull")
return 0
}
val scope = Scope_.load(args.packFolder)
val (_, excludeFrom) = Scope_.materializeForRestic(args.packFolder, scope)
// Restore overwrites files. Use --include for path filter (paths inside
// the snapshot are absolute as they were on the backup machine). We
// restore EVERYTHING in the snapshot to <pack-folder>; the snapshot was
// built from <pack-folder> + scope, so all paths come back as expected.
val (rcCode, _) = Restic.run(
binary,
listOf(
"-r", repo,
"restore", "latest",
"--target", args.packFolder.toString(),
"--exclude-file", excludeFrom.toString(),
),
env,
)
if (rcCode != 0) {
System.err.println("cloud-sync: restic restore failed with exit $rcCode")
return 2
}
println("cloud-sync: pull ok")
return 0
}
/**
* push: snapshot the in-scope files into the user's repo.
*/
fun push(args: Args): Int {
val (discordId, password) = readCredentials(args.tokenFile)
val binary = Restic.resolveBinary(args)
val repo = resticRepo(args.url, discordId)
val env = resticEnv(password)
val scope = Scope_.load(args.packFolder)
val (filesFrom, excludeFrom) = Scope_.materializeForRestic(args.packFolder, scope)
val (rcCode, _) = Restic.run(
binary,
listOf(
"-r", repo,
"backup",
"--files-from", filesFrom.toString(),
"--exclude-file", excludeFrom.toString(),
"--host", "cloud-sync",
"--tag", "auto",
),
env,
cwd = args.packFolder,
)
if (rcCode != 0) {
System.err.println("cloud-sync: restic backup failed with exit $rcCode")
return 2
}
println("cloud-sync: push ok")
return 0
}
@@ -0,0 +1,93 @@
package center.timemachine.cloud
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ArgsTest {
@Test
fun `parses required url and applies defaults`() {
val a = parseArgs(arrayOf("--url=https://cloud.tm.center"))
assertEquals("https://cloud.tm.center", a.url)
assertTrue(a.allowDownload)
assertFalse(a.headless)
// default token file lives under <pack-folder>/.cloud-sync/token
assertTrue(a.tokenFile.endsWith(".cloud-sync/token"))
}
@Test
fun `url required`() {
val ex = assertFailsWith<ArgParseException> { parseArgs(emptyArray()) }
assertTrue(ex.message!!.contains("--url"))
}
@Test
fun `space-separated values work`() {
val a = parseArgs(arrayOf("--url", "https://x", "--pack-folder", "/srv/mc"))
assertEquals("https://x", a.url)
assertEquals("/srv/mc", a.packFolder.toString())
}
@Test
fun `inline values work`() {
val a = parseArgs(arrayOf("--url=https://x", "--pack-folder=/srv/mc"))
assertEquals("https://x", a.url)
assertEquals("/srv/mc", a.packFolder.toString())
}
@Test
fun `no-gui flag`() {
val a = parseArgs(arrayOf("--url=https://x", "-g"))
assertTrue(a.headless)
val b = parseArgs(arrayOf("--url=https://x", "--no-gui"))
assertTrue(b.headless)
}
@Test
fun `no-download flag disables fetch`() {
val a = parseArgs(arrayOf("--url=https://x", "--no-download"))
assertFalse(a.allowDownload)
}
@Test
fun `unknown flag rejected`() {
val ex = assertFailsWith<ArgParseException> {
parseArgs(arrayOf("--url=https://x", "--bogus=foo"))
}
assertTrue(ex.message!!.contains("--bogus"))
}
@Test
fun `bool flag with inline value rejected`() {
val ex = assertFailsWith<ArgParseException> {
parseArgs(arrayOf("--url=https://x", "--no-download=yes"))
}
assertTrue(ex.message!!.contains("does not take a value"))
}
@Test
fun `missing value for non-bool flag rejected`() {
val ex = assertFailsWith<ArgParseException> {
parseArgs(arrayOf("--url=https://x", "--pack-folder"))
}
assertTrue(ex.message!!.contains("requires a value"))
}
@Test
fun `custom token-file overrides default`() {
val a = parseArgs(arrayOf(
"--url=https://x",
"--pack-folder=/srv/mc",
"--token-file=/etc/cloud-creds",
))
assertEquals("/etc/cloud-creds", a.tokenFile.toString())
}
@Test
fun `restic-binary override accepted`() {
val a = parseArgs(arrayOf("--url=https://x", "--restic-binary=/opt/restic"))
assertEquals("/opt/restic", a.resticBinary!!.toString())
}
}
@@ -0,0 +1,52 @@
package center.timemachine.cloud
import java.io.IOException
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class CredentialsTest {
@Test
fun `parses discord_id and password from one-liner`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, "358881557521498112:s3cret-pass\n")
val (id, pw) = readCredentials(tmp)
assertEquals("358881557521498112", id)
assertEquals("s3cret-pass", pw)
}
@Test
fun `trims whitespace around components`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, " 123 : pw \n")
val (id, pw) = readCredentials(tmp)
assertEquals("123", id)
assertEquals("pw", pw)
}
@Test
fun `missing file raises actionable error`() {
val missing = Files.createTempDirectory("nothing-").resolve("missing-token")
val ex = assertFailsWith<IOException> { readCredentials(missing) }
assertTrue(ex.message!!.contains("token not found"))
assertTrue(ex.message!!.contains("discord_id:password"))
}
@Test
fun `missing colon rejected`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, "no-colon-here")
val ex = assertFailsWith<IOException> { readCredentials(tmp) }
assertTrue(ex.message!!.contains("malformed"))
}
@Test
fun `empty id rejected`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, ":password")
val ex = assertFailsWith<IOException> { readCredentials(tmp) }
assertTrue(ex.message!!.contains("malformed"))
}
}
@@ -5,12 +5,17 @@ import kotlin.test.assertEquals
class SmokeTest {
@Test
fun `cli pull returns 0 in skeleton`() {
assertEquals(0, Cli.runPull(arrayOf("--url=http://localhost:9091")))
fun `cli pull missing url returns 2`() {
assertEquals(2, Cli.runPull(emptyArray()))
}
@Test
fun `cli push returns 0 in skeleton`() {
assertEquals(0, Cli.runPush(arrayOf("--url=http://localhost:9091")))
fun `cli push missing url returns 2`() {
assertEquals(2, Cli.runPush(emptyArray()))
}
@Test
fun `cli pull unknown flag returns 2`() {
assertEquals(2, Cli.runPull(arrayOf("--url=https://x", "--bogus")))
}
}