// grapho - Your personal data, everywhere // // A modern CLI for managing your digital life across all devices. // ============================================================================= // Types // ============================================================================= type Status = | Ok | Warn | Err | None // ============================================================================= // Color output helpers (using shell printf for ANSI codes) // ============================================================================= fn print(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '%s\\n' \"" + msg + "\" >&2") () } fn printColor(color: String, msg: String): Unit with {Process} = { let code = match color { "green" => "32", "yellow" => "33", "red" => "31", "blue" => "34", "magenta" => "35", "cyan" => "36", "bold" => "1", "dim" => "2", _ => "0" } let ignore = Process.exec("printf '\\033[" + code + "m%s\\033[0m\\n' \"" + msg + "\" >&2") () } fn printBold(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[1m%s\\033[0m\\n' \"" + msg + "\" >&2") () } fn printSuccess(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[32m✓\\033[0m %s\\n' \"" + msg + "\" >&2") () } fn printWarn(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[33m⚠\\033[0m %s\\n' \"" + msg + "\" >&2") () } fn printErr(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[31m✗\\033[0m %s\\n' \"" + msg + "\" >&2") () } fn printInfo(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[36m→\\033[0m %s\\n' \"" + msg + "\" >&2") () } fn printDim(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[2m%s\\033[0m\\n' \"" + msg + "\" >&2") () } fn printBox(title: String, content: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[36m┌─\\033[1m %s \\033[0m\\033[36m─┐\\033[0m\\n' \"" + title + "\" >&2") let ignore2 = Process.exec("printf '\\033[36m│\\033[0m %s\\n' \"" + content + "\" >&2") let ignore3 = Process.exec("printf '\\033[36m└──────────────────────────────────────────┘\\033[0m\\n' >&2") () } fn spinner(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[36m⠋\\033[0m %s\\r' \"" + msg + "\" >&2") () } fn clearLine(): Unit with {Process} = { let ignore = Process.exec("printf '\\033[2K\\r' >&2") () } // ============================================================================= // Modern output helpers (gh/cargo style) // ============================================================================= // Print a step with aligned status: " Creating directories done" fn printStep(label: String, status: String): Unit with {Process} = { // Pad label to 24 chars for alignment let ignore = Process.exec("printf ' %-24s \\033[32m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2") () } fn printStepPending(label: String): Unit with {Process} = { let ignore = Process.exec("printf ' %-24s \\033[2mrunning...\\033[0m\\r' \"" + label + "\" >&2") () } fn printStepWarn(label: String, status: String): Unit with {Process} = { let ignore = Process.exec("printf ' %-24s \\033[33m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2") () } fn printStepErr(label: String, status: String): Unit with {Process} = { let ignore = Process.exec("printf ' %-24s \\033[31m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2") () } // Print a status line: " sync Running 3 folders, 2 devices" fn printStatusLine(name: String, state: String, detail: String): Unit with {Process} = { let stateColor = match state { "Running" => "32", "Ready" => "32", "Mounted" => "32", "Configured" => "32", "Stopped" => "33", "Not running" => "33", "Not mounted" => "2", "Not configured" => "2", _ => "0" } let ignore = Process.exec("printf ' \\033[1m%-10s\\033[0m \\033[" + stateColor + "m%-16s\\033[0m \\033[2m%s\\033[0m\\n' \"" + name + "\" \"" + state + "\" \"" + detail + "\" >&2") () } // Ask for confirmation: returns true if user confirms fn askConfirm(question: String, defaultYes: Bool): Bool with {Process} = { let prompt = if defaultYes then " [Y/n] " else " [y/N] " let ignore = Process.exec("printf '%s%s' \"" + question + "\" \"" + prompt + "\" >&2") // Read user input and convert to lowercase let response = String.trim(Process.exec("head -n1")) let lower = execQuiet("echo '" + response + "' | tr A-Z a-z") if String.length(lower) == 0 then defaultYes else if lower == "y" || lower == "yes" then true else if lower == "n" || lower == "no" then false else defaultYes } // Print section header fn printHeader(title: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[1m%s\\033[0m\\n\\n' \"" + title + "\" >&2") () } // Print a hint/suggestion fn printHint(msg: String): Unit with {Process} = { let ignore = Process.exec("printf '\\033[2m%s\\033[0m\\n' \"" + msg + "\" >&2") () } // Print a command suggestion fn printCmd(cmd: String): Unit with {Process} = { let ignore = Process.exec("printf ' \\033[32m\\044\\033[0m %s\\n' \"" + cmd + "\" >&2") () } // Print a QR code for easy scanning (e.g., device IDs) fn printQR(data: String): Unit with {Process} = { if hasCommand("qrencode") then { // Generate small terminal QR code and redirect to stderr let ignore = Process.exec("qrencode -t ANSIUTF8 -m 1 '" + data + "' -o - >&2") () } else { // Fallback: just show the data printHint("(Install qrencode for QR codes)") } } // ============================================================================= // Shell helpers // ============================================================================= fn hasCommand(cmd: String): Bool with {Process} = { let result = Process.exec("command -v " + cmd + " 2>/dev/null || true") String.length(String.trim(result)) > 0 } fn execQuiet(cmd: String): String with {Process} = String.trim(Process.exec(cmd + " 2>/dev/null || true")) fn homeDir(): String with {Process} = match Process.env("HOME") { Some(h) => h, None => "/tmp" } fn isYes(s: String): Bool = String.contains(s, "yes") fn isNo(s: String): Bool = String.contains(s, "no") fn isActive(s: String): Bool = String.contains(s, "active") fn graphoDir(): String with {Process} = homeDir() + "/.config/grapho" fn graphoConfig(): String with {Process} = graphoDir() + "/grapho.toml" fn stateDb(): String with {Process} = graphoDir() + "/state.db" // ============================================================================= // Initialization // ============================================================================= 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) () } // ============================================================================= // State tracking // ============================================================================= fn initStateDb(): Unit with {Process} = { let db = stateDb() let schema = "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, timestamp TEXT, type TEXT, message TEXT);" let ignore = Process.exec("sqlite3 " + db + " \"" + schema + "\" 2>/dev/null || true") () } fn logEvent(eventType: String, message: String): Unit with {Process} = { let db = stateDb() let timestamp = execQuiet("date -Iseconds") let sql = "INSERT INTO events (timestamp, type, message) VALUES ('" + timestamp + "', '" + eventType + "', '" + message + "');" let ignore = Process.exec("sqlite3 " + db + " \"" + sql + "\" 2>/dev/null || true") () } fn getRecentEvents(limit: String): String with {Process} = { let db = stateDb() execQuiet("sqlite3 -separator ' | ' " + db + " \"SELECT timestamp, type, message FROM events ORDER BY id DESC LIMIT " + limit + ";\" 2>/dev/null") } // ============================================================================= // Syncthing helpers // ============================================================================= fn graphoSyncthingRunning(): Bool with {Process} = { let result = execQuiet("curl -s http://127.0.0.1:8385/rest/system/status 2>/dev/null | head -c 1") String.length(result) > 0 } fn getSyncFolderCount(): String with {Process} = execQuiet("syncthing cli --home=" + 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") // ============================================================================= // Backup helpers // ============================================================================= fn getBackupRepo(): String with {Process} = execQuiet("cat " + graphoDir() + "/restic/repository 2>/dev/null") fn getBackupPassword(): String with {Process} = graphoDir() + "/restic/password" // ============================================================================= // Logo and branding // ============================================================================= fn showLogo(): Unit with {Process} = { let ignore = Process.exec("printf '\\033[36m __ _ _ __ __ _ _ __ | |__ ___\\n' >&2") let ignore2 = Process.exec("printf ' / _ | __/ _ | _ \\\\ | _ \\\\ / _ \\\\\\n' >&2") let ignore3 = Process.exec("printf ' | (_| | | | (_| | |_) || | | || (_) |\\n' >&2") let ignore4 = Process.exec("printf ' \\\\__, |_| \\\\__,_| .__/ |_| |_| \\\\___/\\n' >&2") let ignore5 = Process.exec("printf ' __/ | | |\\n' >&2") let ignore6 = Process.exec("printf ' |___/ |_|\\n\\033[0m' >&2") print("") printDim(" Your personal data, everywhere.") print("") } fn showMiniLogo(): Unit with {Process} = { let ignore = Process.exec("printf '\\033[1;36mgrapho\\033[0m ' >&2") () } // ============================================================================= // Welcome flow (Interactive) // ============================================================================= fn showInteractiveWelcome(): Unit with {Process} = { print("") showLogo() print("") printBold("Welcome to grapho!") print("") printHint("grapho manages your data across all your devices:") print("") let ignore = Process.exec("printf ' \\033[1mconfig\\033[0m Machine configuration (git-managed)\\n' >&2") let ignore2 = Process.exec("printf ' \\033[1msync\\033[0m Notes & documents (real-time sync)\\n' >&2") let ignore3 = Process.exec("printf ' \\033[1mbackup\\033[0m Point-in-time snapshots\\n' >&2") let ignore4 = Process.exec("printf ' \\033[1mserver\\033[0m Large files on your server\\n' >&2") print("") // Interactive choice printBold("What would you like to do?") print("") let ignore5 = Process.exec("printf ' \\033[36m1\\033[0m Set up a new system\\n' >&2") let ignore6 = Process.exec("printf ' \\033[36m2\\033[0m Join an existing setup (import)\\n' >&2") let ignore7 = Process.exec("printf ' \\033[36m3\\033[0m Just explore (show help)\\n' >&2") print("") let ignore8 = Process.exec("printf 'Choice [1]: ' >&2") let choice = String.trim(Process.exec("head -n1")) match choice { "2" => { print("") printBold("Import from archive") print("") printHint("Provide a grapho export archive:") printCmd("grapho import grapho-export.tar.zst") print("") }, "3" => showHelp(), _ => { print("") doSetup() } } } // Non-interactive welcome for scripts fn showWelcome(): Unit with {Process} = { print("") showLogo() print("") printBold("Not initialized. Run:") printCmd("grapho setup") print("") } // ============================================================================= // Setup wizard // ============================================================================= fn createDirectories(): Unit with {Process} = { let base = graphoDir() ensureDir(base) ensureDir(base + "/config-repo") ensureDir(base + "/syncthing/config") ensureDir(base + "/syncthing/db") ensureDir(base + "/sync/notes") ensureDir(base + "/sync/documents") ensureDir(base + "/sync/dotfiles") ensureDir(base + "/restic/cache") ensureDir(base + "/server") } fn doSetup(): Unit with {Process} = { print("") printHeader("Setting up grapho") // Step 1: Directories printStepPending("Creating directories") createDirectories() printStep("Creating directories", "done") // Step 2: Config printStepPending("Writing configuration") let hostname = execQuiet("hostname") let configContent = "[grapho]\nversion = 1\ndevice_name = \"" + hostname + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\n\n[backup]\nschedule = \"hourly\"\n" let writeCmd = "cat > " + graphoConfig() + " << 'EOF'\n" + configContent + "EOF" let ignore = Process.exec(writeCmd) printStep("Writing configuration", "done") // Step 3: Age key printStepPending("Setting up encryption") let hasAgeKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no") if hasAgeKey |> isYes then printStep("Setting up encryption", "exists") else { let ignore2 = Process.exec("age-keygen -o " + graphoDir() + "/age-key.txt 2>&1 || true") printStep("Setting up encryption", "done") } // Step 4: Syncthing printStepPending("Initializing sync") if hasCommand("syncthing") then { let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no") if hasConfig |> isNo then { let ignore3 = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1") printStep("Initializing sync", "done") } else { printStep("Initializing sync", "ready") } } else { printStepWarn("Initializing sync", "not installed") } // State DB if hasCommand("sqlite3") then { initStateDb() logEvent("setup", "grapho initialized") } else () print("") printSuccess("Setup complete!") print("") printHint("Your data lives in: " + graphoDir()) print("") printBold("Next steps:") printCmd("grapho sync setup # Pair with other devices") printCmd("grapho backup init # Configure backups") print("") } // ============================================================================= // Init from repo // ============================================================================= fn doInit(repoUrl: String): Unit with {Process} = { print("") if String.length(repoUrl) == 0 then { printHeader("Initialize from repository") printHint("Clone a config repository to set up this machine:") print("") printCmd("grapho init https://github.com/you/grapho-config") print("") } else { printHeader("Initializing from repository") printStepPending("Creating directories") createDirectories() printStep("Creating directories", "done") printStepPending("Cloning repository") let cloneResult = Process.exec("git clone " + repoUrl + " " + graphoDir() + "/config-repo 2>&1") if String.contains(cloneResult, "fatal") then { printStepErr("Cloning repository", "failed") print("") printDim(cloneResult) } else { printStep("Cloning repository", "done") let hasFlake = execQuiet("test -f " + graphoDir() + "/config-repo/flake.nix && echo yes || echo no") if hasFlake |> isYes then { printStep("Found flake.nix", "yes") print("") printBold("Apply your config:") printCmd("cd " + graphoDir() + "/config-repo") printCmd("sudo nixos-rebuild switch --flake .") } else { printStepWarn("Found flake.nix", "no") } logEvent("init", "Cloned " + repoUrl) } print("") } } // ============================================================================= // Sync commands // ============================================================================= fn doSyncStatus(): Unit with {Process} = { print("") if hasCommand("syncthing") == false then { printErr("Syncthing not installed") printHint("Install syncthing and run 'grapho sync setup'") } else if graphoSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") print("") let ignore = Process.exec("printf ' Web UI: \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2") } else { printStatusLine("sync", "Stopped", "") print("") printCmd("grapho sync start") } print("") } fn doSyncSetup(): Unit with {Process} = { print("") if hasCommand("syncthing") == false then { printErr("Syncthing not installed") printHint("Add to NixOS: services.grapho.enable = true;") print("") } else { printHeader("Setting up sync") let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no") if hasConfig |> isNo then { printStepPending("Creating config") let ignore = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1") printStep("Creating config", "done") } else { printStep("Creating config", "exists") } // Start if not running if graphoSyncthingRunning() == false then { printStepPending("Starting daemon") let ignore = Process.exec("syncthing serve --home=" + graphoDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &") let ignore2 = Process.exec("sleep 2") printStep("Starting daemon", "done") } else { printStep("Starting daemon", "running") } let deviceId = execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4") if String.length(deviceId) > 10 then { print("") printBold("Your Device ID") let ignore = Process.exec("printf '\\033[33m%s\\033[0m\\n' \"" + deviceId + "\" >&2") print("") printQR(deviceId) print("") printHint("Scan the QR code or share this ID to pair devices.") logEvent("sync", "Device ID displayed") } else { printStepWarn("Getting device ID", "retry in a moment") } print("") } } fn doSyncStart(): Unit with {Process} = { print("") if graphoSyncthingRunning() then { printSuccess("Syncthing is already running") let ignore = Process.exec("printf ' \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2") } else { printStepPending("Starting Syncthing") let ignore = Process.exec("syncthing serve --home=" + graphoDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &") let ignore2 = Process.exec("sleep 2") if graphoSyncthingRunning() then { printStep("Starting Syncthing", "done") print("") let ignore3 = Process.exec("printf ' Web UI: \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2") } else { printStepErr("Starting Syncthing", "failed") } } print("") } fn doSync(): Unit with {Process} = { print("") if graphoSyncthingRunning() then { let ignore = Process.exec("syncthing cli --home=" + graphoDir() + "/syncthing/config scan 2>/dev/null || true") printSuccess("Sync triggered") logEvent("sync", "Manual sync") } else { printWarn("Syncthing not running") printCmd("grapho sync start") } print("") } // ============================================================================= // Backup commands // ============================================================================= fn doBackupInit(repoArg: String): Unit with {Process} = { print("") if hasCommand("restic") == false then { printErr("Restic not installed") print("") } else if String.length(repoArg) == 0 then { printHeader("Initialize backup repository") printHint("Provide a repository location:") print("") printCmd("grapho backup init /path/to/backup") printCmd("grapho backup init sftp:server:/backups") printCmd("grapho backup init b2:bucket:path") print("") } else { printHeader("Initializing backup") printHint("Repository: " + repoArg) print("") let passwordFile = graphoDir() + "/restic/password" let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no") if hasPassword |> isNo then { printStepPending("Generating password") let ignore = Process.exec("head -c 32 /dev/urandom | base64 > " + passwordFile + " && chmod 600 " + passwordFile) printStep("Generating password", "done") } else { printStep("Generating password", "exists") } printStepPending("Creating repository") let initResult = Process.exec("restic -r " + repoArg + " --password-file " + passwordFile + " init 2>&1") if String.contains(initResult, "created restic repository") then { printStep("Creating repository", "done") let ignore = Process.exec("echo '" + repoArg + "' > " + graphoDir() + "/restic/repository") printStep("Saving config", "done") print("") printSuccess("Backup initialized!") printCmd("grapho backup # Create your first snapshot") logEvent("backup", "Initialized " + repoArg) } else if String.contains(initResult, "already initialized") then { printStep("Creating repository", "exists") let ignore = Process.exec("echo '" + repoArg + "' > " + graphoDir() + "/restic/repository") } else { printStepErr("Creating repository", "failed") print("") printDim(initResult) } print("") } } fn doBackupList(): Unit with {Process} = { print("") let repo = getBackupRepo() if String.length(repo) == 0 then { printWarn("No backup configured") printCmd("grapho backup init ") } else { printHeader("Backup snapshots") printHint("Repository: " + repo) print("") // Format snapshots nicely let ignore = Process.exec("printf ' \\033[2m%-10s %-20s %s\\033[0m\\n' 'ID' 'DATE' 'PATHS' >&2") let ignore2 = Process.exec("printf ' \\033[2m%s\\033[0m\\n' '─────────────────────────────────────────────────' >&2") let snapshots = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots --json 2>&1 | jq -r '.[] | \" \" + .short_id + \" \" + .time[:19] + \" \" + (.paths | join(\", \"))' 2>/dev/null || echo ' No snapshots'") print(snapshots) } print("") } fn doBackup(): Unit with {Process} = { print("") let repo = getBackupRepo() if String.length(repo) == 0 then { printWarn("No backup configured") printCmd("grapho backup init ") } else { printStepPending("Creating snapshot") let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + graphoDir() + "/sync 2>&1") if String.contains(backupResult, "snapshot") then { let snapshotId = execQuiet("echo '" + backupResult + "' | grep -o 'snapshot [a-f0-9]*' | cut -d' ' -f2") if String.length(snapshotId) > 0 then { printStep("Creating snapshot", snapshotId) } else { printStep("Creating snapshot", "done") } logEvent("backup", "Backup completed") } else { printStepErr("Creating snapshot", "failed") print("") printDim(backupResult) } } print("") } // ============================================================================= // Server commands // ============================================================================= fn doServerStatus(): Unit with {Process} = { print("") let serverDir = graphoDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { let dfOut = execQuiet("df -h " + serverDir + " 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f3,2 | sed 's/ / \\/ /'") printStatusLine("server", "Mounted", dfOut) printHint(" " + serverDir) } else { printStatusLine("server", "Not mounted", "") print("") printCmd("grapho server mount") } print("") } fn doServerMount(): Unit with {Process} = { print("") let serverDir = graphoDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { printSuccess("Already mounted at " + serverDir) } else { printHeader("Mount server") printHint("Mount a remote filesystem to access server storage:") print("") let ignore = Process.exec("printf ' \\033[2mSSHFS:\\033[0m sshfs user@host:/path %s\\n' \"" + serverDir + "\" >&2") let ignore2 = Process.exec("printf ' \\033[2mNFS:\\033[0m Configure in your NixOS module\\n' >&2") } print("") } fn doServerUnmount(): Unit with {Process} = { print("") let serverDir = graphoDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { printStepPending("Unmounting") let ignore = Process.exec("fusermount -u " + serverDir + " 2>&1 || umount " + serverDir + " 2>&1 || true") let stillMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if stillMounted |> isNo then printStep("Unmounting", "done") else printStepErr("Unmounting", "failed") } else { printHint("Server is not mounted") } print("") } // ============================================================================= // Export/Import // ============================================================================= fn doExport(): Unit with {Process} = { print("") let timestamp = execQuiet("date +%Y-%m-%d") let exportFile = "grapho-export-" + timestamp + ".tar.zst" let exportPath = execQuiet("pwd") + "/" + exportFile printStepPending("Creating archive") let ignore = 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") printStep("Creating archive", fileSize) print("") printSuccess("Export created: " + exportFile) print("") printHint("Restore with:") printCmd("grapho import " + exportFile) logEvent("export", "Created " + exportFile) } else { printStepErr("Creating archive", "failed") } print("") } fn doImport(archivePath: String): Unit with {Process} = { print("") if String.length(archivePath) == 0 then { printHeader("Import grapho data") printHint("Restore from a grapho export archive:") print("") printCmd("grapho import grapho-export.tar.zst") print("") } else { let fileExists = execQuiet("test -f " + archivePath + " && echo yes || echo no") if fileExists |> isNo then { printErr("File not found: " + archivePath) print("") } else { // Check if there's existing data let hasGrapho = execQuiet("test -d " + graphoDir() + " && echo yes || echo no") if hasGrapho |> isYes then { print("") printWarn("This will replace your existing grapho data.") printHint("Existing data will be moved to ~/.config/grapho.bak") print("") let confirmed = askConfirm("Continue?", false) if confirmed == false then { print("") printHint("Import cancelled.") print("") } else { print("") printHeader("Importing") printStepPending("Backing up existing") let ignore = Process.exec("mv " + graphoDir() + " " + graphoDir() + ".bak 2>&1") printStep("Backing up existing", "done") printStepPending("Extracting archive") let ignore2 = Process.exec("mkdir -p " + graphoDir()) let ignore3 = Process.exec("zstd -d < " + archivePath + " | tar -C " + graphoDir() + " -xf - 2>&1") printStep("Extracting archive", "done") printStepPending("Verifying config") let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no") if configExists |> isYes then { printStep("Verifying config", "done") print("") printSuccess("Import complete.") logEvent("import", "Imported from " + archivePath) } else { printStepErr("Verifying config", "failed") print("") printErr("Import failed - config not found in archive") } print("") } } else { // No existing data, proceed without confirmation printHeader("Importing") printStepPending("Extracting archive") let ignore = Process.exec("mkdir -p " + graphoDir()) let ignore2 = Process.exec("zstd -d < " + archivePath + " | tar -C " + graphoDir() + " -xf - 2>&1") printStep("Extracting archive", "done") printStepPending("Verifying config") let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no") if configExists |> isYes then { printStep("Verifying config", "done") print("") printSuccess("Import complete.") logEvent("import", "Imported from " + archivePath) } else { printStepErr("Verifying config", "failed") print("") printErr("Import failed - config not found in archive") } print("") } } } } // ============================================================================= // Status dashboard // ============================================================================= fn showStatus(): Unit with {Process} = { print("") if isInitialized() == false then { printWarn("Not initialized") printCmd("grapho setup") } else { printHeader("Status") // 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 | sed 's/.*\\///' | sed 's/.git//'") printStatusLine("config", "Linked", repoRemote) } else { printStatusLine("config", "Not linked", "") } // Sync if graphoSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") } else { printStatusLine("sync", "Not running", "") } // Backup let repo = getBackupRepo() if String.length(repo) > 0 then { let repoShort = execQuiet("echo '" + repo + "' | sed 's/.*\\///'") printStatusLine("backup", "Configured", repoShort) } else { printStatusLine("backup", "Not configured", "") } // Server let serverDir = graphoDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { printStatusLine("server", "Mounted", serverDir) } else { printStatusLine("server", "Not mounted", "") } } print("") } // ============================================================================= // Compact Status (default command when initialized) // ============================================================================= fn showCompactStatus(): Unit with {Process} = { let syncOk = graphoSyncthingRunning() let hasBackup = String.length(getBackupRepo()) > 0 let folders = if syncOk then getSyncFolderCount() else "0" let devices = if syncOk then getSyncDeviceCount() else "0" // Header line let ignore = Process.exec("printf '\\033[1;36mgrapho\\033[0m ' >&2") if syncOk && hasBackup then { let ignore2 = Process.exec("printf '\\033[32mAll systems operational\\033[0m\\n' >&2") } else { let ignore2 = Process.exec("printf '\\033[33mPartially configured\\033[0m\\n' >&2") } print("") // Sync status if syncOk then { printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") } else { printStatusLine("sync", "Not running", "Run: grapho sync start") } // Backup status if hasBackup then { let snapCount = execQuiet("restic -r " + getBackupRepo() + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r 'length' 2>/dev/null || echo 0") let lastBackup = execQuiet("restic -r " + getBackupRepo() + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r '.[-1].time[:10]' 2>/dev/null || echo 'unknown'") printStatusLine("backup", "Ready", snapCount + " snapshots, last " + lastBackup) } else { printStatusLine("backup", "Not configured", "Run: grapho backup init") } // Server status let serverDir = graphoDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { printStatusLine("server", "Mounted", serverDir) } else { printStatusLine("server", "Not mounted", "") } print("") } // ============================================================================= // Health check (default command) // ============================================================================= fn showHealthCheck(): Unit with {Process} = { // Check if this is an interactive terminal let isTty = execQuiet("test -t 0 && echo yes || echo no") if isInitialized() == false then { // Interactive wizard for first run if isTty |> isYes then showInteractiveWelcome() else showWelcome() } else { showCompactStatus() } } // ============================================================================= // Doctor // ============================================================================= fn showDoctor(): Unit with {Process} = { print("") printHeader("System check") // Check tools let ignore = Process.exec("printf '\\033[2mDependencies:\\033[0m\\n' >&2") if hasCommand("nix") then { let ver = execQuiet("nix --version | cut -d' ' -f3") printStep("nix", ver) } else { printStepErr("nix", "not found") } if hasCommand("git") then { let ver = execQuiet("git --version | cut -d' ' -f3") printStep("git", ver) } else { printStepErr("git", "not found") } if hasCommand("syncthing") then { if graphoSyncthingRunning() then printStep("syncthing", "running") else printStepWarn("syncthing", "installed, not running") } else { printStepErr("syncthing", "not found") } if hasCommand("restic") then { printStep("restic", "installed") } else { printStepErr("restic", "not found") } if hasCommand("age") then { let hasKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no") if hasKey |> isYes then printStep("age", "key exists") else printStepWarn("age", "no key") } else { printStepErr("age", "not found") } if hasCommand("qrencode") then { printStep("qrencode", "installed") } else { printStepWarn("qrencode", "not found (QR codes disabled)") } // Directory check print("") let ignore2 = Process.exec("printf '\\033[2mDirectories:\\033[0m\\n' >&2") checkDir("config-repo") checkDir("syncthing") checkDir("sync") checkDir("restic") checkDir("server") print("") } fn checkDir(dir: String): Unit with {Process} = { let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no") if exists |> isYes then { printStep(dir + "/", "exists") } else { printStepWarn(dir + "/", "missing") } } // ============================================================================= // History // ============================================================================= fn showHistory(): Unit with {Process} = { print("") if hasCommand("sqlite3") == false then { printErr("sqlite3 not installed") } else { let dbExists = execQuiet("test -f " + stateDb() + " && echo yes || echo no") if dbExists |> isYes then { printHeader("Recent events") let ignore = Process.exec("printf ' \\033[2m%-20s %-10s %s\\033[0m\\n' 'TIME' 'TYPE' 'DETAILS' >&2") let ignore2 = Process.exec("printf ' \\033[2m%s\\033[0m\\n' '──────────────────────────────────────────────────' >&2") // Format events nicely let events = execQuiet("sqlite3 -separator ' ' " + stateDb() + " \"SELECT substr(timestamp,1,16), type, message FROM events ORDER BY id DESC LIMIT 15;\" 2>/dev/null | sed 's/^/ /'") if String.length(events) > 0 then { print(events) } else { printHint(" No events yet") } } else { printHint("No history") } } print("") } // ============================================================================= // Dashboard // ============================================================================= fn doDashboard(): Unit with {Process} = { // Show terminal dashboard instead of HTML print("") printHeader("Dashboard") let deviceName = execQuiet("hostname") printHint("Device: " + deviceName) print("") // Sync if graphoSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") } else { printStatusLine("sync", "Stopped", "") } // Backup let repo = getBackupRepo() if String.length(repo) > 0 then { let snapCount = execQuiet("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r 'length' 2>/dev/null || echo 0") printStatusLine("backup", "Ready", snapCount + " snapshots") } else { printStatusLine("backup", "Not configured", "") } // Server let serverDir = graphoDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { let dfOut = execQuiet("df -h " + serverDir + " 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f3,2 | sed 's/ / \\/ /'") printStatusLine("server", "Mounted", dfOut) } else { printStatusLine("server", "Not mounted", "") } // Config let hasRepo = execQuiet("test -d " + 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 | sed 's/.*\\///' | sed 's/.git//'") printStatusLine("config", "Linked", repoRemote) } else { printStatusLine("config", "Not linked", "") } print("") } // ============================================================================= // Version // ============================================================================= fn showVersion(): Unit with {Process} = { let ignore = Process.exec("printf 'grapho 0.2.0\\n' >&2") () } // ============================================================================= // Help // ============================================================================= fn showHelp(): Unit with {Process} = { print("") let ignore = Process.exec("printf '\\033[1mgrapho\\033[0m - Your personal data, everywhere\\n' >&2") print("") let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2") let ignore3 = Process.exec("printf ' grapho [COMMAND]\\n' >&2") print("") let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2") print("") let ignore5 = Process.exec("printf ' \\033[36msetup\\033[0m Initialize grapho on this machine\\n' >&2") let ignore6 = Process.exec("printf ' \\033[36minit\\033[0m Clone config from git repository\\n' >&2") print("") let ignore7 = Process.exec("printf ' \\033[36msync\\033[0m Trigger a rescan\\n' >&2") let ignore8 = Process.exec("printf ' \\033[36msync status\\033[0m Show sync status\\n' >&2") let ignore9 = Process.exec("printf ' \\033[36msync setup\\033[0m Initialize and show device ID\\n' >&2") let ignore10 = Process.exec("printf ' \\033[36msync start\\033[0m Start Syncthing daemon\\n' >&2") print("") let ignore11 = Process.exec("printf ' \\033[36mbackup\\033[0m Create a backup snapshot\\n' >&2") let ignore12 = Process.exec("printf ' \\033[36mbackup list\\033[0m List snapshots\\n' >&2") let ignore13 = Process.exec("printf ' \\033[36mbackup init\\033[0m Initialize backup repository\\n' >&2") print("") let ignore14 = Process.exec("printf ' \\033[36mserver\\033[0m Show server mount status\\n' >&2") let ignore15 = Process.exec("printf ' \\033[36mserver mount\\033[0m Mount remote server\\n' >&2") let ignore16 = Process.exec("printf ' \\033[36mserver unmount\\033[0m Unmount server\\n' >&2") print("") let ignore17 = Process.exec("printf ' \\033[36mstatus\\033[0m Show detailed status\\n' >&2") let ignore18 = Process.exec("printf ' \\033[36mdoctor\\033[0m Check system dependencies\\n' >&2") let ignore19 = Process.exec("printf ' \\033[36mhistory\\033[0m Show recent events\\n' >&2") let ignore20 = Process.exec("printf ' \\033[36mexport\\033[0m Create portable archive\\n' >&2") let ignore21 = Process.exec("printf ' \\033[36mimport\\033[0m Restore from archive\\n' >&2") print("") let ignore22 = Process.exec("printf '\\033[1mOPTIONS\\033[0m\\n' >&2") let ignore23 = Process.exec("printf ' -h, --help Show this help\\n' >&2") let ignore24 = Process.exec("printf ' -V, --version Show version\\n' >&2") print("") printHint("Data directory: " + graphoDir()) print("") } // Subcommand-specific help fn showSyncHelp(): Unit with {Process} = { print("") let ignore = Process.exec("printf '\\033[1mgrapho sync\\033[0m - Real-time file synchronization\\n' >&2") print("") let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2") let ignore3 = Process.exec("printf ' grapho sync [COMMAND]\\n' >&2") print("") let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2") let ignore5 = Process.exec("printf ' (default) Trigger a rescan of all folders\\n' >&2") let ignore6 = Process.exec("printf ' status Show Syncthing status\\n' >&2") let ignore7 = Process.exec("printf ' setup Initialize and display device ID for pairing\\n' >&2") let ignore8 = Process.exec("printf ' start Start Syncthing daemon\\n' >&2") print("") } fn showBackupHelp(): Unit with {Process} = { print("") let ignore = Process.exec("printf '\\033[1mgrapho backup\\033[0m - Point-in-time snapshots\\n' >&2") print("") let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2") let ignore3 = Process.exec("printf ' grapho backup [COMMAND]\\n' >&2") print("") let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2") let ignore5 = Process.exec("printf ' (default) Create a new backup snapshot\\n' >&2") let ignore6 = Process.exec("printf ' list List all snapshots\\n' >&2") let ignore7 = Process.exec("printf ' init Initialize backup repository\\n' >&2") print("") let ignore8 = Process.exec("printf '\\033[1mEXAMPLES\\033[0m\\n' >&2") let ignore9 = Process.exec("printf ' grapho backup init /mnt/backup\\n' >&2") let ignore10 = Process.exec("printf ' grapho backup init sftp:user@server:/backups\\n' >&2") let ignore11 = Process.exec("printf ' grapho backup init b2:bucket-name:path\\n' >&2") print("") } fn showServerHelp(): Unit with {Process} = { print("") let ignore = Process.exec("printf '\\033[1mgrapho server\\033[0m - Remote server storage\\n' >&2") print("") let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2") let ignore3 = Process.exec("printf ' grapho server [COMMAND]\\n' >&2") print("") let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2") let ignore5 = Process.exec("printf ' (default) Show mount status\\n' >&2") let ignore6 = Process.exec("printf ' mount Mount remote server\\n' >&2") let ignore7 = Process.exec("printf ' unmount Unmount server\\n' >&2") print("") } // ============================================================================= // Main // ============================================================================= fn main(): Unit with {Process} = { let args = Process.args() let cmd = match List.get(args, 1) { Some(c) => c, None => "" } let subcmd = match List.get(args, 2) { Some(s) => s, None => "" } let arg3 = match List.get(args, 3) { Some(a) => a, None => "" } match cmd { "" => showHealthCheck(), "-V" => showVersion(), "--version" => showVersion(), "version" => showVersion(), "init" => doInit(subcmd), "setup" => doSetup(), "sync" => { match subcmd { "-h" => showSyncHelp(), "--help" => showSyncHelp(), "help" => showSyncHelp(), "status" => doSyncStatus(), "setup" => doSyncSetup(), "start" => doSyncStart(), "" => doSync(), _ => doSyncStatus() } }, "backup" => { match subcmd { "-h" => showBackupHelp(), "--help" => showBackupHelp(), "help" => showBackupHelp(), "init" => doBackupInit(arg3), "list" => doBackupList(), "" => doBackup(), _ => doBackup() } }, "server" => { match subcmd { "-h" => showServerHelp(), "--help" => showServerHelp(), "help" => showServerHelp(), "mount" => doServerMount(), "unmount" => doServerUnmount(), "" => doServerStatus(), _ => doServerStatus() } }, "export" => doExport(), "import" => doImport(subcmd), "status" => showStatus(), "doctor" => showDoctor(), "history" => showHistory(), "dashboard" => doDashboard(), "help" => showHelp(), "-h" => showHelp(), "--help" => showHelp(), _ => { // Unknown command - suggest similar printErr("Unknown command: " + cmd) print("") printHint("Run 'grapho --help' for usage.") print("") } } } let result = run main() with {}