Phase 5 (Server): - grapho server setup/mount/unmount/ls commands - SSHFS/NFS mount instructions Phase 6 (SQLite): - State database for event tracking - grapho history command - Events logged for sync/backup operations Phase 7 (Export): - grapho export creates tar.zst archive - grapho import restores from archive - Full data portability between machines Phase 8 (Dashboard): - grapho dashboard generates HTML status page - Dark theme, mobile-friendly - Can be served with python http.server Self-contained improvements: - grapho setup now auto-initializes Syncthing - grapho backup init <repo> runs restic init directly - grapho backup runs restic backup directly - grapho backup list shows snapshots directly - All configs saved to ~/.config/grapho/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1092 lines
46 KiB
Plaintext
1092 lines
46 KiB
Plaintext
// grapho - Unified Personal Data Infrastructure
|
|
//
|
|
// Four data types:
|
|
// 1. Config - Declarative machine setup (NixOS + Git)
|
|
// 2. Sync - Real-time bidirectional sync (Syncthing)
|
|
// 3. Backup - Periodic snapshots (Restic)
|
|
// 4. Server - Large files on central server
|
|
//
|
|
// Usage:
|
|
// grapho Health check (or welcome if uninitialized)
|
|
// grapho init <repo-url> Initialize from config repo
|
|
// grapho setup Interactive setup wizard
|
|
// grapho sync Sync all folders
|
|
// grapho sync status Show sync status
|
|
// grapho backup Run backup now
|
|
// grapho backup list List snapshots
|
|
// grapho status Full status dashboard
|
|
// grapho doctor Diagnose issues
|
|
// grapho help Show help
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
type SyncStatus =
|
|
| StatusOk
|
|
| StatusWarn
|
|
| StatusErr
|
|
| StatusNone
|
|
|
|
// =============================================================================
|
|
// Status icons
|
|
// =============================================================================
|
|
|
|
fn statusIcon(s: SyncStatus): String =
|
|
match s {
|
|
StatusOk => "[ok]",
|
|
StatusWarn => "[!!]",
|
|
StatusErr => "[ERR]",
|
|
StatusNone => "[--]"
|
|
}
|
|
|
|
fn statusEmoji(s: SyncStatus): String =
|
|
match s {
|
|
StatusOk => "✓",
|
|
StatusWarn => "⚠",
|
|
StatusErr => "✗",
|
|
StatusNone => "-"
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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"
|
|
}
|
|
|
|
// String comparison helpers (workaround for Lux C backend bug with == on strings)
|
|
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 graphoDir(): String with {Process} =
|
|
homeDir() + "/.config/grapho"
|
|
|
|
fn graphoConfig(): String with {Process} =
|
|
graphoDir() + "/grapho.toml"
|
|
|
|
// =============================================================================
|
|
// Initialization detection
|
|
// =============================================================================
|
|
|
|
fn isInitialized(): Bool with {Process} = {
|
|
let result = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
|
|
result |> isYes
|
|
}
|
|
|
|
fn ensureDir(path: String): Unit with {Process} = {
|
|
let ignore = Process.exec("mkdir -p " + path)
|
|
()
|
|
}
|
|
|
|
fn stateDb(): String with {Process} =
|
|
graphoDir() + "/state.db"
|
|
|
|
// =============================================================================
|
|
// SQLite 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); CREATE TABLE IF NOT EXISTS backups (id INTEGER PRIMARY KEY, snapshot_id TEXT, timestamp TEXT, size_bytes INTEGER, file_count INTEGER, status TEXT); CREATE TABLE IF NOT EXISTS devices (device_id TEXT PRIMARY KEY, name TEXT, last_seen TEXT, status 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")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Directory structure
|
|
// =============================================================================
|
|
|
|
fn createDirectories(): Unit with {Process, Console} = {
|
|
let base = graphoDir()
|
|
Console.print("Creating directory structure...")
|
|
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")
|
|
Console.print(statusIcon(StatusOk) + " Directories created")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Welcome flow
|
|
// =============================================================================
|
|
|
|
fn showWelcome(): Unit with {Console, Process} = {
|
|
Console.print("")
|
|
Console.print(" Welcome to grapho")
|
|
Console.print("")
|
|
Console.print(" grapho manages your data across all your devices:")
|
|
Console.print("")
|
|
Console.print(" * Config - Your machine setup, restored anywhere")
|
|
Console.print(" * Sync - Notes & docs synced across devices")
|
|
Console.print(" * Backup - Snapshots you can restore anytime")
|
|
Console.print(" * Server - Large files on your server")
|
|
Console.print("")
|
|
Console.print(" To get started, run:")
|
|
Console.print("")
|
|
Console.print(" grapho setup # Interactive setup wizard")
|
|
Console.print(" grapho init <url> # Initialize from existing config repo")
|
|
Console.print("")
|
|
Console.print(" For more info: grapho help")
|
|
Console.print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Setup wizard
|
|
// =============================================================================
|
|
|
|
fn doSetup(): Unit with {Console, Process} = {
|
|
Console.print("")
|
|
Console.print("grapho setup wizard")
|
|
Console.print("====================")
|
|
Console.print("")
|
|
|
|
// Create directories
|
|
createDirectories()
|
|
Console.print("")
|
|
|
|
// Create initial config
|
|
Console.print("Creating initial configuration...")
|
|
let configContent = "[grapho]\nversion = 1\ndevice_name = \"" + execQuiet("hostname") + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\nfolders = [\"notes\", \"documents\", \"dotfiles\"]\n\n[backup]\nschedule = \"hourly\"\n\n[services]\n"
|
|
|
|
let writeCmd = "cat > " + graphoConfig() + " << 'GRAPHO_EOF'\n" + configContent + "GRAPHO_EOF"
|
|
let ignore = Process.exec(writeCmd)
|
|
Console.print(statusIcon(StatusOk) + " Configuration created at " + graphoConfig())
|
|
Console.print("")
|
|
|
|
// Generate age key if missing
|
|
let hasAgeKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
|
|
if hasAgeKey |> isYes then
|
|
Console.print(statusIcon(StatusOk) + " Age encryption key exists")
|
|
else {
|
|
Console.print("Generating age encryption key...")
|
|
let ignore = Process.exec("age-keygen -o " + graphoDir() + "/age-key.txt 2>&1 || true")
|
|
Console.print(statusIcon(StatusOk) + " Age key generated")
|
|
}
|
|
Console.print("")
|
|
|
|
// Initialize state database
|
|
if hasCommand("sqlite3") then {
|
|
Console.print("Initializing state database...")
|
|
initStateDb()
|
|
logEvent("setup", "Grapho initialized")
|
|
Console.print(statusIcon(StatusOk) + " State database created at " + stateDb())
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " sqlite3 not found - state tracking disabled")
|
|
}
|
|
Console.print("")
|
|
|
|
// Initialize Syncthing automatically
|
|
if hasCommand("syncthing") then {
|
|
Console.print("Setting up Syncthing...")
|
|
let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no")
|
|
if hasConfig |> isNo then {
|
|
let ignore = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1")
|
|
let ignore2 = Process.exec("sed -i 's/<listenAddress>default</<listenAddress>tcp:\\/\\/0.0.0.0:22001</' " + graphoDir() + "/syncthing/config/config.xml 2>/dev/null || true")
|
|
Console.print(statusIcon(StatusOk) + " Syncthing configured (GUI: 8385, Sync: 22001)")
|
|
} else {
|
|
Console.print(statusIcon(StatusOk) + " Syncthing already configured")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " Syncthing not found")
|
|
Console.print(" Enable in NixOS: services.grapho.enable = true;")
|
|
}
|
|
|
|
// Check for restic
|
|
if hasCommand("restic") then {
|
|
Console.print(statusIcon(StatusOk) + " Restic available for backups")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " Restic not found")
|
|
Console.print(" Enable in NixOS: services.grapho.enable = true;")
|
|
}
|
|
|
|
Console.print("")
|
|
Console.print("Setup complete!")
|
|
Console.print("")
|
|
Console.print("Your grapho data is stored in: " + graphoDir())
|
|
Console.print("")
|
|
Console.print("Next steps:")
|
|
Console.print(" grapho status # Check system health")
|
|
Console.print(" grapho sync setup # Configure device pairing")
|
|
Console.print(" grapho backup init # Configure backups")
|
|
Console.print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Init from repo
|
|
// =============================================================================
|
|
|
|
fn doInit(repoUrl: String): Unit with {Console, Process} = {
|
|
if String.length(repoUrl) == 0 then {
|
|
Console.print("Usage: grapho init <repo-url>")
|
|
Console.print("")
|
|
Console.print("Clone a grapho config repository to set up this machine.")
|
|
Console.print("")
|
|
Console.print("Example:")
|
|
Console.print(" grapho init https://github.com/user/grapho-config")
|
|
Console.print(" grapho init git@github.com:user/grapho-config.git")
|
|
} else {
|
|
Console.print("Initializing from " + repoUrl)
|
|
Console.print("")
|
|
|
|
// Create base directory
|
|
createDirectories()
|
|
|
|
// Clone repo
|
|
Console.print("Cloning config repository...")
|
|
let cloneResult = Process.exec("git clone " + repoUrl + " " + graphoDir() + "/config-repo 2>&1")
|
|
if String.contains(cloneResult, "fatal") then {
|
|
Console.print(statusIcon(StatusErr) + " Failed to clone repository")
|
|
Console.print(" " + cloneResult)
|
|
} else {
|
|
Console.print(statusIcon(StatusOk) + " Repository cloned")
|
|
|
|
// Check for flake.nix
|
|
let hasFlake = execQuiet("test -f " + graphoDir() + "/config-repo/flake.nix && echo yes || echo no")
|
|
if hasFlake |> isYes then {
|
|
Console.print(statusIcon(StatusOk) + " Found flake.nix")
|
|
Console.print("")
|
|
Console.print("To apply the NixOS configuration:")
|
|
Console.print(" cd " + graphoDir() + "/config-repo")
|
|
Console.print(" sudo nixos-rebuild switch --flake .#<hostname>")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " No flake.nix found")
|
|
Console.print(" This repo may not be a NixOS configuration")
|
|
}
|
|
}
|
|
Console.print("")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Syncthing helpers (isolated grapho instance)
|
|
// =============================================================================
|
|
|
|
fn graphoSyncthingRunning(): Bool with {Process} = {
|
|
// Check if Syncthing is running on grapho port (8385)
|
|
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 getSyncthingDeviceId(): String with {Process} = {
|
|
execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show system 2>/dev/null | grep -o 'myID.*' | cut -d'\"' -f3")
|
|
}
|
|
|
|
fn getSyncFolderCount(): String with {Process} = {
|
|
execQuiet("syncthing cli --home=" + graphoDir() + "/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=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Sync commands
|
|
// =============================================================================
|
|
|
|
fn doSyncStatus(): Unit with {Console, Process} = {
|
|
Console.print("grapho sync status")
|
|
Console.print("")
|
|
|
|
if hasCommand("syncthing") then {
|
|
if graphoSyncthingRunning() then {
|
|
let folders = getSyncFolderCount()
|
|
let devices = getSyncDeviceCount()
|
|
Console.print(statusIcon(StatusOk) + " Syncthing running on port 8385")
|
|
Console.print(" Folders: " + folders)
|
|
Console.print(" Devices: " + devices)
|
|
Console.print("")
|
|
Console.print(" Web UI: http://127.0.0.1:8385")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " Grapho Syncthing not running")
|
|
Console.print("")
|
|
Console.print(" Start with: systemctl --user start syncthing-grapho")
|
|
Console.print(" Or: syncthing serve --home=" + graphoDir() + "/syncthing/config --gui-address=127.0.0.1:8385")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " Syncthing not installed")
|
|
Console.print(" Install via nix or NixOS config")
|
|
}
|
|
}
|
|
|
|
fn doSyncSetup(): Unit with {Console, Process} = {
|
|
Console.print("grapho sync setup")
|
|
Console.print("")
|
|
|
|
if hasCommand("syncthing") == false then {
|
|
Console.print(statusIcon(StatusErr) + " Syncthing not installed")
|
|
Console.print(" Add to your NixOS config: services.grapho.enable = true;")
|
|
} else {
|
|
// Initialize Syncthing config if needed
|
|
let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no")
|
|
if hasConfig |> isNo then {
|
|
Console.print("Initializing Syncthing for grapho...")
|
|
let ignore = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1")
|
|
Console.print(statusIcon(StatusOk) + " Syncthing config created")
|
|
|
|
// Update listen address in config
|
|
let ignore2 = Process.exec("sed -i 's/<listenAddress>default</<listenAddress>tcp:\\/\\/0.0.0.0:22001</' " + graphoDir() + "/syncthing/config/config.xml 2>/dev/null || true")
|
|
Console.print(statusIcon(StatusOk) + " Configured ports (GUI: 8385, Sync: 22001)")
|
|
} else {
|
|
Console.print(statusIcon(StatusOk) + " Syncthing already configured")
|
|
}
|
|
|
|
// Start Syncthing if not running
|
|
if graphoSyncthingRunning() == false then {
|
|
Console.print("")
|
|
Console.print("Starting Syncthing...")
|
|
let ignore = Process.exec("syncthing serve --home=" + graphoDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 &")
|
|
// Wait a moment for it to start
|
|
let ignore2 = Process.exec("sleep 2")
|
|
} else {
|
|
Console.print(statusIcon(StatusOk) + " Syncthing already running")
|
|
}
|
|
|
|
// Get device ID
|
|
Console.print("")
|
|
let deviceId = execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4")
|
|
if String.length(deviceId) > 10 then {
|
|
Console.print("Your device ID:")
|
|
Console.print(" " + deviceId)
|
|
Console.print("")
|
|
Console.print("Share this ID with other devices to pair.")
|
|
Console.print("Web UI: http://127.0.0.1:8385")
|
|
logEvent("sync", "Syncthing setup complete")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " Could not get device ID")
|
|
Console.print(" Syncthing may still be starting. Try: grapho sync status")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn doSync(): Unit with {Console, Process} = {
|
|
Console.print("Syncing...")
|
|
|
|
if graphoSyncthingRunning() then {
|
|
Console.print("-> Triggering Syncthing rescan")
|
|
let ignore = Process.exec("syncthing cli --home=" + graphoDir() + "/syncthing/config scan 2>/dev/null || true")
|
|
Console.print(statusIcon(StatusOk) + " Sync triggered")
|
|
logEvent("sync", "Manual sync triggered")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " Syncthing not running")
|
|
Console.print(" Start with: grapho sync setup")
|
|
}
|
|
Console.print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Backup commands
|
|
// =============================================================================
|
|
|
|
fn doBackupInit(repoArg: String): Unit with {Console, Process} = {
|
|
Console.print("grapho backup init")
|
|
Console.print("")
|
|
|
|
if hasCommand("restic") == false then {
|
|
Console.print(statusIcon(StatusErr) + " Restic not installed")
|
|
Console.print(" Add to your NixOS config: services.grapho.enable = true;")
|
|
} else {
|
|
if String.length(repoArg) == 0 then {
|
|
Console.print("Usage: grapho backup init <repository>")
|
|
Console.print("")
|
|
Console.print("Examples:")
|
|
Console.print(" grapho backup init /mnt/backup/grapho")
|
|
Console.print(" grapho backup init sftp:server:/backups/grapho")
|
|
Console.print(" grapho backup init b2:bucket-name:grapho")
|
|
Console.print("")
|
|
Console.print("This will initialize a new restic repository and save the config.")
|
|
} else {
|
|
Console.print("Initializing backup repository: " + repoArg)
|
|
Console.print("")
|
|
|
|
// Generate a password file if it doesn't exist
|
|
let passwordFile = graphoDir() + "/restic/password"
|
|
let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no")
|
|
if hasPassword |> isNo then {
|
|
Console.print("Generating backup password...")
|
|
let ignore = Process.exec("head -c 32 /dev/urandom | base64 > " + passwordFile + " && chmod 600 " + passwordFile)
|
|
Console.print(statusIcon(StatusOk) + " Password saved to " + passwordFile)
|
|
} else {
|
|
Console.print(statusIcon(StatusOk) + " Using existing password from " + passwordFile)
|
|
}
|
|
Console.print("")
|
|
|
|
// Initialize restic repository
|
|
Console.print("Initializing restic repository...")
|
|
let initResult = Process.exec("restic -r " + repoArg + " --password-file " + passwordFile + " init 2>&1")
|
|
|
|
if String.contains(initResult, "created restic repository") then {
|
|
Console.print(statusIcon(StatusOk) + " Repository initialized")
|
|
Console.print("")
|
|
|
|
// Save repository path to a simple file
|
|
let repoFile = graphoDir() + "/restic/repository"
|
|
let ignore = Process.exec("echo '" + repoArg + "' > " + repoFile)
|
|
|
|
Console.print(statusIcon(StatusOk) + " Repository saved to " + repoFile)
|
|
Console.print("")
|
|
Console.print("Backup configured! Files created:")
|
|
Console.print(" " + passwordFile + " (keep this safe!)")
|
|
Console.print(" " + repoFile)
|
|
Console.print("")
|
|
Console.print("To run a backup: grapho backup")
|
|
logEvent("backup", "Repository initialized: " + repoArg)
|
|
} else if String.contains(initResult, "already initialized") then {
|
|
Console.print(statusIcon(StatusOk) + " Repository already initialized")
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " Failed to initialize repository")
|
|
Console.print(initResult)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn getBackupRepo(): String with {Process} = {
|
|
let repoFile = graphoDir() + "/restic/repository"
|
|
execQuiet("cat " + repoFile + " 2>/dev/null")
|
|
}
|
|
|
|
fn getBackupPassword(): String with {Process} = {
|
|
graphoDir() + "/restic/password"
|
|
}
|
|
|
|
fn doBackupList(): Unit with {Console, Process} = {
|
|
Console.print("grapho backup list")
|
|
Console.print("")
|
|
|
|
if hasCommand("restic") == false then {
|
|
Console.print(statusIcon(StatusErr) + " Restic not installed")
|
|
} else {
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) == 0 then {
|
|
Console.print(statusIcon(StatusWarn) + " No backup repository configured")
|
|
Console.print(" Run: grapho backup init <repository>")
|
|
} else {
|
|
Console.print("Repository: " + repo)
|
|
Console.print("")
|
|
let snapshots = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots 2>&1")
|
|
Console.print(snapshots)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn doBackup(): Unit with {Console, Process} = {
|
|
Console.print("Running backup...")
|
|
Console.print("")
|
|
|
|
if hasCommand("restic") == false then {
|
|
Console.print(statusIcon(StatusErr) + " Restic not installed")
|
|
} else {
|
|
let repo = getBackupRepo()
|
|
if String.length(repo) == 0 then {
|
|
Console.print(statusIcon(StatusWarn) + " No backup repository configured")
|
|
Console.print(" Run: grapho backup init <repository>")
|
|
} else {
|
|
Console.print("Repository: " + repo)
|
|
Console.print("Backing up: " + graphoDir() + "/sync")
|
|
Console.print("")
|
|
|
|
let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + graphoDir() + "/sync 2>&1")
|
|
Console.print(backupResult)
|
|
|
|
if String.contains(backupResult, "snapshot") then {
|
|
Console.print("")
|
|
Console.print(statusIcon(StatusOk) + " Backup complete")
|
|
logEvent("backup", "Backup completed successfully")
|
|
} else {
|
|
Console.print("")
|
|
Console.print(statusIcon(StatusErr) + " Backup may have failed")
|
|
logEvent("backup", "Backup failed or incomplete")
|
|
}
|
|
}
|
|
}
|
|
Console.print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Server commands
|
|
// =============================================================================
|
|
|
|
fn doServerStatus(): Unit with {Console, Process} = {
|
|
Console.print("grapho server status")
|
|
Console.print("")
|
|
|
|
let serverDir = graphoDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
let usage = execQuiet("df -h " + serverDir + " | tail -1 | tr -s ' ' | cut -d' ' -f3,2,5 | tr ' ' '/'")
|
|
Console.print(statusIcon(StatusOk) + " Server mounted at " + serverDir)
|
|
Console.print(" Usage: " + usage)
|
|
Console.print("")
|
|
Console.print("Commands:")
|
|
Console.print(" grapho server unmount Unmount server")
|
|
Console.print(" grapho server ls List server contents")
|
|
} else {
|
|
Console.print(statusIcon(StatusNone) + " Server not mounted")
|
|
Console.print("")
|
|
Console.print("To configure server access, add to " + graphoConfig() + ":")
|
|
Console.print("")
|
|
Console.print(" [server]")
|
|
Console.print(" type = \"syncthing\" # or \"nfs\", \"sshfs\"")
|
|
Console.print(" host = \"server.local\"")
|
|
Console.print(" path = \"/data/shared\"")
|
|
Console.print("")
|
|
Console.print("Then run: grapho server mount")
|
|
}
|
|
}
|
|
|
|
fn doServerSetup(): Unit with {Console, Process} = {
|
|
Console.print("grapho server setup")
|
|
Console.print("")
|
|
Console.print("Server data (Type 4) provides access to large files on a central server.")
|
|
Console.print("Unlike synced data, server files are accessed on-demand, not copied locally.")
|
|
Console.print("")
|
|
Console.print("Options:")
|
|
Console.print("")
|
|
Console.print("1. Syncthing (selective sync)")
|
|
Console.print(" - Use .stignore to exclude large folders")
|
|
Console.print(" - Full read/write access")
|
|
Console.print(" - Works offline for synced folders")
|
|
Console.print("")
|
|
Console.print("2. NFS mount")
|
|
Console.print(" - Direct network filesystem access")
|
|
Console.print(" - Requires NFS server on remote machine")
|
|
Console.print(" - Automounts on access")
|
|
Console.print("")
|
|
Console.print("3. SSHFS mount")
|
|
Console.print(" - Mount via SSH (no server setup needed)")
|
|
Console.print(" - Requires SSH access to remote machine")
|
|
Console.print("")
|
|
Console.print("Configure in " + graphoConfig() + " under [server] section.")
|
|
}
|
|
|
|
fn doServerMount(): Unit with {Console, Process} = {
|
|
Console.print("Mounting server...")
|
|
|
|
let serverDir = graphoDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
Console.print(statusIcon(StatusOk) + " Already mounted at " + serverDir)
|
|
} else {
|
|
// Check for NFS/SSHFS configuration in grapho.toml
|
|
// For now, show instructions
|
|
Console.print(statusIcon(StatusWarn) + " Manual mount required")
|
|
Console.print("")
|
|
Console.print("For SSHFS:")
|
|
Console.print(" sshfs user@server:/path " + serverDir)
|
|
Console.print("")
|
|
Console.print("For NFS (configure in NixOS):")
|
|
Console.print(" services.grapho.server.enable = true;")
|
|
Console.print(" services.grapho.server.type = \"nfs\";")
|
|
Console.print(" services.grapho.server.host = \"server.local\";")
|
|
Console.print(" services.grapho.server.remotePath = \"/data/shared\";")
|
|
}
|
|
}
|
|
|
|
fn doServerUnmount(): Unit with {Console, Process} = {
|
|
Console.print("Unmounting server...")
|
|
|
|
let serverDir = graphoDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
let result = Process.exec("fusermount -u " + serverDir + " 2>&1 || umount " + serverDir + " 2>&1")
|
|
let stillMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
if stillMounted |> isNo then
|
|
Console.print(statusIcon(StatusOk) + " Unmounted " + serverDir)
|
|
else {
|
|
Console.print(statusIcon(StatusErr) + " Failed to unmount")
|
|
Console.print(" Try: sudo umount " + serverDir)
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusNone) + " Not mounted")
|
|
}
|
|
}
|
|
|
|
fn doServerLs(): Unit with {Console, Process} = {
|
|
let serverDir = graphoDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
|
|
if isMounted |> isYes then {
|
|
Console.print("Contents of " + serverDir + ":")
|
|
Console.print("")
|
|
let listing = Process.exec("ls -la " + serverDir + " 2>&1")
|
|
Console.print(listing)
|
|
} else {
|
|
Console.print(statusIcon(StatusNone) + " Server not mounted")
|
|
Console.print(" Run: grapho server mount")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// One-liner health check
|
|
// =============================================================================
|
|
|
|
fn showHealthCheck(): Unit with {Console, Process} = {
|
|
// Check if initialized
|
|
if isInitialized() == false then {
|
|
showWelcome()
|
|
} else {
|
|
let configOk = true
|
|
let syncOk = graphoSyncthingRunning()
|
|
let backupOk = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null") |> isActive
|
|
|
|
let folders = if syncOk then getSyncFolderCount() else "0"
|
|
let devices = if syncOk then getSyncDeviceCount() else "0"
|
|
|
|
// Determine issues
|
|
if syncOk == false || backupOk == false then {
|
|
if syncOk == false && backupOk == false then {
|
|
Console.print(statusEmoji(StatusWarn) + " Warnings")
|
|
Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho")
|
|
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init")
|
|
} else if syncOk == false then {
|
|
Console.print(statusEmoji(StatusWarn) + " Sync not running")
|
|
Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho")
|
|
} else {
|
|
Console.print(statusEmoji(StatusWarn) + " Backup timer inactive")
|
|
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init")
|
|
}
|
|
} else {
|
|
Console.print(statusEmoji(StatusOk) + " All systems healthy (" + folders + " folders, " + devices + " devices, backup active)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Status dashboard
|
|
// =============================================================================
|
|
|
|
fn showStatus(): Unit with {Console, Process} = {
|
|
Console.print("grapho status")
|
|
Console.print("")
|
|
|
|
if isInitialized() == false then {
|
|
Console.print(statusIcon(StatusNone) + " grapho not initialized")
|
|
Console.print(" Run: grapho setup")
|
|
Console.print("")
|
|
} else {
|
|
// Config
|
|
let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no")
|
|
if hasRepo |> isYes then {
|
|
let repoRemote = execQuiet("cd " + graphoDir() + "/config-repo && git remote get-url origin 2>/dev/null || echo local")
|
|
Console.print(statusIcon(StatusOk) + " config: " + repoRemote)
|
|
} else {
|
|
Console.print(statusIcon(StatusNone) + " config: no repo linked")
|
|
Console.print(" Run: grapho init <repo-url>")
|
|
}
|
|
|
|
// Sync
|
|
if graphoSyncthingRunning() then {
|
|
let folders = getSyncFolderCount()
|
|
let devices = getSyncDeviceCount()
|
|
Console.print(statusIcon(StatusOk) + " sync: " + folders + " folders, " + devices + " devices")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " sync: not running")
|
|
Console.print(" Run: grapho sync setup")
|
|
}
|
|
|
|
// Backup
|
|
let timerActive = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null")
|
|
if timerActive |> isActive then {
|
|
Console.print(statusIcon(StatusOk) + " backup: timer active")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " backup: timer inactive")
|
|
Console.print(" Run: grapho backup init")
|
|
}
|
|
|
|
// Server
|
|
let serverDir = graphoDir() + "/server"
|
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
if isMounted |> isYes then {
|
|
Console.print(statusIcon(StatusOk) + " server: mounted")
|
|
} else {
|
|
Console.print(statusIcon(StatusNone) + " server: not configured")
|
|
}
|
|
}
|
|
Console.print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Doctor command
|
|
// =============================================================================
|
|
|
|
fn showDoctor(): Unit with {Console, Process} = {
|
|
Console.print("grapho doctor")
|
|
Console.print("")
|
|
|
|
// Check nix
|
|
if hasCommand("nix") then {
|
|
let nixVer = execQuiet("nix --version")
|
|
Console.print(statusIcon(StatusOk) + " nix: " + nixVer)
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " nix: not found")
|
|
Console.print(" Install: https://nixos.org/download")
|
|
}
|
|
|
|
// Check git
|
|
if hasCommand("git") then {
|
|
let gitVer = execQuiet("git --version | cut -d' ' -f3")
|
|
Console.print(statusIcon(StatusOk) + " git: " + gitVer)
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " git: not found")
|
|
}
|
|
|
|
// Check syncthing
|
|
if hasCommand("syncthing") then {
|
|
if graphoSyncthingRunning() then {
|
|
Console.print(statusIcon(StatusOk) + " syncthing: running on port 8385")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " syncthing: installed but not running")
|
|
Console.print(" Fix: grapho sync setup")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
|
|
}
|
|
|
|
// Check restic
|
|
if hasCommand("restic") then {
|
|
Console.print(statusIcon(StatusOk) + " restic: installed")
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " restic: not installed")
|
|
}
|
|
|
|
// Check age
|
|
if hasCommand("age") then {
|
|
let hasKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
|
|
if hasKey |> isYes then {
|
|
Console.print(statusIcon(StatusOk) + " age: key exists")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " age: no key at " + graphoDir() + "/age-key.txt")
|
|
Console.print(" Fix: grapho setup")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " age: not installed")
|
|
}
|
|
|
|
// Directory structure
|
|
Console.print("")
|
|
Console.print("Directory structure:")
|
|
let hasGraphoDir = execQuiet("test -d " + graphoDir() + " && echo yes || echo no")
|
|
if hasGraphoDir |> isYes then {
|
|
Console.print(statusIcon(StatusOk) + " " + graphoDir())
|
|
checkDir("config-repo")
|
|
checkDir("syncthing")
|
|
checkDir("sync")
|
|
checkDir("restic")
|
|
checkDir("server")
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " " + graphoDir() + " does not exist")
|
|
Console.print(" Fix: grapho setup")
|
|
}
|
|
|
|
Console.print("")
|
|
}
|
|
|
|
fn checkDir(dir: String): Unit with {Console, Process} = {
|
|
let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no")
|
|
if exists |> isYes then
|
|
Console.print(" " + statusIcon(StatusOk) + " " + dir + "/")
|
|
else
|
|
Console.print(" " + statusIcon(StatusNone) + " " + dir + "/ (missing)")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Dashboard command
|
|
// =============================================================================
|
|
|
|
fn doDashboard(): Unit with {Console, Process} = {
|
|
Console.print("grapho dashboard")
|
|
Console.print("")
|
|
|
|
let dashboardFile = graphoDir() + "/dashboard.html"
|
|
|
|
// Gather status
|
|
let deviceName = execQuiet("hostname")
|
|
let syncRunning = if graphoSyncthingRunning() then "Running" else "Stopped"
|
|
let syncClass = if graphoSyncthingRunning() then "ok" else "warn"
|
|
let folders = if graphoSyncthingRunning() then getSyncFolderCount() else "0"
|
|
let devices = if graphoSyncthingRunning() then getSyncDeviceCount() else "0"
|
|
|
|
let backupRepo = getBackupRepo()
|
|
let backupStatus = if String.length(backupRepo) > 0 then "Configured" else "Not configured"
|
|
let backupClass = if String.length(backupRepo) > 0 then "ok" else "none"
|
|
|
|
let serverDir = graphoDir() + "/server"
|
|
let serverMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
|
let serverStatus = if serverMounted |> isYes then "Mounted" else "Not mounted"
|
|
let serverClass = if serverMounted |> isYes then "ok" else "none"
|
|
|
|
// Generate HTML via shell script to avoid Lux string issues
|
|
let genScript = graphoDir() + "/gen-dashboard.sh"
|
|
let ignore = Process.exec("printf '%s' '#!/bin/sh' > " + genScript)
|
|
let ignore2 = Process.exec("chmod +x " + genScript)
|
|
|
|
// Write dashboard using printf to avoid issues
|
|
let ignore3 = Process.exec("printf '<!DOCTYPE html><html><head><title>grapho</title></head><body style=\"background:rgb(30,30,50);color:white;font-family:sans-serif;padding:20px\"><h1>grapho</h1>' > " + dashboardFile)
|
|
let ignore4 = Process.exec("printf '<div style=\"background:rgb(40,40,70);padding:15px;margin:10px 0;border-radius:8px\"><b>Device:</b> " + deviceName + "</div>' >> " + dashboardFile)
|
|
let ignore5 = Process.exec("printf '<div style=\"background:rgb(40,40,70);padding:15px;margin:10px 0;border-radius:8px\"><b>Sync:</b> <span style=\"color:" + (if graphoSyncthingRunning() then "lightgreen" else "orange") + "\">" + syncRunning + "</span> (" + folders + " folders, " + devices + " devices)</div>' >> " + dashboardFile)
|
|
let ignore6 = Process.exec("printf '<div style=\"background:rgb(40,40,70);padding:15px;margin:10px 0;border-radius:8px\"><b>Backup:</b> " + backupStatus + "</div>' >> " + dashboardFile)
|
|
let ignore7 = Process.exec("printf '<div style=\"background:rgb(40,40,70);padding:15px;margin:10px 0;border-radius:8px\"><b>Server:</b> " + serverStatus + "</div>' >> " + dashboardFile)
|
|
let ignore8 = Process.exec("printf '</body></html>' >> " + dashboardFile)
|
|
|
|
Console.print(statusIcon(StatusOk) + " Dashboard generated: " + dashboardFile)
|
|
Console.print("")
|
|
Console.print("Open in browser:")
|
|
Console.print(" xdg-open " + dashboardFile)
|
|
Console.print("")
|
|
Console.print("Or serve it:")
|
|
Console.print(" python -m http.server 8386 -d " + graphoDir())
|
|
}
|
|
|
|
// =============================================================================
|
|
// Export/Import commands
|
|
// =============================================================================
|
|
|
|
fn doExport(): Unit with {Console, Process} = {
|
|
Console.print("grapho export")
|
|
Console.print("")
|
|
|
|
let timestamp = execQuiet("date +%Y-%m-%d")
|
|
let exportFile = "grapho-export-" + timestamp + ".tar.zst"
|
|
let exportPath = execQuiet("pwd") + "/" + exportFile
|
|
|
|
Console.print("Exporting grapho data to " + exportFile)
|
|
Console.print("")
|
|
|
|
// Create encrypted archive
|
|
Console.print("Archiving...")
|
|
let tarResult = Process.exec("tar -C " + graphoDir() + " -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")
|
|
Console.print(statusIcon(StatusOk) + " Export created: " + exportPath)
|
|
Console.print(" Size: " + fileSize)
|
|
Console.print("")
|
|
Console.print("To restore on another machine:")
|
|
Console.print(" grapho import " + exportFile)
|
|
logEvent("export", "Exported to " + exportFile)
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " Export failed")
|
|
Console.print(" Make sure zstd is installed")
|
|
}
|
|
}
|
|
|
|
fn doImport(archivePath: String): Unit with {Console, Process} = {
|
|
Console.print("grapho import")
|
|
Console.print("")
|
|
|
|
if String.length(archivePath) == 0 then {
|
|
Console.print("Usage: grapho import <archive.tar.zst>")
|
|
Console.print("")
|
|
Console.print("Restore grapho data from an export archive.")
|
|
} else {
|
|
let fileExists = execQuiet("test -f " + archivePath + " && echo yes || echo no")
|
|
if fileExists |> isNo then {
|
|
Console.print(statusIcon(StatusErr) + " File not found: " + archivePath)
|
|
} else {
|
|
Console.print("Importing from " + archivePath)
|
|
Console.print("")
|
|
|
|
// Check if grapho dir already exists
|
|
let hasGrapho = execQuiet("test -d " + graphoDir() + " && echo yes || echo no")
|
|
if hasGrapho |> isYes then {
|
|
Console.print(statusIcon(StatusWarn) + " Existing grapho data found at " + graphoDir())
|
|
Console.print(" Backing up to " + graphoDir() + ".bak")
|
|
let ignore = Process.exec("mv " + graphoDir() + " " + graphoDir() + ".bak 2>&1")
|
|
} else ()
|
|
|
|
// Create directory and extract
|
|
Console.print("Extracting...")
|
|
let ignore = Process.exec("mkdir -p " + graphoDir())
|
|
let extractResult = Process.exec("zstd -d < " + archivePath + " | tar -C " + graphoDir() + " -xf - 2>&1")
|
|
|
|
let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
|
|
if configExists |> isYes then {
|
|
Console.print(statusIcon(StatusOk) + " Import complete")
|
|
Console.print("")
|
|
Console.print("Restored to: " + graphoDir())
|
|
Console.print("")
|
|
Console.print("Next steps:")
|
|
Console.print(" grapho status # Check system health")
|
|
Console.print(" grapho sync setup # Re-pair devices")
|
|
logEvent("import", "Imported from " + archivePath)
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " Import may have failed")
|
|
Console.print(" Check if archive is valid")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// History command
|
|
// =============================================================================
|
|
|
|
fn showHistory(): Unit with {Console, Process} = {
|
|
Console.print("grapho history")
|
|
Console.print("")
|
|
|
|
if hasCommand("sqlite3") then {
|
|
let dbExists = execQuiet("test -f " + stateDb() + " && echo yes || echo no")
|
|
if dbExists |> isYes then {
|
|
let events = getRecentEvents("20")
|
|
if String.length(events) > 0 then {
|
|
Console.print("Recent events:")
|
|
Console.print("")
|
|
Console.print(events)
|
|
} else {
|
|
Console.print("No events recorded yet.")
|
|
}
|
|
} else {
|
|
Console.print("State database not found.")
|
|
Console.print(" Run: grapho setup")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " sqlite3 not installed")
|
|
Console.print(" History tracking requires sqlite3")
|
|
}
|
|
Console.print("")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Help
|
|
// =============================================================================
|
|
|
|
fn showHelp(): Unit with {Console, Process} = {
|
|
Console.print("grapho - Personal Data Infrastructure")
|
|
Console.print("")
|
|
Console.print("Usage:")
|
|
Console.print(" grapho Health check (or welcome if new)")
|
|
Console.print(" grapho init <repo-url> Initialize from config repo")
|
|
Console.print(" grapho setup Interactive setup wizard")
|
|
Console.print("")
|
|
Console.print("Sync (Type 2 - Real-time bidirectional):")
|
|
Console.print(" grapho sync Trigger sync")
|
|
Console.print(" grapho sync status Show sync status")
|
|
Console.print(" grapho sync setup Configure Syncthing")
|
|
Console.print("")
|
|
Console.print("Backup (Type 3 - Periodic snapshots):")
|
|
Console.print(" grapho backup Run backup now")
|
|
Console.print(" grapho backup list List snapshots")
|
|
Console.print(" grapho backup init <repo> Initialize backup repository")
|
|
Console.print("")
|
|
Console.print("Server (Type 4 - Central storage):")
|
|
Console.print(" grapho server Show server status")
|
|
Console.print(" grapho server setup Configure server access")
|
|
Console.print(" grapho server mount Mount server data")
|
|
Console.print(" grapho server unmount Unmount server data")
|
|
Console.print(" grapho server ls List server contents")
|
|
Console.print("")
|
|
Console.print("Export/Import:")
|
|
Console.print(" grapho export Export all data to archive")
|
|
Console.print(" grapho import <file> Import from archive")
|
|
Console.print("")
|
|
Console.print("Status:")
|
|
Console.print(" grapho status Full status dashboard")
|
|
Console.print(" grapho doctor Diagnose issues")
|
|
Console.print(" grapho history Show recent events")
|
|
Console.print(" grapho dashboard Generate HTML status page")
|
|
Console.print(" grapho help Show this help")
|
|
Console.print("")
|
|
Console.print("Data directory: " + graphoDir())
|
|
Console.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(),
|
|
"init" => doInit(subcmd),
|
|
"setup" => doSetup(),
|
|
"sync" => {
|
|
match subcmd {
|
|
"status" => doSyncStatus(),
|
|
"setup" => doSyncSetup(),
|
|
"" => doSync(),
|
|
_ => doSyncStatus()
|
|
}
|
|
},
|
|
"backup" => {
|
|
match subcmd {
|
|
"init" => doBackupInit(arg3),
|
|
"list" => doBackupList(),
|
|
"" => doBackup(),
|
|
_ => doBackup()
|
|
}
|
|
},
|
|
"server" => {
|
|
match subcmd {
|
|
"setup" => doServerSetup(),
|
|
"mount" => doServerMount(),
|
|
"unmount" => doServerUnmount(),
|
|
"ls" => doServerLs(),
|
|
"" => doServerStatus(),
|
|
_ => doServerStatus()
|
|
}
|
|
},
|
|
"export" => doExport(),
|
|
"import" => doImport(subcmd),
|
|
"status" => showStatus(),
|
|
"doctor" => showDoctor(),
|
|
"history" => showHistory(),
|
|
"dashboard" => doDashboard(),
|
|
"help" => showHelp(),
|
|
"-h" => showHelp(),
|
|
"--help" => showHelp(),
|
|
_ => showHelp()
|
|
}
|
|
}
|
|
|
|
let result = run main() with {}
|