When step 4 finds an existing backup repo, show the current location and ask to reconfigure with the existing path as default, rather than silently skipping the step. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1723 lines
67 KiB
Plaintext
1723 lines
67 KiB
Plaintext
// pal - Your personal data, everywhere
|
|
//
|
|
// A modern CLI for managing your digital life across all devices.
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
type Status =
|
|
| Ok
|
|
| Warn
|
|
| Err
|
|
| None
|
|
|
|
// =============================================================================
|
|
// Color output helpers (using shell printf for ANSI codes)
|
|
// =============================================================================
|
|
|
|
fn print(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '%s\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printColor(color: String, msg: String): Unit with {Process} = {
|
|
let code = match color {
|
|
"green" => "32",
|
|
"yellow" => "33",
|
|
"red" => "31",
|
|
"blue" => "34",
|
|
"magenta" => "35",
|
|
"cyan" => "36",
|
|
"bold" => "1",
|
|
"dim" => "2",
|
|
_ => "0"
|
|
}
|
|
let ignore = Process.exec("printf '\\033[" + code + "m%s\\033[0m\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printBold(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[1m%s\\033[0m\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printSuccess(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[32m✓\\033[0m %s\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printWarn(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[33m⚠\\033[0m %s\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printErr(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[31m✗\\033[0m %s\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printInfo(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[36m→\\033[0m %s\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printDim(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[2m%s\\033[0m\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printBox(title: String, content: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[36m┌─\\033[1m %s \\033[0m\\033[36m─┐\\033[0m\\n' \"" + title + "\" >&2")
|
|
let ignore2 = Process.exec("printf '\\033[36m│\\033[0m %s\\n' \"" + content + "\" >&2")
|
|
let ignore3 = Process.exec("printf '\\033[36m└──────────────────────────────────────────┘\\033[0m\\n' >&2")
|
|
()
|
|
}
|
|
|
|
fn spinner(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[36m⠋\\033[0m %s\\r' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn clearLine(): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[2K\\r' >&2")
|
|
()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Modern output helpers (gh/cargo style)
|
|
// =============================================================================
|
|
|
|
// Print a step with aligned status: " Creating directories done"
|
|
fn printStep(label: String, status: String): Unit with {Process} = {
|
|
// Pad label to 24 chars for alignment
|
|
let ignore = Process.exec("printf ' %-24s \\033[32m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printStepPending(label: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf ' %-24s \\033[2mrunning...\\033[0m\\r' \"" + label + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printStepWarn(label: String, status: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf ' %-24s \\033[33m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2")
|
|
()
|
|
}
|
|
|
|
fn printStepErr(label: String, status: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf ' %-24s \\033[31m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2")
|
|
()
|
|
}
|
|
|
|
// Print a status line: " sync Running 3 folders, 2 devices"
|
|
fn printStatusLine(name: String, state: String, detail: String): Unit with {Process} = {
|
|
let stateColor = match state {
|
|
"Running" => "32",
|
|
"Ready" => "32",
|
|
"Mounted" => "32",
|
|
"Configured" => "32",
|
|
"Stopped" => "33",
|
|
"Not running" => "33",
|
|
"Not mounted" => "2",
|
|
"Not configured" => "2",
|
|
_ => "0"
|
|
}
|
|
let ignore = Process.exec("printf ' \\033[1m%-10s\\033[0m \\033[" + stateColor + "m%-16s\\033[0m \\033[2m%s\\033[0m\\n' \"" + name + "\" \"" + state + "\" \"" + detail + "\" >&2")
|
|
()
|
|
}
|
|
|
|
// Ask for confirmation: returns true if user confirms
|
|
fn askConfirm(question: String, defaultYes: Bool): Bool with {Console, Process} = {
|
|
let prompt = if defaultYes then " [Y/n] " else " [y/N] "
|
|
let ignore = Process.exec("printf '%s%s' \"" + question + "\" \"" + prompt + "\" >&2")
|
|
let response = String.trim(Console.readLine())
|
|
let lower = execQuiet("echo '" + response + "' | tr A-Z a-z")
|
|
if String.length(lower) == 0 then
|
|
defaultYes
|
|
else if lower == "y" then
|
|
true
|
|
else if lower == "yes" then
|
|
true
|
|
else if lower == "n" then
|
|
false
|
|
else if lower == "no" then
|
|
false
|
|
else
|
|
defaultYes
|
|
}
|
|
|
|
// Ask for text input with a default value
|
|
fn askInput(prompt: String, defaultVal: String): String with {Console, Process} = {
|
|
let display = if String.length(defaultVal) > 0 then
|
|
"printf '%s [%s]: ' \"" + prompt + "\" \"" + defaultVal + "\" >&2"
|
|
else
|
|
"printf '%s: ' \"" + prompt + "\" >&2"
|
|
let ignore = Process.exec(display)
|
|
let input = String.trim(Console.readLine())
|
|
if String.length(input) == 0 then defaultVal else input
|
|
}
|
|
|
|
// Print section header
|
|
fn printHeader(title: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[1m%s\\033[0m\\n\\n' \"" + title + "\" >&2")
|
|
()
|
|
}
|
|
|
|
// Print a hint/suggestion
|
|
fn printHint(msg: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[2m%s\\033[0m\\n' \"" + msg + "\" >&2")
|
|
()
|
|
}
|
|
|
|
// Print a command suggestion
|
|
fn printCmd(cmd: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf ' \\033[32m\\044\\033[0m %s\\n' \"" + cmd + "\" >&2")
|
|
()
|
|
}
|
|
|
|
// Print a QR code for easy scanning (e.g., device IDs)
|
|
fn printQR(data: String): Unit with {Process} = {
|
|
if hasCommand("qrencode") then {
|
|
// Generate small terminal QR code and redirect to stderr
|
|
let ignore = Process.exec("qrencode -t ANSIUTF8 -m 1 '" + data + "' -o - >&2")
|
|
()
|
|
} else {
|
|
// Fallback: just show the data
|
|
printHint("(Install qrencode for QR codes)")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Shell helpers
|
|
// =============================================================================
|
|
|
|
fn hasCommand(cmd: String): Bool with {Process} = {
|
|
let result = Process.exec("command -v " + cmd + " 2>/dev/null || true")
|
|
String.length(String.trim(result)) > 0
|
|
}
|
|
|
|
fn execQuiet(cmd: String): String with {Process} =
|
|
String.trim(Process.exec(cmd + " 2>/dev/null || true"))
|
|
|
|
fn homeDir(): String with {Process} =
|
|
match Process.env("HOME") {
|
|
Some(h) => h,
|
|
None => "/tmp"
|
|
}
|
|
|
|
fn isYes(s: String): Bool = String.contains(s, "yes")
|
|
fn isNo(s: String): Bool = String.contains(s, "no")
|
|
fn isActive(s: String): Bool = String.contains(s, "active")
|
|
|
|
fn palDir(): String with {Process} = homeDir() + "/.config/pal"
|
|
fn palConfig(): String with {Process} = palDir() + "/pal.toml"
|
|
fn stateDb(): String with {Process} = palDir() + "/state.db"
|
|
|
|
// =============================================================================
|
|
// Initialization
|
|
// =============================================================================
|
|
|
|
fn isInitialized(): Bool with {Process} = {
|
|
let result = execQuiet("test -f " + palConfig() + " && echo yes || echo no")
|
|
result |> isYes
|
|
}
|
|
|
|
fn ensureDir(path: String): Unit with {Process} = {
|
|
let ignore = Process.exec("mkdir -p " + path)
|
|
()
|
|
}
|
|
|
|
// =============================================================================
|
|
// State tracking
|
|
// =============================================================================
|
|
|
|
fn initStateDb(): Unit with {Process} = {
|
|
let db = stateDb()
|
|
let schema = "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, timestamp TEXT, type TEXT, message TEXT);"
|
|
let ignore = Process.exec("sqlite3 " + db + " \"" + schema + "\" 2>/dev/null || true")
|
|
()
|
|
}
|
|
|
|
fn logEvent(eventType: String, message: String): Unit with {Process} = {
|
|
let db = stateDb()
|
|
let timestamp = execQuiet("date -Iseconds")
|
|
let sql = "INSERT INTO events (timestamp, type, message) VALUES ('" + timestamp + "', '" + eventType + "', '" + message + "');"
|
|
let ignore = Process.exec("sqlite3 " + db + " \"" + sql + "\" 2>/dev/null || true")
|
|
()
|
|
}
|
|
|
|
fn getRecentEvents(limit: String): String with {Process} = {
|
|
let db = stateDb()
|
|
execQuiet("sqlite3 -separator ' | ' " + db + " \"SELECT timestamp, type, message FROM events ORDER BY id DESC LIMIT " + limit + ";\" 2>/dev/null")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Syncthing helpers
|
|
// =============================================================================
|
|
|
|
fn palSyncthingRunning(): Bool with {Process} = {
|
|
let result = execQuiet("curl -s http://127.0.0.1:8385/rest/system/status 2>/dev/null | head -c 1")
|
|
String.length(result) > 0
|
|
}
|
|
|
|
fn getSyncFolderCount(): String with {Process} =
|
|
execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0")
|
|
|
|
fn getSyncDeviceCount(): String with {Process} =
|
|
execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0")
|
|
|
|
// =============================================================================
|
|
// Backup helpers
|
|
// =============================================================================
|
|
|
|
fn getBackupRepo(): String with {Process} =
|
|
execQuiet("cat " + palDir() + "/restic/repository 2>/dev/null")
|
|
|
|
fn getBackupPassword(): String with {Process} =
|
|
palDir() + "/restic/password"
|
|
|
|
// =============================================================================
|
|
// Logo and branding
|
|
// =============================================================================
|
|
|
|
fn showLogo(): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[36m _\\n' >&2")
|
|
let ignore2 = Process.exec("printf ' _ __ __ _| |\\n' >&2")
|
|
let ignore3 = Process.exec("printf ' | '\"'\"'_ \\\\/ _'\"'\"' | |\\n' >&2")
|
|
let ignore4 = Process.exec("printf ' | |_) | (_| | |\\n' >&2")
|
|
let ignore5 = Process.exec("printf ' | .__/ \\\\__,_|_|\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' |_|\\n\\033[0m' >&2")
|
|
print("")
|
|
printDim(" Your personal data, everywhere.")
|
|
print("")
|
|
}
|
|
|
|
fn showMiniLogo(): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\033[1;36mpal\\033[0m ' >&2")
|
|
()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Welcome flow (Interactive)
|
|
// =============================================================================
|
|
|
|
fn showInteractiveWelcome(): Unit with {Console, Process} = {
|
|
print("")
|
|
showLogo()
|
|
print("")
|
|
printBold("Welcome to pal!")
|
|
print("")
|
|
printHint("pal manages your data across all your devices:")
|
|
print("")
|
|
let ignore = Process.exec("printf ' \\033[1mconfig\\033[0m Machine configuration (git-managed)\\n' >&2")
|
|
let ignore2 = Process.exec("printf ' \\033[1msync\\033[0m Notes & documents (real-time sync)\\n' >&2")
|
|
let ignore3 = Process.exec("printf ' \\033[1mbackup\\033[0m Point-in-time snapshots\\n' >&2")
|
|
let ignore4 = Process.exec("printf ' \\033[1mserver\\033[0m Large files on your server\\n' >&2")
|
|
print("")
|
|
|
|
// Interactive choice
|
|
printBold("What would you like to do?")
|
|
print("")
|
|
let ignore5 = Process.exec("printf ' \\033[36m1\\033[0m Set up a new system\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' \\033[36m2\\033[0m Join an existing setup (import)\\n' >&2")
|
|
let ignore7 = Process.exec("printf ' \\033[36m3\\033[0m Just explore (show help)\\n' >&2")
|
|
print("")
|
|
|
|
let ignore8 = Process.exec("printf 'Choice [1]: ' >&2")
|
|
let choice = String.trim(Console.readLine())
|
|
|
|
match choice {
|
|
"2" => {
|
|
print("")
|
|
printBold("Import from archive")
|
|
print("")
|
|
printHint("Provide a pal export archive:")
|
|
printCmd("pal import pal-export.tar.zst")
|
|
print("")
|
|
},
|
|
"3" => showHelp(),
|
|
_ => {
|
|
print("")
|
|
doOnboard()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-interactive welcome for scripts
|
|
fn showWelcome(): Unit with {Process} = {
|
|
print("")
|
|
showLogo()
|
|
print("")
|
|
printBold("Not initialized. Run:")
|
|
printCmd("pal setup")
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Setup wizard
|
|
// =============================================================================
|
|
|
|
fn createDirectories(): Unit with {Process} = {
|
|
let base = palDir()
|
|
ensureDir(base)
|
|
ensureDir(base + "/config-repo")
|
|
ensureDir(base + "/syncthing/config")
|
|
ensureDir(base + "/syncthing/db")
|
|
ensureDir(base + "/sync/notes")
|
|
ensureDir(base + "/sync/documents")
|
|
ensureDir(base + "/sync/dotfiles")
|
|
ensureDir(base + "/restic/cache")
|
|
ensureDir(base + "/server")
|
|
}
|
|
|
|
fn doSetup(): Unit with {Console, Process} = {
|
|
print("")
|
|
|
|
if isInitialized() then {
|
|
printWarn("pal is already initialized on this device.")
|
|
print("")
|
|
let rerun = askConfirm("Re-run setup? This will overwrite your config.", false)
|
|
if rerun == false then {
|
|
print("")
|
|
printHint("No changes made.")
|
|
print("")
|
|
()
|
|
} else {
|
|
print("")
|
|
doSetupSteps()
|
|
}
|
|
} else {
|
|
doSetupSteps()
|
|
}
|
|
}
|
|
|
|
fn doSetupSteps(): Unit with {Process} = {
|
|
print("")
|
|
printHeader("Setting up pal")
|
|
|
|
// Step 1: Directories
|
|
printStepPending("Creating directories")
|
|
createDirectories()
|
|
printStep("Creating directories", "done")
|
|
|
|
// Step 2: Config
|
|
printStepPending("Writing configuration")
|
|
let hostname = execQuiet("hostname")
|
|
let configContent = "[pal]\nversion = 1\ndevice_name = \"" + hostname + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\n\n[backup]\nschedule = \"hourly\"\n"
|
|
let writeCmd = "cat > " + palConfig() + " << 'EOF'\n" + configContent + "EOF"
|
|
let ignore = Process.exec(writeCmd)
|
|
printStep("Writing configuration", "done")
|
|
|
|
// Step 3: Age key
|
|
printStepPending("Setting up encryption")
|
|
let hasAgeKey = execQuiet("test -f " + palDir() + "/age-key.txt && echo yes || echo no")
|
|
if hasAgeKey |> isYes then
|
|
printStep("Setting up encryption", "exists")
|
|
else {
|
|
let ignore2 = Process.exec("age-keygen -o " + palDir() + "/age-key.txt 2>&1 || true")
|
|
printStep("Setting up encryption", "done")
|
|
}
|
|
|
|
// Step 4: Syncthing
|
|
printStepPending("Initializing sync")
|
|
if hasCommand("syncthing") then {
|
|
let hasConfig = execQuiet("test -f " + palDir() + "/syncthing/config/config.xml && echo yes || echo no")
|
|
if hasConfig |> isNo then {
|
|
let ignore3 = Process.exec("syncthing generate --home=" + palDir() + "/syncthing/config 2>&1")
|
|
printStep("Initializing sync", "done")
|
|
} else {
|
|
printStep("Initializing sync", "ready")
|
|
}
|
|
} else {
|
|
printStepWarn("Initializing sync", "not installed")
|
|
}
|
|
|
|
// State DB
|
|
if hasCommand("sqlite3") then {
|
|
initStateDb()
|
|
logEvent("setup", "pal initialized")
|
|
} else ()
|
|
|
|
print("")
|
|
printSuccess("Setup complete!")
|
|
print("")
|
|
printHint("Your data lives in: " + palDir())
|
|
print("")
|
|
printBold("Next steps:")
|
|
printCmd("pal sync setup # Pair with other devices")
|
|
printCmd("pal backup init # Configure backups")
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Init from repo
|
|
// =============================================================================
|
|
|
|
fn doInit(repoUrl: String): Unit with {Process} = {
|
|
print("")
|
|
if String.length(repoUrl) == 0 then {
|
|
printHeader("Initialize from repository")
|
|
printHint("Clone a config repository to set up this machine:")
|
|
print("")
|
|
printCmd("pal init https://github.com/you/pal-config")
|
|
print("")
|
|
} else {
|
|
printHeader("Initializing from repository")
|
|
|
|
printStepPending("Creating directories")
|
|
createDirectories()
|
|
printStep("Creating directories", "done")
|
|
|
|
printStepPending("Cloning repository")
|
|
let cloneResult = Process.exec("git clone " + repoUrl + " " + palDir() + "/config-repo 2>&1")
|
|
if String.contains(cloneResult, "fatal") then {
|
|
printStepErr("Cloning repository", "failed")
|
|
print("")
|
|
printDim(cloneResult)
|
|
} else {
|
|
printStep("Cloning repository", "done")
|
|
|
|
let hasFlake = execQuiet("test -f " + palDir() + "/config-repo/flake.nix && echo yes || echo no")
|
|
if hasFlake |> isYes then {
|
|
printStep("Found flake.nix", "yes")
|
|
print("")
|
|
printBold("Apply your config:")
|
|
printCmd("cd " + palDir() + "/config-repo")
|
|
printCmd("sudo nixos-rebuild switch --flake .")
|
|
} else {
|
|
printStepWarn("Found flake.nix", "no")
|
|
}
|
|
logEvent("init", "Cloned " + repoUrl)
|
|
}
|
|
print("")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Sync commands
|
|
// =============================================================================
|
|
|
|
fn doSyncStatus(): Unit with {Process} = {
|
|
print("")
|
|
if hasCommand("syncthing") == false then {
|
|
printErr("Syncthing not installed")
|
|
printHint("Install syncthing and run 'pal sync setup'")
|
|
} else if palSyncthingRunning() then {
|
|
let folders = getSyncFolderCount()
|
|
let devices = getSyncDeviceCount()
|
|
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
|
|
print("")
|
|
let ignore = Process.exec("printf ' Web UI: \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2")
|
|
} else {
|
|
printStatusLine("sync", "Stopped", "")
|
|
print("")
|
|
printCmd("pal sync start")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
fn doSyncSetup(): Unit with {Process} = {
|
|
print("")
|
|
if hasCommand("syncthing") == false then {
|
|
printErr("Syncthing not installed")
|
|
printHint("Add to NixOS: services.pal.enable = true;")
|
|
print("")
|
|
} else {
|
|
printHeader("Setting up sync")
|
|
|
|
let hasConfig = execQuiet("test -f " + palDir() + "/syncthing/config/config.xml && echo yes || echo no")
|
|
if hasConfig |> isNo then {
|
|
printStepPending("Creating config")
|
|
let ignore = Process.exec("syncthing generate --home=" + palDir() + "/syncthing/config 2>&1")
|
|
printStep("Creating config", "done")
|
|
} else {
|
|
printStep("Creating config", "exists")
|
|
}
|
|
|
|
// Start if not running
|
|
if palSyncthingRunning() == false then {
|
|
printStepPending("Starting daemon")
|
|
let ignore = Process.exec("syncthing serve --home=" + palDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &")
|
|
let ignore2 = Process.exec("sleep 2")
|
|
printStep("Starting daemon", "done")
|
|
} else {
|
|
printStep("Starting daemon", "running")
|
|
}
|
|
|
|
let deviceId = execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4")
|
|
if String.length(deviceId) > 10 then {
|
|
print("")
|
|
printBold("Your Device ID")
|
|
let ignore = Process.exec("printf '\\033[33m%s\\033[0m\\n' \"" + deviceId + "\" >&2")
|
|
print("")
|
|
printQR(deviceId)
|
|
print("")
|
|
printHint("Scan the QR code or share this ID to pair devices.")
|
|
logEvent("sync", "Device ID displayed")
|
|
} else {
|
|
printStepWarn("Getting device ID", "retry in a moment")
|
|
}
|
|
print("")
|
|
}
|
|
}
|
|
|
|
fn doSyncStart(): Unit with {Process} = {
|
|
print("")
|
|
if palSyncthingRunning() then {
|
|
printSuccess("Syncthing is already running")
|
|
let ignore = Process.exec("printf ' \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2")
|
|
} else {
|
|
printStepPending("Starting Syncthing")
|
|
let ignore = Process.exec("syncthing serve --home=" + palDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &")
|
|
let ignore2 = Process.exec("sleep 2")
|
|
if palSyncthingRunning() then {
|
|
printStep("Starting Syncthing", "done")
|
|
print("")
|
|
let ignore3 = Process.exec("printf ' Web UI: \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2")
|
|
} else {
|
|
printStepErr("Starting Syncthing", "failed")
|
|
}
|
|
}
|
|
print("")
|
|
}
|
|
|
|
fn doSync(): Unit with {Process} = {
|
|
print("")
|
|
if palSyncthingRunning() then {
|
|
let ignore = Process.exec("syncthing cli --home=" + palDir() + "/syncthing/config scan 2>/dev/null || true")
|
|
printSuccess("Sync triggered")
|
|
logEvent("sync", "Manual sync")
|
|
} else {
|
|
printWarn("Syncthing not running")
|
|
printCmd("pal sync start")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Backup commands
|
|
// =============================================================================
|
|
|
|
fn doBackupInit(repoArg: String): Unit with {Process} = {
|
|
print("")
|
|
if hasCommand("restic") == false then {
|
|
printErr("Restic not installed")
|
|
print("")
|
|
} else if String.length(repoArg) == 0 then {
|
|
printHeader("Initialize backup repository")
|
|
printHint("Provide a repository location:")
|
|
print("")
|
|
printCmd("pal backup init /path/to/backup")
|
|
printCmd("pal backup init sftp:server:/backups")
|
|
printCmd("pal backup init b2:bucket:path")
|
|
print("")
|
|
} else {
|
|
printHeader("Initializing backup")
|
|
printHint("Repository: " + repoArg)
|
|
print("")
|
|
|
|
let passwordFile = palDir() + "/restic/password"
|
|
let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no")
|
|
if hasPassword |> isNo then {
|
|
printStepPending("Generating password")
|
|
let ignore = Process.exec("head -c 32 /dev/urandom | base64 > " + passwordFile + " && chmod 600 " + passwordFile)
|
|
printStep("Generating password", "done")
|
|
} else {
|
|
printStep("Generating password", "exists")
|
|
}
|
|
|
|
printStepPending("Creating repository")
|
|
let initResult = Process.exec("restic -r " + repoArg + " --password-file " + passwordFile + " init 2>&1")
|
|
|
|
if String.contains(initResult, "created restic repository") then {
|
|
printStep("Creating repository", "done")
|
|
let ignore = Process.exec("echo '" + repoArg + "' > " + palDir() + "/restic/repository")
|
|
printStep("Saving config", "done")
|
|
print("")
|
|
printSuccess("Backup initialized!")
|
|
printCmd("pal backup # Create your first snapshot")
|
|
logEvent("backup", "Initialized " + repoArg)
|
|
} else if String.contains(initResult, "already initialized") then {
|
|
printStep("Creating repository", "exists")
|
|
let ignore = Process.exec("echo '" + repoArg + "' > " + palDir() + "/restic/repository")
|
|
} else {
|
|
printStepErr("Creating repository", "failed")
|
|
print("")
|
|
printDim(initResult)
|
|
}
|
|
print("")
|
|
}
|
|
}
|
|
|
|
fn doBackupList(): Unit with {Process} = {
|
|
print("")
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) == 0 then {
|
|
printWarn("No backup configured")
|
|
printCmd("pal backup init <repo>")
|
|
} else {
|
|
printHeader("Backup snapshots")
|
|
printHint("Repository: " + repo)
|
|
print("")
|
|
// Format snapshots nicely
|
|
let ignore = Process.exec("printf ' \\033[2m%-10s %-20s %s\\033[0m\\n' 'ID' 'DATE' 'PATHS' >&2")
|
|
let ignore2 = Process.exec("printf ' \\033[2m%s\\033[0m\\n' '─────────────────────────────────────────────────' >&2")
|
|
let snapshots = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots --json 2>&1 | jq -r '.[] | \" \" + .short_id + \" \" + .time[:19] + \" \" + (.paths | join(\", \"))' 2>/dev/null || echo ' No snapshots'")
|
|
print(snapshots)
|
|
}
|
|
print("")
|
|
}
|
|
|
|
fn doBackup(): Unit with {Process} = {
|
|
print("")
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) == 0 then {
|
|
printWarn("No backup configured")
|
|
printCmd("pal backup init <repo>")
|
|
} else {
|
|
printStepPending("Creating snapshot")
|
|
let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + palDir() + "/sync 2>&1")
|
|
|
|
if String.contains(backupResult, "snapshot") then {
|
|
let snapshotId = execQuiet("echo '" + backupResult + "' | grep -o 'snapshot [a-f0-9]*' | cut -d' ' -f2")
|
|
if String.length(snapshotId) > 0 then {
|
|
printStep("Creating snapshot", snapshotId)
|
|
} else {
|
|
printStep("Creating snapshot", "done")
|
|
}
|
|
logEvent("backup", "Backup completed")
|
|
} else {
|
|
printStepErr("Creating snapshot", "failed")
|
|
print("")
|
|
printDim(backupResult)
|
|
}
|
|
}
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Server commands
|
|
// =============================================================================
|
|
|
|
fn doServerStatus(): Unit with {Process} = {
|
|
print("")
|
|
let serverDir = palDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
let dfOut = execQuiet("df -h " + serverDir + " 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f3,2 | sed 's/ / \\/ /'")
|
|
printStatusLine("server", "Mounted", dfOut)
|
|
printHint(" " + serverDir)
|
|
} else {
|
|
printStatusLine("server", "Not mounted", "")
|
|
print("")
|
|
printCmd("pal server mount")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
fn doServerMount(): Unit with {Process} = {
|
|
print("")
|
|
let serverDir = palDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
printSuccess("Already mounted at " + serverDir)
|
|
} else {
|
|
printHeader("Mount server")
|
|
printHint("Mount a remote filesystem to access large files, media, and archives.")
|
|
print("")
|
|
|
|
// SSHFS section
|
|
printBold("SSHFS (recommended for most users)")
|
|
printHint("Mounts remote directories over SSH. No server config needed.")
|
|
print("")
|
|
printCmd("sshfs user@server:/path " + serverDir)
|
|
print("")
|
|
printHint("Useful options:")
|
|
print(" -o reconnect Auto-reconnect on connection loss")
|
|
print(" -o follow_symlinks Follow symbolic links")
|
|
print(" -o cache=yes Enable caching for better performance")
|
|
print("")
|
|
printHint("Example with options:")
|
|
printCmd("sshfs -o reconnect,cache=yes user@server:/data " + serverDir)
|
|
print("")
|
|
|
|
// NFS section
|
|
printBold("NFS (for local network)")
|
|
printHint("Faster than SSHFS but requires NFS server setup.")
|
|
print("")
|
|
printCmd("sudo mount -t nfs server:/export " + serverDir)
|
|
print("")
|
|
|
|
// NixOS section
|
|
printBold("NixOS declarative mount")
|
|
printHint("Add to configuration.nix for automatic mounting.")
|
|
printHint("See: nixos.wiki/wiki/SSHFS")
|
|
print("")
|
|
|
|
// Unmount hint
|
|
printHint("To unmount: pal server unmount")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
fn doServerUnmount(): Unit with {Process} = {
|
|
print("")
|
|
let serverDir = palDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
printStepPending("Unmounting")
|
|
let ignore = Process.exec("fusermount -u " + serverDir + " 2>&1 || umount " + serverDir + " 2>&1 || true")
|
|
let stillMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
if stillMounted |> isNo then
|
|
printStep("Unmounting", "done")
|
|
else
|
|
printStepErr("Unmounting", "failed")
|
|
} else {
|
|
printHint("Server is not mounted")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Export/Import
|
|
// =============================================================================
|
|
|
|
fn doExport(): Unit with {Console, Process} = {
|
|
print("")
|
|
let timestamp = execQuiet("date +%Y-%m-%d")
|
|
let exportFile = "pal-export-" + timestamp + ".tar.zst"
|
|
let exportPath = execQuiet("pwd") + "/" + exportFile
|
|
|
|
printStepPending("Creating archive")
|
|
let ignore = Process.exec("tar -C " + palDir() + " -cf - . 2>/dev/null | zstd -q > " + exportPath + " 2>&1")
|
|
|
|
let fileExists = execQuiet("test -f " + exportPath + " && echo yes || echo no")
|
|
if fileExists |> isYes then {
|
|
let fileSize = execQuiet("du -h " + exportPath + " | cut -f1")
|
|
printStep("Creating archive", fileSize)
|
|
print("")
|
|
printSuccess("Export created: " + exportPath)
|
|
print("")
|
|
printHint("Restore with:")
|
|
printCmd("pal import " + exportFile)
|
|
logEvent("export", "Created " + exportFile)
|
|
} else {
|
|
printStepErr("Creating archive", "failed")
|
|
}
|
|
print("")
|
|
}
|
|
|
|
fn doImport(archivePath: String): Unit with {Console, Process} = {
|
|
print("")
|
|
|
|
if String.length(archivePath) == 0 then {
|
|
printHeader("Import pal data")
|
|
printHint("Restore from a pal export archive:")
|
|
print("")
|
|
printCmd("pal import pal-export.tar.zst")
|
|
print("")
|
|
} else {
|
|
let fileExists = execQuiet("test -f " + archivePath + " && echo yes || echo no")
|
|
if fileExists |> isNo then {
|
|
printErr("File not found: " + archivePath)
|
|
print("")
|
|
} else {
|
|
// Check if there's existing data
|
|
let hasPal = execQuiet("test -d " + palDir() + " && echo yes || echo no")
|
|
if hasPal |> isYes then {
|
|
print("")
|
|
printWarn("This will replace your existing pal data.")
|
|
printHint("Existing data will be moved to ~/.config/pal.bak")
|
|
print("")
|
|
|
|
let confirmed = askConfirm("Continue?", false)
|
|
if confirmed then {
|
|
print("")
|
|
printHeader("Importing")
|
|
|
|
printStepPending("Backing up existing")
|
|
let ignore = Process.exec("mv " + palDir() + " " + palDir() + ".bak 2>&1")
|
|
printStep("Backing up existing", "done")
|
|
|
|
printStepPending("Extracting archive")
|
|
let ignore2 = Process.exec("mkdir -p " + palDir())
|
|
let ignore3 = Process.exec("zstd -d < " + archivePath + " | tar -C " + palDir() + " -xf - 2>&1")
|
|
printStep("Extracting archive", "done")
|
|
|
|
printStepPending("Verifying config")
|
|
let configExists = execQuiet("test -f " + palConfig() + " && echo yes || echo no")
|
|
if configExists |> isYes then {
|
|
printStep("Verifying config", "done")
|
|
print("")
|
|
printSuccess("Import complete.")
|
|
logEvent("import", "Imported from " + archivePath)
|
|
} else {
|
|
printStepErr("Verifying config", "failed")
|
|
print("")
|
|
printErr("Import failed - config not found in archive")
|
|
}
|
|
print("")
|
|
} else {
|
|
print("")
|
|
printHint("Import cancelled.")
|
|
print("")
|
|
}
|
|
} else {
|
|
// No existing data, proceed without confirmation
|
|
printHeader("Importing")
|
|
|
|
printStepPending("Extracting archive")
|
|
let ignore = Process.exec("mkdir -p " + palDir())
|
|
let ignore2 = Process.exec("zstd -d < " + archivePath + " | tar -C " + palDir() + " -xf - 2>&1")
|
|
printStep("Extracting archive", "done")
|
|
|
|
printStepPending("Verifying config")
|
|
let configExists = execQuiet("test -f " + palConfig() + " && echo yes || echo no")
|
|
if configExists |> isYes then {
|
|
printStep("Verifying config", "done")
|
|
print("")
|
|
printSuccess("Import complete.")
|
|
logEvent("import", "Imported from " + archivePath)
|
|
} else {
|
|
printStepErr("Verifying config", "failed")
|
|
print("")
|
|
printErr("Import failed - config not found in archive")
|
|
}
|
|
print("")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Status dashboard
|
|
// =============================================================================
|
|
|
|
fn showStatus(): Unit with {Process} = {
|
|
print("")
|
|
if isInitialized() == false then {
|
|
printWarn("Not initialized")
|
|
printCmd("pal setup")
|
|
} else {
|
|
printHeader("Status")
|
|
|
|
// Config
|
|
let hasRepo = execQuiet("test -d " + palDir() + "/config-repo/.git && echo yes || echo no")
|
|
if hasRepo |> isYes then {
|
|
let repoRemote = execQuiet("cd " + palDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'")
|
|
printStatusLine("config", "Linked", repoRemote)
|
|
} else {
|
|
printStatusLine("config", "Not linked", "")
|
|
}
|
|
|
|
// Sync
|
|
if palSyncthingRunning() then {
|
|
let folders = getSyncFolderCount()
|
|
let devices = getSyncDeviceCount()
|
|
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
|
|
} else {
|
|
printStatusLine("sync", "Not running", "")
|
|
}
|
|
|
|
// Backup
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) > 0 then {
|
|
let repoShort = execQuiet("echo '" + repo + "' | sed 's/.*\\///'")
|
|
printStatusLine("backup", "Configured", repoShort)
|
|
} else {
|
|
printStatusLine("backup", "Not configured", "")
|
|
}
|
|
|
|
// Server
|
|
let serverDir = palDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
if isMounted |> isYes then {
|
|
printStatusLine("server", "Mounted", serverDir)
|
|
} else {
|
|
printStatusLine("server", "Not mounted", "")
|
|
}
|
|
}
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Compact Status (default command when initialized)
|
|
// =============================================================================
|
|
|
|
fn showCompactStatus(): Unit with {Process} = {
|
|
let syncOk = palSyncthingRunning()
|
|
let hasBackup = String.length(getBackupRepo()) > 0
|
|
let folders = if syncOk then getSyncFolderCount() else "0"
|
|
let devices = if syncOk then getSyncDeviceCount() else "0"
|
|
|
|
// Header line
|
|
let ignore = Process.exec("printf '\\033[1;36mpal\\033[0m ' >&2")
|
|
if syncOk && hasBackup then {
|
|
let ignore2 = Process.exec("printf '\\033[32mAll systems operational\\033[0m\\n' >&2")
|
|
} else {
|
|
let ignore2 = Process.exec("printf '\\033[33mPartially configured\\033[0m\\n' >&2")
|
|
}
|
|
print("")
|
|
|
|
// Sync status
|
|
if syncOk then {
|
|
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
|
|
} else {
|
|
printStatusLine("sync", "Not running", "Run: pal sync start")
|
|
}
|
|
|
|
// Backup status
|
|
if hasBackup then {
|
|
let snapCount = execQuiet("restic -r " + getBackupRepo() + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r 'length' 2>/dev/null || echo 0")
|
|
let lastBackup = execQuiet("restic -r " + getBackupRepo() + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r '.[-1].time[:10]' 2>/dev/null || echo 'unknown'")
|
|
printStatusLine("backup", "Ready", snapCount + " snapshots, last " + lastBackup)
|
|
} else {
|
|
printStatusLine("backup", "Not configured", "Run: pal backup init")
|
|
}
|
|
|
|
// Server status
|
|
let serverDir = palDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
if isMounted |> isYes then {
|
|
printStatusLine("server", "Mounted", serverDir)
|
|
} else {
|
|
printStatusLine("server", "Not mounted", "")
|
|
}
|
|
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Health check (default command)
|
|
// =============================================================================
|
|
|
|
fn showHealthCheck(): Unit with {Console, Process} = {
|
|
// Check if this is an interactive terminal
|
|
let isTty = execQuiet("test -t 0 && echo yes || echo no")
|
|
|
|
if isInitialized() == false then {
|
|
// Interactive wizard for first run
|
|
if isTty |> isYes then
|
|
showInteractiveWelcome()
|
|
else
|
|
showWelcome()
|
|
} else {
|
|
showCompactStatus()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Doctor
|
|
// =============================================================================
|
|
|
|
fn showDoctor(): Unit with {Process} = {
|
|
print("")
|
|
printHeader("System check")
|
|
|
|
// Check tools
|
|
let ignore = Process.exec("printf '\\033[2mDependencies:\\033[0m\\n' >&2")
|
|
if hasCommand("nix") then {
|
|
let ver = execQuiet("nix --version | cut -d' ' -f3")
|
|
printStep("nix", ver)
|
|
} else {
|
|
printStepErr("nix", "not found")
|
|
}
|
|
|
|
if hasCommand("git") then {
|
|
let ver = execQuiet("git --version | cut -d' ' -f3")
|
|
printStep("git", ver)
|
|
} else {
|
|
printStepErr("git", "not found")
|
|
}
|
|
|
|
if hasCommand("syncthing") then {
|
|
if palSyncthingRunning() then
|
|
printStep("syncthing", "running")
|
|
else
|
|
printStepWarn("syncthing", "installed, not running")
|
|
} else {
|
|
printStepErr("syncthing", "not found")
|
|
}
|
|
|
|
if hasCommand("restic") then {
|
|
printStep("restic", "installed")
|
|
} else {
|
|
printStepErr("restic", "not found")
|
|
}
|
|
|
|
if hasCommand("age") then {
|
|
let hasKey = execQuiet("test -f " + palDir() + "/age-key.txt && echo yes || echo no")
|
|
if hasKey |> isYes then
|
|
printStep("age", "key exists")
|
|
else
|
|
printStepWarn("age", "no key")
|
|
} else {
|
|
printStepErr("age", "not found")
|
|
}
|
|
|
|
if hasCommand("qrencode") then {
|
|
printStep("qrencode", "installed")
|
|
} else {
|
|
printStepWarn("qrencode", "not found (QR codes disabled)")
|
|
}
|
|
|
|
// Directory check
|
|
print("")
|
|
let ignore2 = Process.exec("printf '\\033[2mDirectories:\\033[0m\\n' >&2")
|
|
checkDir("config-repo")
|
|
checkDir("syncthing")
|
|
checkDir("sync")
|
|
checkDir("restic")
|
|
checkDir("server")
|
|
print("")
|
|
}
|
|
|
|
fn checkDir(dir: String): Unit with {Process} = {
|
|
let exists = execQuiet("test -d " + palDir() + "/" + dir + " && echo yes || echo no")
|
|
if exists |> isYes then {
|
|
printStep(dir + "/", "exists")
|
|
} else {
|
|
printStepWarn(dir + "/", "missing")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// History
|
|
// =============================================================================
|
|
|
|
fn showHistory(): Unit with {Process} = {
|
|
print("")
|
|
if hasCommand("sqlite3") == false then {
|
|
printErr("sqlite3 not installed")
|
|
} else {
|
|
let dbExists = execQuiet("test -f " + stateDb() + " && echo yes || echo no")
|
|
if dbExists |> isYes then {
|
|
printHeader("Recent events")
|
|
let ignore = Process.exec("printf ' \\033[2m%-20s %-10s %s\\033[0m\\n' 'TIME' 'TYPE' 'DETAILS' >&2")
|
|
let ignore2 = Process.exec("printf ' \\033[2m%s\\033[0m\\n' '──────────────────────────────────────────────────' >&2")
|
|
// Format events nicely
|
|
let events = execQuiet("sqlite3 -separator ' ' " + stateDb() + " \"SELECT substr(timestamp,1,16), type, message FROM events ORDER BY id DESC LIMIT 15;\" 2>/dev/null | sed 's/^/ /'")
|
|
if String.length(events) > 0 then {
|
|
print(events)
|
|
} else {
|
|
printHint(" No events yet")
|
|
}
|
|
} else {
|
|
printHint("No history")
|
|
}
|
|
}
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Dashboard
|
|
// =============================================================================
|
|
|
|
fn doDashboard(): Unit with {Process} = {
|
|
// Show terminal dashboard instead of HTML
|
|
print("")
|
|
printHeader("Dashboard")
|
|
|
|
let deviceName = execQuiet("hostname")
|
|
printHint("Device: " + deviceName)
|
|
print("")
|
|
|
|
// Sync
|
|
if palSyncthingRunning() then {
|
|
let folders = getSyncFolderCount()
|
|
let devices = getSyncDeviceCount()
|
|
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
|
|
} else {
|
|
printStatusLine("sync", "Stopped", "")
|
|
}
|
|
|
|
// Backup
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) > 0 then {
|
|
let snapCount = execQuiet("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r 'length' 2>/dev/null || echo 0")
|
|
printStatusLine("backup", "Ready", snapCount + " snapshots")
|
|
} else {
|
|
printStatusLine("backup", "Not configured", "")
|
|
}
|
|
|
|
// Server
|
|
let serverDir = palDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
if isMounted |> isYes then {
|
|
let dfOut = execQuiet("df -h " + serverDir + " 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f3,2 | sed 's/ / \\/ /'")
|
|
printStatusLine("server", "Mounted", dfOut)
|
|
} else {
|
|
printStatusLine("server", "Not mounted", "")
|
|
}
|
|
|
|
// Config
|
|
let hasRepo = execQuiet("test -d " + palDir() + "/config-repo/.git && echo yes || echo no")
|
|
if hasRepo |> isYes then {
|
|
let repoRemote = execQuiet("cd " + palDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'")
|
|
printStatusLine("config", "Linked", repoRemote)
|
|
} else {
|
|
printStatusLine("config", "Not linked", "")
|
|
}
|
|
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Onboard wizard
|
|
// =============================================================================
|
|
|
|
fn showOnboardSummary(deviceName: String): Unit with {Process} = {
|
|
let ignore = Process.exec("printf '\\n\\033[1m── Setup Complete ──\\033[0m\\n\\n' >&2")
|
|
|
|
// Device info
|
|
let osInfo = execQuiet("uname -sr")
|
|
printStatusLine("device", deviceName, osInfo)
|
|
|
|
// Config status
|
|
let hasRepo = execQuiet("test -d " + palDir() + "/config-repo/.git && echo yes || echo no")
|
|
if hasRepo |> isYes then {
|
|
let repoRemote = execQuiet("cd " + palDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'")
|
|
if String.length(repoRemote) > 0 then
|
|
printStatusLine("config", "Linked", repoRemote)
|
|
else
|
|
printStatusLine("config", "Initialized", "local repo")
|
|
} else {
|
|
printStatusLine("config", "Not linked", "")
|
|
}
|
|
|
|
// Sync status
|
|
if palSyncthingRunning() then {
|
|
let folders = getSyncFolderCount()
|
|
let devices = getSyncDeviceCount()
|
|
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
|
|
} else if hasCommand("syncthing") then {
|
|
printStatusLine("sync", "Ready", "not running")
|
|
} else {
|
|
printStatusLine("sync", "Not installed", "")
|
|
}
|
|
|
|
// Backup status
|
|
let finalRepo = getBackupRepo()
|
|
if String.length(finalRepo) > 0 then {
|
|
printStatusLine("backup", "Configured", finalRepo)
|
|
} else {
|
|
printStatusLine("backup", "Not configured", "")
|
|
}
|
|
|
|
print("")
|
|
|
|
// Connected devices
|
|
if palSyncthingRunning() then {
|
|
printBold("Connected Devices")
|
|
let ignore2 = Process.exec("printf ' \\033[32m●\\033[0m %s \\033[2m(this device)\\033[0m\\n' \"" + deviceName + "\" >&2")
|
|
let otherDevices = execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices[] | .name // .deviceID[:8]' 2>/dev/null | grep -v '^$'")
|
|
if String.length(otherDevices) > 1 then {
|
|
let ignore3 = Process.exec("echo '" + otherDevices + "' | while read -r dev; do printf ' \\033[2m●\\033[0m %s\\n' \"$dev\" >&2; done")
|
|
()
|
|
} else ()
|
|
print("")
|
|
} else {
|
|
printBold("Connected Devices")
|
|
let ignore2 = Process.exec("printf ' \\033[2m●\\033[0m %s \\033[2m(this device, sync not running)\\033[0m\\n' \"" + deviceName + "\" >&2")
|
|
print("")
|
|
}
|
|
|
|
// Synced data
|
|
let syncDir = palDir() + "/sync"
|
|
let hasSyncDir = execQuiet("test -d " + syncDir + " && echo yes || echo no")
|
|
if hasSyncDir |> isYes then {
|
|
printBold("Synced Data")
|
|
let notesCount = execQuiet("find " + syncDir + "/notes -type f 2>/dev/null | wc -l | tr -d ' '")
|
|
let docsCount = execQuiet("find " + syncDir + "/documents -type f 2>/dev/null | wc -l | tr -d ' '")
|
|
let dotfilesCount = execQuiet("find " + syncDir + "/dotfiles -type f 2>/dev/null | wc -l | tr -d ' '")
|
|
let ignore4 = Process.exec("printf ' %-16s %s files\\n' 'notes/' '" + notesCount + "' >&2")
|
|
let ignore5 = Process.exec("printf ' %-16s %s files\\n' 'documents/' '" + docsCount + "' >&2")
|
|
let ignore6 = Process.exec("printf ' %-16s %s files\\n' 'dotfiles/' '" + dotfilesCount + "' >&2")
|
|
print("")
|
|
} else ()
|
|
|
|
printSuccess("Onboarding complete!")
|
|
print("")
|
|
printHint("Data directory: " + palDir())
|
|
print("")
|
|
}
|
|
|
|
fn doOnboardSteps(): Unit with {Console, Process} = {
|
|
// ── Step 1: Device ──────────────────────────────────────────────────
|
|
let ignore = Process.exec("printf '\\033[1m── Step 1: Device ──\\033[0m\\n\\n' >&2")
|
|
|
|
printStepPending("Creating directories")
|
|
createDirectories()
|
|
printStep("Creating directories", "done")
|
|
|
|
let hostname = execQuiet("hostname")
|
|
let deviceName = askInput("Device name", hostname)
|
|
print("")
|
|
|
|
printStepPending("Writing configuration")
|
|
let configContent = "[pal]\nversion = 1\ndevice_name = \"" + deviceName + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\n\n[backup]\nschedule = \"hourly\"\n"
|
|
let writeCmd = "cat > " + palConfig() + " << 'EOF'\n" + configContent + "EOF"
|
|
let ignore2 = Process.exec(writeCmd)
|
|
printStep("Writing configuration", "done")
|
|
|
|
printStepPending("Setting up encryption")
|
|
let hasAgeKey = execQuiet("test -f " + palDir() + "/age-key.txt && echo yes || echo no")
|
|
if hasAgeKey |> isYes then
|
|
printStep("Setting up encryption", "exists")
|
|
else if hasCommand("age-keygen") then {
|
|
let ignore3 = Process.exec("age-keygen -o " + palDir() + "/age-key.txt 2>&1 || true")
|
|
printStep("Setting up encryption", "done")
|
|
} else {
|
|
printStepWarn("Setting up encryption", "age not installed")
|
|
}
|
|
|
|
if hasCommand("sqlite3") then {
|
|
initStateDb()
|
|
logEvent("onboard", "Started onboarding for " + deviceName)
|
|
} else ()
|
|
|
|
print("")
|
|
|
|
// ── Step 2: Config Repository ───────────────────────────────────────
|
|
let ignore4 = Process.exec("printf '\\033[1m── Step 2: Config Repository ──\\033[0m\\n\\n' >&2")
|
|
printHint("A git repo lets you version-control your system config")
|
|
printHint("(NixOS flakes, dotfiles, etc.) and share it across machines.")
|
|
print("")
|
|
|
|
let ignore5 = Process.exec("printf ' \\033[36m1\\033[0m I have a git repo URL\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' \\033[36m2\\033[0m Initialize a new repo in a directory\\n' >&2")
|
|
let ignore7 = Process.exec("printf ' \\033[36m3\\033[0m Skip for now\\n' >&2")
|
|
print("")
|
|
|
|
let ignore8 = Process.exec("printf 'Choice [3]: ' >&2")
|
|
let configChoice = String.trim(Console.readLine())
|
|
print("")
|
|
|
|
match configChoice {
|
|
"1" => {
|
|
let repoUrl = askInput("Repository URL", "")
|
|
if String.length(repoUrl) == 0 then {
|
|
printHint("No URL provided, skipping.")
|
|
} else {
|
|
print("")
|
|
printStepPending("Cloning repository")
|
|
let cloneResult = Process.exec("git clone " + repoUrl + " " + palDir() + "/config-repo 2>&1")
|
|
if String.contains(cloneResult, "fatal") then {
|
|
printStepErr("Cloning repository", "failed")
|
|
printDim(cloneResult)
|
|
print("")
|
|
printHint("You can try again later with:")
|
|
printCmd("pal init " + repoUrl)
|
|
} else {
|
|
printStep("Cloning repository", "done")
|
|
logEvent("onboard", "Cloned config: " + repoUrl)
|
|
}
|
|
}
|
|
},
|
|
"2" => {
|
|
let defaultDir = palDir() + "/config-repo"
|
|
let repoDir = askInput("Directory", defaultDir)
|
|
print("")
|
|
printStepPending("Initializing repository")
|
|
let ignore9 = Process.exec("mkdir -p " + repoDir)
|
|
let initResult = Process.exec("cd " + repoDir + " && git init 2>&1")
|
|
if String.contains(initResult, "nitialized") then {
|
|
printStep("Initializing repository", "done")
|
|
printHint("Add a remote later:")
|
|
printCmd("git -C " + repoDir + " remote add origin <url>")
|
|
logEvent("onboard", "Init config repo: " + repoDir)
|
|
} else {
|
|
printStepErr("Initializing repository", "failed")
|
|
printDim(initResult)
|
|
}
|
|
},
|
|
_ => {
|
|
printHint("Skipping config repository.")
|
|
printHint("Set this up later with:")
|
|
printCmd("pal init <repo-url>")
|
|
}
|
|
}
|
|
|
|
print("")
|
|
|
|
// ── Step 3: File Sync ───────────────────────────────────────────────
|
|
let ignore10 = Process.exec("printf '\\033[1m── Step 3: File Sync ──\\033[0m\\n\\n' >&2")
|
|
printHint("Syncthing keeps notes, documents, and dotfiles")
|
|
printHint("in sync across all your devices in real-time.")
|
|
print("")
|
|
|
|
if hasCommand("syncthing") == false then {
|
|
printStepWarn("Syncthing", "not installed")
|
|
printHint("Install syncthing and run 'pal sync setup' later.")
|
|
} else {
|
|
let setupSync = askConfirm("Set up sync?", true)
|
|
print("")
|
|
if setupSync then {
|
|
let hasConfig = execQuiet("test -f " + palDir() + "/syncthing/config/config.xml && echo yes || echo no")
|
|
if hasConfig |> isNo then {
|
|
printStepPending("Creating sync config")
|
|
let ignore11 = Process.exec("syncthing generate --home=" + palDir() + "/syncthing/config 2>&1")
|
|
printStep("Creating sync config", "done")
|
|
} else {
|
|
printStep("Creating sync config", "exists")
|
|
}
|
|
|
|
if palSyncthingRunning() == false then {
|
|
printStepPending("Starting sync daemon")
|
|
let ignore12 = Process.exec("syncthing serve --home=" + palDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &")
|
|
let ignore13 = Process.exec("sleep 2")
|
|
if palSyncthingRunning() then
|
|
printStep("Starting sync daemon", "done")
|
|
else
|
|
printStepWarn("Starting sync daemon", "may need a moment")
|
|
} else {
|
|
printStep("Starting sync daemon", "running")
|
|
}
|
|
|
|
let deviceId = execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4")
|
|
if String.length(deviceId) > 10 then {
|
|
print("")
|
|
printBold("Your Device ID")
|
|
let ignore14 = Process.exec("printf '\\033[33m%s\\033[0m\\n' \"" + deviceId + "\" >&2")
|
|
print("")
|
|
printQR(deviceId)
|
|
print("")
|
|
printHint("Share this ID or scan the QR code to pair devices.")
|
|
logEvent("onboard", "Sync configured")
|
|
} else {
|
|
printHint("Device ID not ready yet. Try 'pal sync setup' in a moment.")
|
|
}
|
|
} else {
|
|
printHint("Skipping sync setup.")
|
|
printHint("Set this up later with:")
|
|
printCmd("pal sync setup")
|
|
}
|
|
}
|
|
|
|
print("")
|
|
|
|
// ── Step 4: Backups ─────────────────────────────────────────────────
|
|
let ignore15 = Process.exec("printf '\\033[1m── Step 4: Backups ──\\033[0m\\n\\n' >&2")
|
|
printHint("Restic creates encrypted, deduplicated snapshots of")
|
|
printHint("your synced data. Supports local, SFTP, S3, B2, and more.")
|
|
print("")
|
|
|
|
if hasCommand("restic") == false then {
|
|
printStepWarn("Restic", "not installed")
|
|
printHint("Install restic and run 'pal backup init <repo>' later.")
|
|
} else {
|
|
let existingRepo = getBackupRepo()
|
|
let setupBackup = if String.length(existingRepo) > 0 then {
|
|
printHint("Current repository: " + existingRepo)
|
|
print("")
|
|
askConfirm("Reconfigure backup location?", false)
|
|
} else {
|
|
askConfirm("Set up backups?", true)
|
|
}
|
|
print("")
|
|
if setupBackup then {
|
|
let defaultRepo = existingRepo
|
|
printHint("Enter a path or URL where backup snapshots will be stored.")
|
|
print("")
|
|
let ignore15b = Process.exec("printf ' \\033[36mLocal directory\\033[0m /mnt/backup, /media/usb/backups\\n' >&2")
|
|
let ignore15c = Process.exec("printf ' \\033[36mSFTP (SSH)\\033[0m sftp:user@host:/backups\\n' >&2")
|
|
let ignore15d = Process.exec("printf ' \\033[36mBackblaze B2\\033[0m b2:bucket-name:path\\n' >&2")
|
|
let ignore15e = Process.exec("printf ' \\033[36mAmazon S3\\033[0m s3:bucket-name/path\\n' >&2")
|
|
print("")
|
|
let backupRepo = askInput("Backup location", defaultRepo)
|
|
if String.length(backupRepo) == 0 then {
|
|
printHint("Skipping backup setup.")
|
|
printCmd("pal backup init <repo>")
|
|
} else {
|
|
print("")
|
|
let passwordFile = palDir() + "/restic/password"
|
|
let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no")
|
|
if hasPassword |> isNo then {
|
|
printStepPending("Generating password")
|
|
let ignore16 = Process.exec("head -c 32 /dev/urandom | base64 > " + passwordFile + " && chmod 600 " + passwordFile)
|
|
printStep("Generating password", "done")
|
|
} else {
|
|
printStep("Generating password", "exists")
|
|
}
|
|
|
|
printStepPending("Initializing repository")
|
|
let initResult = Process.exec("restic -r " + backupRepo + " --password-file " + passwordFile + " init 2>&1")
|
|
if String.contains(initResult, "created restic repository") || String.contains(initResult, "already initialized") then {
|
|
printStep("Initializing repository", "done")
|
|
let ignore17 = Process.exec("echo '" + backupRepo + "' > " + palDir() + "/restic/repository")
|
|
logEvent("onboard", "Backup: " + backupRepo)
|
|
} else {
|
|
printStepErr("Initializing repository", "failed")
|
|
printDim(initResult)
|
|
printHint("Try again later with:")
|
|
printCmd("pal backup init " + backupRepo)
|
|
}
|
|
}
|
|
} else {
|
|
printHint("Skipping backup setup.")
|
|
printHint("Set this up later with:")
|
|
printCmd("pal backup init <repo>")
|
|
}
|
|
}
|
|
|
|
print("")
|
|
|
|
// ── Summary ─────────────────────────────────────────────────────────
|
|
showOnboardSummary(deviceName)
|
|
|
|
if hasCommand("sqlite3") then {
|
|
logEvent("onboard", "Onboarding completed for " + deviceName)
|
|
} else ()
|
|
}
|
|
|
|
fn doOnboard(): Unit with {Console, Process} = {
|
|
print("")
|
|
|
|
if isInitialized() then {
|
|
printWarn("pal is already set up on this device.")
|
|
print("")
|
|
|
|
// Show current state so user knows what exists
|
|
printBold("Current setup:")
|
|
print("")
|
|
|
|
let deviceName = execQuiet("grep device_name " + palConfig() + " 2>/dev/null | cut -d'\"' -f2")
|
|
if String.length(deviceName) > 0 then
|
|
printStep("Device", deviceName)
|
|
else ()
|
|
|
|
let hasRepo = execQuiet("test -d " + palDir() + "/config-repo/.git && echo yes || echo no")
|
|
if hasRepo |> isYes then
|
|
printStep("Config repo", "configured")
|
|
else
|
|
printStepWarn("Config repo", "not set up")
|
|
|
|
if palSyncthingRunning() then
|
|
printStep("Sync", "running")
|
|
else if hasCommand("syncthing") then
|
|
printStepWarn("Sync", "not running")
|
|
else
|
|
printStepErr("Sync", "not installed")
|
|
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) > 0 then
|
|
printStep("Backup", repo)
|
|
else
|
|
printStepWarn("Backup", "not configured")
|
|
|
|
print("")
|
|
printWarn("Re-running will overwrite your device name and config file.")
|
|
printHint("Existing sync, backup, and repo settings will be kept if you skip those steps.")
|
|
print("")
|
|
|
|
let rerun = askConfirm("Continue?", false)
|
|
if rerun then {
|
|
print("")
|
|
doOnboardSteps()
|
|
} else {
|
|
print("")
|
|
printHint("No changes made.")
|
|
print("")
|
|
}
|
|
} else {
|
|
showLogo()
|
|
printBold("Welcome! Let's get your system set up.")
|
|
print("")
|
|
printHint("This wizard will walk you through configuring:")
|
|
print(" - Device identity")
|
|
print(" - Config repository (git-managed)")
|
|
print(" - File sync (Syncthing)")
|
|
print(" - Backups (restic)")
|
|
print("")
|
|
doOnboardSteps()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Version
|
|
// =============================================================================
|
|
|
|
fn showVersion(): Unit with {Process} = {
|
|
let ignore = Process.exec("printf 'pal 0.2.0\\n' >&2")
|
|
()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Help
|
|
// =============================================================================
|
|
|
|
fn showHelp(): Unit with {Process} = {
|
|
print("")
|
|
let ignore = Process.exec("printf '\\033[1mpal\\033[0m - Your personal data, everywhere\\n' >&2")
|
|
print("")
|
|
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
|
|
let ignore3 = Process.exec("printf ' pal [COMMAND]\\n' >&2")
|
|
print("")
|
|
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
|
|
print("")
|
|
let ignore5 = Process.exec("printf ' \\033[36msetup\\033[0m Initialize pal on this machine\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' \\033[36minit\\033[0m <repo> Clone config from git repository\\n' >&2")
|
|
let ignore6b = Process.exec("printf ' \\033[36monboard\\033[0m Interactive setup wizard\\n' >&2")
|
|
print("")
|
|
let ignore7 = Process.exec("printf ' \\033[36msync\\033[0m Trigger a rescan\\n' >&2")
|
|
let ignore8 = Process.exec("printf ' \\033[36msync status\\033[0m Show sync status\\n' >&2")
|
|
let ignore9 = Process.exec("printf ' \\033[36msync setup\\033[0m Initialize and show device ID\\n' >&2")
|
|
let ignore10 = Process.exec("printf ' \\033[36msync start\\033[0m Start Syncthing daemon\\n' >&2")
|
|
print("")
|
|
let ignore11 = Process.exec("printf ' \\033[36mbackup\\033[0m Create a backup snapshot\\n' >&2")
|
|
let ignore12 = Process.exec("printf ' \\033[36mbackup list\\033[0m List snapshots\\n' >&2")
|
|
let ignore13 = Process.exec("printf ' \\033[36mbackup init\\033[0m <repo> Initialize backup repository\\n' >&2")
|
|
print("")
|
|
let ignore14 = Process.exec("printf ' \\033[36mserver\\033[0m Show server mount status\\n' >&2")
|
|
let ignore15 = Process.exec("printf ' \\033[36mserver mount\\033[0m Mount remote server\\n' >&2")
|
|
let ignore16 = Process.exec("printf ' \\033[36mserver unmount\\033[0m Unmount server\\n' >&2")
|
|
print("")
|
|
let ignore17 = Process.exec("printf ' \\033[36mstatus\\033[0m Show detailed status\\n' >&2")
|
|
let ignore18 = Process.exec("printf ' \\033[36mdoctor\\033[0m Check system dependencies\\n' >&2")
|
|
let ignore19 = Process.exec("printf ' \\033[36mhistory\\033[0m Show recent events\\n' >&2")
|
|
let ignore20 = Process.exec("printf ' \\033[36mexport\\033[0m Create portable archive\\n' >&2")
|
|
let ignore21 = Process.exec("printf ' \\033[36mimport\\033[0m <file> Restore from archive\\n' >&2")
|
|
print("")
|
|
let ignore22 = Process.exec("printf '\\033[1mOPTIONS\\033[0m\\n' >&2")
|
|
let ignore23 = Process.exec("printf ' -h, --help Show this help\\n' >&2")
|
|
let ignore24 = Process.exec("printf ' -V, --version Show version\\n' >&2")
|
|
print("")
|
|
printHint("Data directory: " + palDir())
|
|
print("")
|
|
}
|
|
|
|
// Subcommand-specific help
|
|
fn showSyncHelp(): Unit with {Process} = {
|
|
print("")
|
|
let ignore = Process.exec("printf '\\033[1mpal sync\\033[0m - Real-time file synchronization\\n' >&2")
|
|
print("")
|
|
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
|
|
let ignore3 = Process.exec("printf ' pal sync [COMMAND]\\n' >&2")
|
|
print("")
|
|
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
|
|
let ignore5 = Process.exec("printf ' (default) Trigger a rescan of all folders\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' status Show Syncthing status\\n' >&2")
|
|
let ignore7 = Process.exec("printf ' setup Initialize and display device ID for pairing\\n' >&2")
|
|
let ignore8 = Process.exec("printf ' start Start Syncthing daemon\\n' >&2")
|
|
print("")
|
|
}
|
|
|
|
fn showBackupHelp(): Unit with {Process} = {
|
|
print("")
|
|
let ignore = Process.exec("printf '\\033[1mpal backup\\033[0m - Point-in-time snapshots\\n' >&2")
|
|
print("")
|
|
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
|
|
let ignore3 = Process.exec("printf ' pal backup [COMMAND]\\n' >&2")
|
|
print("")
|
|
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
|
|
let ignore5 = Process.exec("printf ' (default) Create a new backup snapshot\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' list List all snapshots\\n' >&2")
|
|
let ignore7 = Process.exec("printf ' init <repo> Initialize backup repository\\n' >&2")
|
|
print("")
|
|
let ignore8 = Process.exec("printf '\\033[1mEXAMPLES\\033[0m\\n' >&2")
|
|
let ignore9 = Process.exec("printf ' pal backup init /mnt/backup\\n' >&2")
|
|
let ignore10 = Process.exec("printf ' pal backup init sftp:user@server:/backups\\n' >&2")
|
|
let ignore11 = Process.exec("printf ' pal backup init b2:bucket-name:path\\n' >&2")
|
|
print("")
|
|
}
|
|
|
|
fn showServerHelp(): Unit with {Process} = {
|
|
print("")
|
|
let ignore = Process.exec("printf '\\033[1mpal server\\033[0m - Remote server storage\\n' >&2")
|
|
print("")
|
|
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
|
|
let ignore3 = Process.exec("printf ' pal server [COMMAND]\\n' >&2")
|
|
print("")
|
|
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
|
|
let ignore5 = Process.exec("printf ' (default) Show mount status\\n' >&2")
|
|
let ignore6 = Process.exec("printf ' mount Mount remote server\\n' >&2")
|
|
let ignore7 = Process.exec("printf ' unmount Unmount server\\n' >&2")
|
|
print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main
|
|
// =============================================================================
|
|
|
|
fn main(): Unit with {Console, Process} = {
|
|
let args = Process.args()
|
|
let cmd = match List.get(args, 1) {
|
|
Some(c) => c,
|
|
None => ""
|
|
}
|
|
let subcmd = match List.get(args, 2) {
|
|
Some(s) => s,
|
|
None => ""
|
|
}
|
|
let arg3 = match List.get(args, 3) {
|
|
Some(a) => a,
|
|
None => ""
|
|
}
|
|
|
|
match cmd {
|
|
"" => showHealthCheck(),
|
|
"-V" => showVersion(),
|
|
"--version" => showVersion(),
|
|
"version" => showVersion(),
|
|
"init" => doInit(subcmd),
|
|
"setup" => doSetup(),
|
|
"onboard" => doOnboard(),
|
|
"sync" => {
|
|
match subcmd {
|
|
"-h" => showSyncHelp(),
|
|
"--help" => showSyncHelp(),
|
|
"help" => showSyncHelp(),
|
|
"status" => doSyncStatus(),
|
|
"setup" => doSyncSetup(),
|
|
"start" => doSyncStart(),
|
|
"" => doSync(),
|
|
_ => doSyncStatus()
|
|
}
|
|
},
|
|
"backup" => {
|
|
match subcmd {
|
|
"-h" => showBackupHelp(),
|
|
"--help" => showBackupHelp(),
|
|
"help" => showBackupHelp(),
|
|
"init" => doBackupInit(arg3),
|
|
"list" => doBackupList(),
|
|
"" => doBackup(),
|
|
_ => doBackup()
|
|
}
|
|
},
|
|
"server" => {
|
|
match subcmd {
|
|
"-h" => showServerHelp(),
|
|
"--help" => showServerHelp(),
|
|
"help" => showServerHelp(),
|
|
"mount" => doServerMount(),
|
|
"unmount" => doServerUnmount(),
|
|
"" => doServerStatus(),
|
|
_ => doServerStatus()
|
|
}
|
|
},
|
|
"export" => doExport(),
|
|
"import" => doImport(subcmd),
|
|
"status" => showStatus(),
|
|
"doctor" => showDoctor(),
|
|
"history" => showHistory(),
|
|
"dashboard" => doDashboard(),
|
|
"help" => showHelp(),
|
|
"-h" => showHelp(),
|
|
"--help" => showHelp(),
|
|
_ => {
|
|
// Unknown command - suggest similar
|
|
printErr("Unknown command: " + cmd)
|
|
print("")
|
|
printHint("Run 'pal --help' for usage.")
|
|
print("")
|
|
}
|
|
}
|
|
}
|
|
|
|
let result = run main() with {}
|