// 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 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 # 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/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(" 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 ") 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 .#") } 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/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 = 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 ") 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 ") } 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 ") } 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 ") } // 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 '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 // ============================================================================= 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 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 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()) 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 {}