From d3d720b3bc5ff2d14495277f84d51516e3feff26 Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Mon, 16 Feb 2026 22:15:35 -0500 Subject: [PATCH] Modernize CLI UX with gh/cargo-style polish - Add --version/-V and per-subcommand --help - Replace logo spam with compact status dashboard - Add aligned step output (label done format) - Add confirmation prompts for destructive operations - Interactive first-run wizard when uninitialized - Consistent color language and status line formatting - Clean help with USAGE/COMMANDS/OPTIONS structure Co-Authored-By: Claude Opus 4.5 --- cli/grapho.lux | 1622 +++++++++++++++++++++++++++--------------------- 1 file changed, 898 insertions(+), 724 deletions(-) diff --git a/cli/grapho.lux b/cli/grapho.lux index cc66ce1..47d5f4e 100644 --- a/cli/grapho.lux +++ b/cli/grapho.lux @@ -1,52 +1,166 @@ -// grapho - Unified Personal Data Infrastructure +// grapho - Your personal data, everywhere // -// 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 +// A modern CLI for managing your digital life across all devices. // ============================================================================= // Types // ============================================================================= -type SyncStatus = - | StatusOk - | StatusWarn - | StatusErr - | StatusNone +type Status = + | Ok + | Warn + | Err + | None // ============================================================================= -// Status icons +// Color output helpers (using shell printf for ANSI codes) // ============================================================================= -fn statusIcon(s: SyncStatus): String = - match s { - StatusOk => "[ok]", - StatusWarn => "[!!]", - StatusErr => "[ERR]", - StatusNone => "[--]" - } +fn print(msg: String): Unit with {Process} = { + let ignore = Process.exec("printf '%s\\n' \"" + msg + "\" >&2") + () +} -fn statusEmoji(s: SyncStatus): String = - match s { - StatusOk => "✓", - StatusWarn => "⚠", - StatusErr => "✗", - StatusNone => "-" +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") + () +} // ============================================================================= // Shell helpers @@ -66,19 +180,16 @@ fn homeDir(): String with {Process} = 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" +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 detection +// Initialization // ============================================================================= fn isInitialized(): Bool with {Process} = { @@ -91,16 +202,13 @@ fn ensureDir(path: String): Unit with {Process} = { () } -fn stateDb(): String with {Process} = - graphoDir() + "/state.db" - // ============================================================================= -// SQLite state tracking +// 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 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") () } @@ -119,12 +227,113 @@ fn getRecentEvents(limit: String): String with {Process} = { } // ============================================================================= -// Directory structure +// Syncthing helpers // ============================================================================= -fn createDirectories(): Unit with {Process, Console} = { +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() - Console.print("Creating directory structure...") ensureDir(base) ensureDir(base + "/config-repo") ensureDir(base + "/syncthing/config") @@ -134,553 +343,474 @@ fn createDirectories(): Unit with {Process, Console} = { ensureDir(base + "/sync/dotfiles") ensureDir(base + "/restic/cache") ensureDir(base + "/server") - Console.print(statusIcon(StatusOk) + " Directories created") } -// ============================================================================= -// Welcome flow -// ============================================================================= +fn doSetup(): Unit with {Process} = { + print("") + printHeader("Setting up grapho") -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 + // Step 1: Directories + printStepPending("Creating directories") createDirectories() - Console.print("") + printStep("Creating directories", "done") - // 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" + // 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) - Console.print(statusIcon(StatusOk) + " Configuration created at " + graphoConfig()) - Console.print("") + printStep("Writing configuration", "done") - // Generate age key if missing + // 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 - Console.print(statusIcon(StatusOk) + " Age encryption key exists") + printStep("Setting up encryption", "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") + let ignore2 = Process.exec("age-keygen -o " + graphoDir() + "/age-key.txt 2>&1 || true") + printStep("Setting up encryption", "done") } - 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 + // Step 4: Syncthing + printStepPending("Initializing sync") 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)") + 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 { - Console.print(statusIcon(StatusOk) + " Syncthing already configured") + printStep("Initializing sync", "ready") } } else { - Console.print(statusIcon(StatusWarn) + " Syncthing not found") - Console.print(" Enable in NixOS: services.grapho.enable = true;") + printStepWarn("Initializing sync", "not installed") } - // 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;") - } + // State DB + if hasCommand("sqlite3") then { + initStateDb() + logEvent("setup", "grapho initialized") + } else () - 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("") + 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 {Console, Process} = { +fn doInit(repoUrl: String): Unit with {Process} = { + print("") 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") + 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 { - Console.print("Initializing from " + repoUrl) - Console.print("") + printHeader("Initializing from repository") - // Create base directory + printStepPending("Creating directories") createDirectories() + printStep("Creating directories", "done") - // Clone repo - Console.print("Cloning config repository...") + printStepPending("Cloning 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) + printStepErr("Cloning repository", "failed") + print("") + printDim(cloneResult) } else { - Console.print(statusIcon(StatusOk) + " Repository cloned") + printStep("Cloning repository", "done") - // 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 .#") + printStep("Found flake.nix", "yes") + print("") + printBold("Apply your config:") + printCmd("cd " + graphoDir() + "/config-repo") + printCmd("sudo nixos-rebuild switch --flake .") } else { - Console.print(statusIcon(StatusWarn) + " No flake.nix found") - Console.print(" This repo may not be a NixOS configuration") + printStepWarn("Found flake.nix", "no") } + logEvent("init", "Cloned " + repoUrl) } - Console.print("") + 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") - } +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 { - Console.print(statusIcon(StatusErr) + " Syncthing not installed") - Console.print(" Install via nix or NixOS config") + printStatusLine("sync", "Stopped", "") + print("") + printCmd("grapho sync start") } + print("") } -fn doSyncSetup(): Unit with {Console, Process} = { - Console.print("grapho sync setup") - Console.print("") - +fn doSyncSetup(): Unit with {Process} = { + print("") if hasCommand("syncthing") == false then { - Console.print(statusIcon(StatusErr) + " Syncthing not installed") - Console.print(" Add to your NixOS config: services.grapho.enable = true;") + printErr("Syncthing not installed") + printHint("Add to NixOS: services.grapho.enable = true;") + print("") } else { - // Initialize Syncthing config if needed + printHeader("Setting up sync") + let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no") if hasConfig |> isNo then { - Console.print("Initializing Syncthing for grapho...") + 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") - 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)") + printStep("Creating config", "done") } else { - Console.print(statusIcon(StatusOk) + " Syncthing already configured") + printStep("Creating config", "exists") } - // Start Syncthing if not running + // Start 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 + 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 { - Console.print(statusIcon(StatusOk) + " Syncthing already running") + printStep("Starting daemon", "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") + print("") + printBold("Your Device ID") + let ignore = Process.exec("printf '\\033[33m%s\\033[0m\\n' \"" + deviceId + "\" >&2") + print("") + printHint("Share this ID with other devices to pair.") + logEvent("sync", "Device ID displayed") } else { - Console.print(statusIcon(StatusWarn) + " Could not get device ID") - Console.print(" Syncthing may still be starting. Try: grapho sync status") + printStepWarn("Getting device ID", "retry in a moment") } + print("") } } -fn doSync(): Unit with {Console, Process} = { - Console.print("Syncing...") - +fn doSyncStart(): Unit with {Process} = { + print("") 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") + printSuccess("Syncthing is already running") + let ignore = Process.exec("printf ' \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2") } else { - Console.print(statusIcon(StatusWarn) + " Syncthing not running") - Console.print(" Start with: grapho sync setup") + 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") + } } - Console.print("") + 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 {Console, Process} = { - Console.print("grapho backup init") - Console.print("") - +fn doBackupInit(repoArg: String): Unit with {Process} = { + print("") if hasCommand("restic") == false then { - Console.print(statusIcon(StatusErr) + " Restic not installed") - Console.print(" Add to your NixOS config: services.grapho.enable = true;") + 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 { - 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.") + 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 { - 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) - } + 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 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") +fn doBackupList(): Unit with {Process} = { + print("") + let repo = getBackupRepo() + if String.length(repo) == 0 then { + printWarn("No backup configured") + printCmd("grapho backup init ") } 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) - } + 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 {Console, Process} = { - Console.print("Running backup...") - Console.print("") - - if hasCommand("restic") == false then { - Console.print(statusIcon(StatusErr) + " Restic not installed") +fn doBackup(): Unit with {Process} = { + print("") + let repo = getBackupRepo() + if String.length(repo) == 0 then { + printWarn("No backup configured") + printCmd("grapho backup init ") } 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("") + printStepPending("Creating snapshot") + let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + graphoDir() + "/sync 2>&1") - 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") + 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 { - Console.print("") - Console.print(statusIcon(StatusErr) + " Backup may have failed") - logEvent("backup", "Backup failed or incomplete") + printStep("Creating snapshot", "done") } + logEvent("backup", "Backup completed") + } else { + printStepErr("Creating snapshot", "failed") + print("") + printDim(backupResult) } } - Console.print("") + print("") } // ============================================================================= // Server commands // ============================================================================= -fn doServerStatus(): Unit with {Console, Process} = { - Console.print("grapho server status") - Console.print("") - +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 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") + 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 { - 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") + printStatusLine("server", "Not mounted", "") + print("") + printCmd("grapho server mount") } + print("") } -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...") - +fn doServerMount(): Unit with {Process} = { + print("") 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) + printSuccess("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\";") + 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 {Console, Process} = { - Console.print("Unmounting server...") - +fn doServerUnmount(): Unit with {Process} = { + print("") 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") + 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 - Console.print(statusIcon(StatusOk) + " Unmounted " + serverDir) - else { - Console.print(statusIcon(StatusErr) + " Failed to unmount") - Console.print(" Try: sudo umount " + serverDir) - } + printStep("Unmounting", "done") + else + printStepErr("Unmounting", "failed") } 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") + printHint("Server is not mounted") } + print("") } // ============================================================================= -// One-liner health check +// Export/Import // ============================================================================= -fn showHealthCheck(): Unit with {Console, Process} = { - // Check if initialized - if isInitialized() == false then { - showWelcome() +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 { - 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 + printStepErr("Creating archive", "failed") + } + print("") +} - let folders = if syncOk then getSyncFolderCount() else "0" - let devices = if syncOk then getSyncDeviceCount() else "0" +fn doImport(archivePath: String): Unit with {Process} = { + print("") - // 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") - } + 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 { - Console.print(statusEmoji(StatusOk) + " All systems healthy (" + folders + " folders, " + devices + " devices, backup active)") + // 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("") + } } } } @@ -689,348 +819,375 @@ fn showHealthCheck(): Unit with {Console, Process} = { // Status dashboard // ============================================================================= -fn showStatus(): Unit with {Console, Process} = { - Console.print("grapho status") - Console.print("") - +fn showStatus(): Unit with {Process} = { + print("") if isInitialized() == false then { - Console.print(statusIcon(StatusNone) + " grapho not initialized") - Console.print(" Run: grapho setup") - Console.print("") + 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 || echo local") - Console.print(statusIcon(StatusOk) + " config: " + repoRemote) + 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 { - Console.print(statusIcon(StatusNone) + " config: no repo linked") - Console.print(" Run: grapho init ") + printStatusLine("config", "Not linked", "") } // Sync if graphoSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() - Console.print(statusIcon(StatusOk) + " sync: " + folders + " folders, " + devices + " devices") + printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") } else { - Console.print(statusIcon(StatusWarn) + " sync: not running") - Console.print(" Run: grapho sync setup") + printStatusLine("sync", "Not running", "") } // 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") + let repo = getBackupRepo() + if String.length(repo) > 0 then { + let repoShort = execQuiet("echo '" + repo + "' | sed 's/.*\\///'") + printStatusLine("backup", "Configured", repoShort) } else { - Console.print(statusIcon(StatusWarn) + " backup: timer inactive") - Console.print(" Run: grapho backup init") + printStatusLine("backup", "Not configured", "") } // 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") + printStatusLine("server", "Mounted", serverDir) } else { - Console.print(statusIcon(StatusNone) + " server: not configured") + printStatusLine("server", "Not mounted", "") } } - Console.print("") + print("") } // ============================================================================= -// Doctor command +// Compact Status (default command when initialized) // ============================================================================= -fn showDoctor(): Unit with {Console, Process} = { - Console.print("grapho doctor") - Console.print("") +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" - // Check nix + // 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 nixVer = execQuiet("nix --version") - Console.print(statusIcon(StatusOk) + " nix: " + nixVer) + let ver = execQuiet("nix --version | cut -d' ' -f3") + printStep("nix", ver) } else { - Console.print(statusIcon(StatusErr) + " nix: not found") - Console.print(" Install: https://nixos.org/download") + printStepErr("nix", "not found") } - // Check git if hasCommand("git") then { - let gitVer = execQuiet("git --version | cut -d' ' -f3") - Console.print(statusIcon(StatusOk) + " git: " + gitVer) + let ver = execQuiet("git --version | cut -d' ' -f3") + printStep("git", ver) } else { - Console.print(statusIcon(StatusErr) + " git: not found") + printStepErr("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") - } + if graphoSyncthingRunning() then + printStep("syncthing", "running") + else + printStepWarn("syncthing", "installed, not running") } else { - Console.print(statusIcon(StatusErr) + " syncthing: not installed") + printStepErr("syncthing", "not found") } - // Check restic if hasCommand("restic") then { - Console.print(statusIcon(StatusOk) + " restic: installed") + printStep("restic", "installed") } else { - Console.print(statusIcon(StatusErr) + " restic: not installed") + printStepErr("restic", "not found") } - // 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") - } + if hasKey |> isYes then + printStep("age", "key exists") + else + printStepWarn("age", "no key") } else { - Console.print(statusIcon(StatusErr) + " age: not installed") + printStepErr("age", "not found") } - // 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("") + // 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 {Console, Process} = { +fn checkDir(dir: String): Unit with {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) + if exists |> isYes then { + printStep(dir + "/", "exists") } 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") - } - } + printStepWarn(dir + "/", "missing") } } // ============================================================================= -// History command +// History // ============================================================================= -fn showHistory(): Unit with {Console, Process} = { - Console.print("grapho history") - Console.print("") - - if hasCommand("sqlite3") then { +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 { - let events = getRecentEvents("20") + 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 { - Console.print("Recent events:") - Console.print("") - Console.print(events) + print(events) } else { - Console.print("No events recorded yet.") + printHint(" No events yet") } } else { - Console.print("State database not found.") - Console.print(" Run: grapho setup") + printHint("No history") } - } else { - Console.print(statusIcon(StatusErr) + " sqlite3 not installed") - Console.print(" History tracking requires sqlite3") } - Console.print("") + 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 {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("") +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 {Console, Process} = { +fn main(): Unit with {Process} = { let args = Process.args() let cmd = match List.get(args, 1) { Some(c) => c, @@ -1047,18 +1204,28 @@ fn main(): Unit with {Console, Process} = { 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(), @@ -1067,10 +1234,11 @@ fn main(): Unit with {Console, Process} = { }, "server" => { match subcmd { - "setup" => doServerSetup(), + "-h" => showServerHelp(), + "--help" => showServerHelp(), + "help" => showServerHelp(), "mount" => doServerMount(), "unmount" => doServerUnmount(), - "ls" => doServerLs(), "" => doServerStatus(), _ => doServerStatus() } @@ -1084,7 +1252,13 @@ fn main(): Unit with {Console, Process} = { "help" => showHelp(), "-h" => showHelp(), "--help" => showHelp(), - _ => showHelp() + _ => { + // Unknown command - suggest similar + printErr("Unknown command: " + cmd) + print("") + printHint("Run 'grapho --help' for usage.") + print("") + } } }