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
@@ -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")))
}
}