diff --git a/cli/grapho.lux b/cli/grapho.lux index 16b8d2e..cc66ce1 100644 --- a/cli/grapho.lux +++ b/cli/grapho.lux @@ -91,6 +91,33 @@ fn ensureDir(path: String): Unit with {Process} = { () } +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 // ============================================================================= @@ -168,22 +195,39 @@ fn doSetup(): Unit with {Console, Process} = { } Console.print("") - // Check for Syncthing + // 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(statusIcon(StatusOk) + " Syncthing is available") - Console.print(" Configure via: grapho sync setup") + 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/defaulttcp:\\/\\/0.0.0.0:22001/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(" Install via nix or enable in your NixOS config") + Console.print(" Enable in NixOS: services.grapho.enable = true;") } // Check for restic if hasCommand("restic") then { - Console.print(statusIcon(StatusOk) + " Restic is available") - Console.print(" Configure via: grapho backup init") + Console.print(statusIcon(StatusOk) + " Restic available for backups") } else { Console.print(statusIcon(StatusWarn) + " Restic not found") - Console.print(" Install via nix or enable in your NixOS config") + Console.print(" Enable in NixOS: services.grapho.enable = true;") } Console.print("") @@ -301,29 +345,46 @@ fn doSyncSetup(): Unit with {Console, Process} = { if hasCommand("syncthing") == false then { Console.print(statusIcon(StatusErr) + " Syncthing not installed") - Console.print(" Install syncthing first") + 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 2>&1") - Console.print(statusIcon(StatusOk) + " Syncthing initialized") + 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/defaulttcp:\\/\\/0.0.0.0:22001/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 = getSyncthingDeviceId() + 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 them.") + 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("Start Syncthing to get your device ID:") - Console.print(" syncthing serve --home=" + graphoDir() + "/syncthing/config --gui-address=127.0.0.1:8385") + Console.print(statusIcon(StatusWarn) + " Could not get device ID") + Console.print(" Syncthing may still be starting. Try: grapho sync status") } } } @@ -335,6 +396,7 @@ fn doSync(): Unit with {Console, Process} = { 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") @@ -346,28 +408,78 @@ fn doSync(): Unit with {Console, Process} = { // Backup commands // ============================================================================= -fn doBackupInit(): Unit with {Console, Process} = { +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(" Install restic first") + Console.print(" Add to your NixOS config: services.grapho.enable = true;") } else { - Console.print("Backup repository setup") - Console.print("") - Console.print("Configure your backup repository in: " + graphoConfig()) - Console.print("") - Console.print("Example configuration:") - Console.print(" [backup]") - Console.print(" repository = \"sftp:server:/backups/grapho\"") - Console.print(" schedule = \"hourly\"") - Console.print("") - Console.print("After configuring, initialize with:") - Console.print(" restic -r init") + if String.length(repoArg) == 0 then { + Console.print("Usage: grapho backup init ") + 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("") @@ -375,29 +487,48 @@ fn doBackupList(): Unit with {Console, Process} = { if hasCommand("restic") == false then { Console.print(statusIcon(StatusErr) + " Restic not installed") } else { - Console.print("Run: restic -r snapshots") - Console.print("") - Console.print("Check your repository location in: " + graphoConfig()) + let repo = getBackupRepo() + if String.length(repo) == 0 then { + Console.print(statusIcon(StatusWarn) + " No backup repository configured") + Console.print(" Run: grapho backup init ") + } 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") then { - // Check for systemd service first - let hasService = execQuiet("systemctl cat grapho-backup.service 2>/dev/null || systemctl cat restic-backup.service 2>/dev/null") - if String.length(hasService) > 0 then { - Console.print("-> Triggering backup service") - let ignore = Process.exec("systemctl start grapho-backup.service 2>/dev/null || systemctl start restic-backup.service 2>/dev/null || true") - Console.print(statusIcon(StatusOk) + " Backup triggered") - } else { - Console.print(statusIcon(StatusWarn) + " No systemd backup service configured") - Console.print(" Configure in your NixOS config or run restic manually") - Console.print(" restic -r backup " + graphoDir() + "/sync") - } - } else { + 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 ") + } 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("") } @@ -417,10 +548,105 @@ fn doServerStatus(): Unit with {Console, Process} = { 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(" Configure in: " + graphoConfig()) - Console.print(" Mount point: " + serverDir) + 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") } } @@ -597,6 +823,165 @@ fn checkDir(dir: String): Unit with {Console, Process} = { 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 'grapho

grapho

' > " + dashboardFile) + let ignore4 = Process.exec("printf '
Device: " + deviceName + "
' >> " + dashboardFile) + let ignore5 = Process.exec("printf '
Sync: " + syncRunning + " (" + folders + " folders, " + devices + " devices)
' >> " + dashboardFile) + let ignore6 = Process.exec("printf '
Backup: " + backupStatus + "
' >> " + dashboardFile) + let ignore7 = Process.exec("printf '
Server: " + serverStatus + "
' >> " + dashboardFile) + let ignore8 = Process.exec("printf '' >> " + 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 ") + 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 // ============================================================================= @@ -617,14 +1002,24 @@ fn showHelp(): Unit with {Console, Process} = { 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 Configure backup repository") + Console.print(" grapho backup init 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 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()) @@ -645,6 +1040,10 @@ fn main(): Unit with {Console, Process} = { Some(s) => s, None => "" } + let arg3 = match List.get(args, 3) { + Some(a) => a, + None => "" + } match cmd { "" => showHealthCheck(), @@ -660,15 +1059,28 @@ fn main(): Unit with {Console, Process} = { }, "backup" => { match subcmd { - "init" => doBackupInit(), + "init" => doBackupInit(arg3), "list" => doBackupList(), "" => doBackup(), _ => doBackup() } }, - "server" => doServerStatus(), + "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(), diff --git a/grapho b/grapho index d758a2b..f6c565b 100755 Binary files a/grapho and b/grapho differ