// 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) () } // ============================================================================= // 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("") // Check for Syncthing if hasCommand("syncthing") then { Console.print(statusIcon(StatusOk) + " Syncthing is available") Console.print(" Configure via: grapho sync setup") } else { Console.print(statusIcon(StatusWarn) + " Syncthing not found") Console.print(" Install via nix or enable in your NixOS config") } // Check for restic if hasCommand("restic") then { Console.print(statusIcon(StatusOk) + " Restic is available") Console.print(" Configure via: grapho backup init") } else { Console.print(statusIcon(StatusWarn) + " Restic not found") Console.print(" Install via nix or enable in your NixOS config") } 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(" Install syncthing first") } 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") } else { Console.print(statusIcon(StatusOk) + " Syncthing already configured") } // Get device ID Console.print("") let deviceId = getSyncthingDeviceId() 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.") } 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") } } } 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") } else { Console.print(statusIcon(StatusWarn) + " Syncthing not running") Console.print(" Start with: grapho sync setup") } Console.print("") } // ============================================================================= // Backup commands // ============================================================================= fn doBackupInit(): 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") } 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") } } 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 { Console.print("Run: restic -r snapshots") Console.print("") Console.print("Check your repository location in: " + graphoConfig()) } } fn doBackup(): Unit with {Console, Process} = { Console.print("Running backup...") 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 { Console.print(statusIcon(StatusErr) + " Restic not installed") } 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) } else { Console.print(statusIcon(StatusNone) + " Server not mounted") Console.print(" Configure in: " + graphoConfig()) Console.print(" Mount point: " + serverDir) } } // ============================================================================= // 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)") } // ============================================================================= // 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 Configure backup repository") Console.print("") Console.print("Server (Type 4 - Central storage):") Console.print(" grapho server Show server status") Console.print("") Console.print("Status:") Console.print(" grapho status Full status dashboard") Console.print(" grapho doctor Diagnose issues") 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 => "" } match cmd { "" => showHealthCheck(), "init" => doInit(subcmd), "setup" => doSetup(), "sync" => { match subcmd { "status" => doSyncStatus(), "setup" => doSyncSetup(), "" => doSync(), _ => doSyncStatus() } }, "backup" => { match subcmd { "init" => doBackupInit(), "list" => doBackupList(), "" => doBackup(), _ => doBackup() } }, "server" => doServerStatus(), "status" => showStatus(), "doctor" => showDoctor(), "help" => showHelp(), "-h" => showHelp(), "--help" => showHelp(), _ => showHelp() } } let result = run main() with {}