diff --git a/cli/grapho.lux b/cli/grapho.lux index 16d4428..16b8d2e 100644 --- a/cli/grapho.lux +++ b/cli/grapho.lux @@ -1,14 +1,22 @@ -// grapho - Unified CLI for the Ultimate Notetaking, Sync & Backup System +// grapho - Unified Personal Data Infrastructure +// +// Four data types: +// 1. Config - Declarative machine setup (NixOS + Git) +// 2. Sync - Real-time bidirectional sync (Syncthing) +// 3. Backup - Periodic snapshots (Restic) +// 4. Server - Large files on central server // // Usage: -// grapho One-line health check (default) -// grapho status Full status dashboard -// grapho status -v Verbose status with all details -// grapho doctor Diagnose issues and suggest fixes -// grapho sync Sync all (nb + syncthing) -// grapho backup Run backup now -// grapho help Show help -// grapho --json Machine-readable JSON output +// grapho Health check (or welcome if uninitialized) +// grapho init Initialize from config repo +// grapho setup Interactive setup wizard +// grapho sync Sync all folders +// grapho sync status Show sync status +// grapho backup Run backup now +// grapho backup list List snapshots +// grapho status Full status dashboard +// grapho doctor Diagnose issues +// grapho help Show help // ============================================================================= // Types @@ -58,70 +66,396 @@ 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" + // ============================================================================= -// Syncthing helpers +// Initialization detection // ============================================================================= -fn syncthingRunning(): Bool with {Process} = { - let result = Process.exec("syncthing cli show system 2>/dev/null | jq -r '.myID' 2>/dev/null || true") - String.length(String.trim(result)) > 10 +fn isInitialized(): Bool with {Process} = { + let result = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no") + result |> isYes } -fn getSyncthingUptime(): String with {Process} = - Process.exec("syncthing cli show system 2>/dev/null | jq -r '.uptime // 0' 2>/dev/null || echo 0") - -fn getSyncthingFolderCount(): String with {Process} = - String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0")) - -fn getSyncthingDeviceCount(): String with {Process} = - String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0")) +fn ensureDir(path: String): Unit with {Process} = { + let ignore = Process.exec("mkdir -p " + path) + () +} // ============================================================================= -// One-liner health check (new default) +// Directory structure +// ============================================================================= + +fn createDirectories(): Unit with {Process, Console} = { + let base = graphoDir() + Console.print("Creating directory structure...") + ensureDir(base) + ensureDir(base + "/config-repo") + ensureDir(base + "/syncthing/config") + ensureDir(base + "/syncthing/db") + ensureDir(base + "/sync/notes") + ensureDir(base + "/sync/documents") + ensureDir(base + "/sync/dotfiles") + ensureDir(base + "/restic/cache") + ensureDir(base + "/server") + Console.print(statusIcon(StatusOk) + " Directories created") +} + +// ============================================================================= +// Welcome flow +// ============================================================================= + +fn showWelcome(): Unit with {Console, Process} = { + Console.print("") + Console.print(" Welcome to grapho") + Console.print("") + Console.print(" grapho manages your data across all your devices:") + Console.print("") + Console.print(" * Config - Your machine setup, restored anywhere") + Console.print(" * Sync - Notes & docs synced across devices") + Console.print(" * Backup - Snapshots you can restore anytime") + Console.print(" * Server - Large files on your server") + Console.print("") + Console.print(" To get started, run:") + Console.print("") + Console.print(" grapho setup # Interactive setup wizard") + Console.print(" grapho init # Initialize from existing config repo") + Console.print("") + Console.print(" For more info: grapho help") + Console.print("") +} + +// ============================================================================= +// Setup wizard +// ============================================================================= + +fn doSetup(): Unit with {Console, Process} = { + Console.print("") + Console.print("grapho setup wizard") + Console.print("====================") + Console.print("") + + // Create directories + createDirectories() + Console.print("") + + // Create initial config + Console.print("Creating initial configuration...") + let configContent = "[grapho]\nversion = 1\ndevice_name = \"" + execQuiet("hostname") + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\nfolders = [\"notes\", \"documents\", \"dotfiles\"]\n\n[backup]\nschedule = \"hourly\"\n\n[services]\n" + + let writeCmd = "cat > " + graphoConfig() + " << 'GRAPHO_EOF'\n" + configContent + "GRAPHO_EOF" + let ignore = Process.exec(writeCmd) + Console.print(statusIcon(StatusOk) + " Configuration created at " + graphoConfig()) + Console.print("") + + // Generate age key if missing + let hasAgeKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no") + if hasAgeKey |> isYes then + Console.print(statusIcon(StatusOk) + " Age encryption key exists") + else { + Console.print("Generating age encryption key...") + let ignore = Process.exec("age-keygen -o " + graphoDir() + "/age-key.txt 2>&1 || true") + Console.print(statusIcon(StatusOk) + " Age key generated") + } + Console.print("") + + // Check for Syncthing + if hasCommand("syncthing") then { + Console.print(statusIcon(StatusOk) + " Syncthing is available") + Console.print(" Configure via: grapho sync setup") + } else { + Console.print(statusIcon(StatusWarn) + " Syncthing not found") + Console.print(" Install via nix or enable in your NixOS config") + } + + // Check for restic + if hasCommand("restic") then { + Console.print(statusIcon(StatusOk) + " Restic is available") + Console.print(" Configure via: grapho backup init") + } else { + Console.print(statusIcon(StatusWarn) + " Restic not found") + Console.print(" Install via nix or enable in your NixOS config") + } + + Console.print("") + Console.print("Setup complete!") + Console.print("") + Console.print("Your grapho data is stored in: " + graphoDir()) + Console.print("") + Console.print("Next steps:") + Console.print(" grapho status # Check system health") + Console.print(" grapho sync setup # Configure device pairing") + Console.print(" grapho backup init # Configure backups") + Console.print("") +} + +// ============================================================================= +// Init from repo +// ============================================================================= + +fn doInit(repoUrl: String): Unit with {Console, Process} = { + if String.length(repoUrl) == 0 then { + Console.print("Usage: grapho init ") + Console.print("") + Console.print("Clone a grapho config repository to set up this machine.") + Console.print("") + Console.print("Example:") + Console.print(" grapho init https://github.com/user/grapho-config") + Console.print(" grapho init git@github.com:user/grapho-config.git") + } else { + Console.print("Initializing from " + repoUrl) + Console.print("") + + // Create base directory + createDirectories() + + // Clone repo + Console.print("Cloning config repository...") + let cloneResult = Process.exec("git clone " + repoUrl + " " + graphoDir() + "/config-repo 2>&1") + if String.contains(cloneResult, "fatal") then { + Console.print(statusIcon(StatusErr) + " Failed to clone repository") + Console.print(" " + cloneResult) + } else { + Console.print(statusIcon(StatusOk) + " Repository cloned") + + // Check for flake.nix + let hasFlake = execQuiet("test -f " + graphoDir() + "/config-repo/flake.nix && echo yes || echo no") + if hasFlake |> isYes then { + Console.print(statusIcon(StatusOk) + " Found flake.nix") + Console.print("") + Console.print("To apply the NixOS configuration:") + Console.print(" cd " + graphoDir() + "/config-repo") + Console.print(" sudo nixos-rebuild switch --flake .#") + } else { + Console.print(statusIcon(StatusWarn) + " No flake.nix found") + Console.print(" This repo may not be a NixOS configuration") + } + } + Console.print("") + } +} + +// ============================================================================= +// Syncthing helpers (isolated grapho instance) +// ============================================================================= + +fn graphoSyncthingRunning(): Bool with {Process} = { + // Check if Syncthing is running on grapho port (8385) + let result = execQuiet("curl -s http://127.0.0.1:8385/rest/system/status 2>/dev/null | head -c 1") + String.length(result) > 0 +} + +fn getSyncthingDeviceId(): String with {Process} = { + execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show system 2>/dev/null | grep -o 'myID.*' | cut -d'\"' -f3") +} + +fn getSyncFolderCount(): String with {Process} = { + execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0") +} + +fn getSyncDeviceCount(): String with {Process} = { + execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0") +} + +// ============================================================================= +// Sync commands +// ============================================================================= + +fn doSyncStatus(): Unit with {Console, Process} = { + Console.print("grapho sync status") + Console.print("") + + if hasCommand("syncthing") then { + if graphoSyncthingRunning() then { + let folders = getSyncFolderCount() + let devices = getSyncDeviceCount() + Console.print(statusIcon(StatusOk) + " Syncthing running on port 8385") + Console.print(" Folders: " + folders) + Console.print(" Devices: " + devices) + Console.print("") + Console.print(" Web UI: http://127.0.0.1:8385") + } else { + Console.print(statusIcon(StatusWarn) + " Grapho Syncthing not running") + Console.print("") + Console.print(" Start with: systemctl --user start syncthing-grapho") + Console.print(" Or: syncthing serve --home=" + graphoDir() + "/syncthing/config --gui-address=127.0.0.1:8385") + } + } else { + Console.print(statusIcon(StatusErr) + " Syncthing not installed") + Console.print(" Install via nix or NixOS config") + } +} + +fn doSyncSetup(): Unit with {Console, Process} = { + Console.print("grapho sync setup") + Console.print("") + + if hasCommand("syncthing") == false then { + Console.print(statusIcon(StatusErr) + " Syncthing not installed") + Console.print(" Install syncthing first") + } else { + // Initialize Syncthing config if needed + let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no") + if hasConfig |> isNo then { + Console.print("Initializing Syncthing for grapho...") + let ignore = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder 2>&1") + Console.print(statusIcon(StatusOk) + " Syncthing initialized") + } else { + Console.print(statusIcon(StatusOk) + " Syncthing already configured") + } + + // Get device ID + Console.print("") + let deviceId = getSyncthingDeviceId() + if String.length(deviceId) > 10 then { + Console.print("Your device ID:") + Console.print(" " + deviceId) + Console.print("") + Console.print("Share this ID with other devices to pair them.") + } else { + Console.print("Start Syncthing to get your device ID:") + Console.print(" syncthing serve --home=" + graphoDir() + "/syncthing/config --gui-address=127.0.0.1:8385") + } + } +} + +fn doSync(): Unit with {Console, Process} = { + Console.print("Syncing...") + + if graphoSyncthingRunning() then { + Console.print("-> Triggering Syncthing rescan") + let ignore = Process.exec("syncthing cli --home=" + graphoDir() + "/syncthing/config scan 2>/dev/null || true") + Console.print(statusIcon(StatusOk) + " Sync triggered") + } else { + Console.print(statusIcon(StatusWarn) + " Syncthing not running") + Console.print(" Start with: grapho sync setup") + } + Console.print("") +} + +// ============================================================================= +// Backup commands +// ============================================================================= + +fn doBackupInit(): Unit with {Console, Process} = { + Console.print("grapho backup init") + Console.print("") + + if hasCommand("restic") == false then { + Console.print(statusIcon(StatusErr) + " Restic not installed") + Console.print(" Install restic first") + } else { + Console.print("Backup repository setup") + Console.print("") + Console.print("Configure your backup repository in: " + graphoConfig()) + Console.print("") + Console.print("Example configuration:") + Console.print(" [backup]") + Console.print(" repository = \"sftp:server:/backups/grapho\"") + Console.print(" schedule = \"hourly\"") + Console.print("") + Console.print("After configuring, initialize with:") + Console.print(" restic -r init") + } +} + +fn doBackupList(): Unit with {Console, Process} = { + Console.print("grapho backup list") + Console.print("") + + if hasCommand("restic") == false then { + Console.print(statusIcon(StatusErr) + " Restic not installed") + } else { + Console.print("Run: restic -r snapshots") + Console.print("") + Console.print("Check your repository location in: " + graphoConfig()) + } +} + +fn doBackup(): Unit with {Console, Process} = { + Console.print("Running backup...") + + if hasCommand("restic") then { + // Check for systemd service first + let hasService = execQuiet("systemctl cat grapho-backup.service 2>/dev/null || systemctl cat restic-backup.service 2>/dev/null") + if String.length(hasService) > 0 then { + Console.print("-> Triggering backup service") + let ignore = Process.exec("systemctl start grapho-backup.service 2>/dev/null || systemctl start restic-backup.service 2>/dev/null || true") + Console.print(statusIcon(StatusOk) + " Backup triggered") + } else { + Console.print(statusIcon(StatusWarn) + " No systemd backup service configured") + Console.print(" Configure in your NixOS config or run restic manually") + Console.print(" restic -r backup " + graphoDir() + "/sync") + } + } else { + Console.print(statusIcon(StatusErr) + " Restic not installed") + } + Console.print("") +} + +// ============================================================================= +// Server commands +// ============================================================================= + +fn doServerStatus(): Unit with {Console, Process} = { + Console.print("grapho server status") + Console.print("") + + let serverDir = graphoDir() + "/server" + let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") + + if isMounted |> isYes then { + let usage = execQuiet("df -h " + serverDir + " | tail -1 | tr -s ' ' | cut -d' ' -f3,2,5 | tr ' ' '/'") + Console.print(statusIcon(StatusOk) + " Server mounted at " + serverDir) + Console.print(" Usage: " + usage) + } else { + Console.print(statusIcon(StatusNone) + " Server not mounted") + Console.print(" Configure in: " + graphoConfig()) + Console.print(" Mount point: " + serverDir) + } +} + +// ============================================================================= +// One-liner health check // ============================================================================= fn showHealthCheck(): Unit with {Console, Process} = { - let hasIssues = false - let hasWarnings = false - let summary = "" - - // Check nb - let nbOk = hasCommand("nb") - let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0" - - // Check syncthing - let stOk = hasCommand("syncthing") - let stRunning = if stOk then syncthingRunning() else false - let stFolders = if stRunning then getSyncthingFolderCount() else "0" - let stDevices = if stRunning then getSyncthingDeviceCount() else "0" - - // Check backup - let resticOk = hasCommand("restic") - let timerActive = execQuiet("systemctl is-active restic-backup.timer") - let backupOk = if timerActive == "active" then true else false - - // Determine overall status and print appropriate message - if nbOk == false || stOk == false || resticOk == false then { - Console.print(statusEmoji(StatusErr) + " Issues detected") - if nbOk == false then - Console.print(" " + statusIcon(StatusErr) + " nb: not installed -> nix develop") - else () - if stOk == false then - Console.print(" " + statusIcon(StatusErr) + " syncthing: not installed -> nix develop") - else () - if resticOk == false then - Console.print(" " + statusIcon(StatusErr) + " restic: not installed -> nix develop") - else () - } else if stRunning == false || backupOk == false then { - Console.print(statusEmoji(StatusWarn) + " Warnings") - if stRunning == false then - Console.print(" " + statusIcon(StatusWarn) + " syncthing: not running -> systemctl --user start syncthing") - else () - if backupOk == false then - Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> sudo systemctl enable --now restic-backup.timer") - else () + // Check if initialized + if isInitialized() == false then { + showWelcome() } else { - Console.print(statusEmoji(StatusOk) + " All systems healthy (" + nbCount + " notebooks, " + stFolders + " folders, " + stDevices + " devices, backup active)") + let configOk = true + let syncOk = graphoSyncthingRunning() + let backupOk = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null") |> isActive + + let folders = if syncOk then getSyncFolderCount() else "0" + let devices = if syncOk then getSyncDeviceCount() else "0" + + // Determine issues + if syncOk == false || backupOk == false then { + if syncOk == false && backupOk == false then { + Console.print(statusEmoji(StatusWarn) + " Warnings") + Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho") + Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init") + } else if syncOk == false then { + Console.print(statusEmoji(StatusWarn) + " Sync not running") + Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho") + } else { + Console.print(statusEmoji(StatusWarn) + " Backup timer inactive") + Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init") + } + } else { + Console.print(statusEmoji(StatusOk) + " All systems healthy (" + folders + " folders, " + devices + " devices, backup active)") + } } } @@ -129,99 +463,56 @@ fn showHealthCheck(): Unit with {Console, Process} = { // Status dashboard // ============================================================================= -fn showStatus(verbose: Bool): Unit with {Console, Process} = { +fn showStatus(): Unit with {Console, Process} = { Console.print("grapho status") Console.print("") - // nb - if hasCommand("nb") then { - let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l") - Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks") - if verbose then { - let notebooks = execQuiet("nb notebooks --names") - if String.length(notebooks) > 0 then { - let lines = String.lines(notebooks) - printNotebooks(lines) - } else () - } else () + if isInitialized() == false then { + Console.print(statusIcon(StatusNone) + " grapho not initialized") + Console.print(" Run: grapho setup") + Console.print("") } else { - Console.print(statusIcon(StatusErr) + " nb: not installed") - Console.print(" Fix: nix develop") - } - - // Syncthing - if hasCommand("syncthing") then { - if syncthingRunning() then { - let folders = getSyncthingFolderCount() - let devices = getSyncthingDeviceCount() - Console.print(statusIcon(StatusOk) + " syncthing: " + folders + " folders, " + devices + " devices") - if verbose then { - let folderLabels = execQuiet("syncthing cli show config | jq -r '.folders[].label'") - if String.length(folderLabels) > 0 then { - Console.print(" Folders:") - let flines = String.lines(folderLabels) - printLinesIndented(flines) - } else () - let deviceNames = execQuiet("syncthing cli show config | jq -r '.devices[].name'") - if String.length(deviceNames) > 0 then { - Console.print(" Devices:") - let dlines = String.lines(deviceNames) - printLinesIndented(dlines) - } else () - } else () + // Config + let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no") + if hasRepo |> isYes then { + let repoRemote = execQuiet("cd " + graphoDir() + "/config-repo && git remote get-url origin 2>/dev/null || echo local") + Console.print(statusIcon(StatusOk) + " config: " + repoRemote) } else { - Console.print(statusIcon(StatusWarn) + " syncthing: not running") - Console.print(" Fix: systemctl --user start syncthing") + Console.print(statusIcon(StatusNone) + " config: no repo linked") + Console.print(" Run: grapho init ") } - } else { - Console.print(statusIcon(StatusErr) + " syncthing: not installed") - Console.print(" Fix: nix develop") - } - // Backup - if hasCommand("restic") then { - let timerActive = execQuiet("systemctl is-active restic-backup.timer") - if timerActive == "active" then { + // Sync + if graphoSyncthingRunning() then { + let folders = getSyncFolderCount() + let devices = getSyncDeviceCount() + Console.print(statusIcon(StatusOk) + " sync: " + folders + " folders, " + devices + " devices") + } else { + Console.print(statusIcon(StatusWarn) + " sync: not running") + Console.print(" Run: grapho sync setup") + } + + // Backup + let timerActive = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null") + if timerActive |> isActive then { Console.print(statusIcon(StatusOk) + " backup: timer active") } else { Console.print(statusIcon(StatusWarn) + " backup: timer inactive") - Console.print(" Fix: sudo systemctl enable --now restic-backup.timer") + Console.print(" Run: grapho backup init") + } + + // Server + let serverDir = graphoDir() + "/server" + let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") + if isMounted |> isYes then { + Console.print(statusIcon(StatusOk) + " server: mounted") + } else { + Console.print(statusIcon(StatusNone) + " server: not configured") } - } else { - Console.print(statusIcon(StatusErr) + " backup: restic not installed") - Console.print(" Fix: nix develop") } + Console.print("") } -fn printNotebooks(lines: List): Unit with {Console, Process} = - match List.head(lines) { - Some(name) => { - if String.length(name) > 0 then { - let count = execQuiet("find ~/.nb/" + name + " -name '*.md' 2>/dev/null | wc -l") - Console.print(" " + name + ": " + count + " notes") - } else () - match List.tail(lines) { - Some(rest) => printNotebooks(rest), - None => () - } - }, - None => () - } - -fn printLinesIndented(lines: List): Unit with {Console} = - match List.head(lines) { - Some(line) => { - if String.length(line) > 0 then - Console.print(" - " + line) - else () - match List.tail(lines) { - Some(rest) => printLinesIndented(rest), - None => () - } - }, - None => () - } - // ============================================================================= // Doctor command // ============================================================================= @@ -229,8 +520,6 @@ fn printLinesIndented(lines: List): Unit with {Console} = fn showDoctor(): Unit with {Console, Process} = { Console.print("grapho doctor") Console.print("") - Console.print("Checking system health...") - Console.print("") // Check nix if hasCommand("nix") then { @@ -249,166 +538,97 @@ fn showDoctor(): Unit with {Console, Process} = { Console.print(statusIcon(StatusErr) + " git: not found") } - // Check nb - if hasCommand("nb") then { - let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l") - Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks") - } else { - Console.print(statusIcon(StatusErr) + " nb: not installed") - Console.print(" Fix: nix develop") - } - // Check syncthing if hasCommand("syncthing") then { - if syncthingRunning() then { - let folders = getSyncthingFolderCount() - Console.print(statusIcon(StatusOk) + " syncthing: running (" + folders + " folders)") + if graphoSyncthingRunning() then { + Console.print(statusIcon(StatusOk) + " syncthing: running on port 8385") } else { - Console.print(statusIcon(StatusWarn) + " syncthing: not running") - Console.print(" Fix: systemctl --user start syncthing") + Console.print(statusIcon(StatusWarn) + " syncthing: installed but not running") + Console.print(" Fix: grapho sync setup") } } else { Console.print(statusIcon(StatusErr) + " syncthing: not installed") - Console.print(" Fix: nix develop") } - // Check backup + // Check restic if hasCommand("restic") then { - let timerActive = execQuiet("systemctl is-active restic-backup.timer") - if timerActive == "active" then { - Console.print(statusIcon(StatusOk) + " backup: timer active") - } else { - Console.print(statusIcon(StatusWarn) + " backup: timer inactive") - Console.print(" Fix: sudo systemctl enable --now restic-backup.timer") - } + Console.print(statusIcon(StatusOk) + " restic: installed") } else { Console.print(statusIcon(StatusErr) + " restic: not installed") - Console.print(" Fix: nix develop") } - // Additional checks - Console.print("") - Console.print("Additional checks:") - - // Check age key - let ageKey = execQuiet("test -f ~/.config/sops/age/keys.txt && echo yes") - if ageKey == "yes" then - Console.print(statusIcon(StatusOk) + " Age encryption key exists") - else { - Console.print(statusIcon(StatusWarn) + " No age key") - Console.print(" Fix: age-keygen -o ~/.config/sops/age/keys.txt") - } - - Console.print("") - Console.print("Run 'grapho status -v' for detailed component info") -} - -// ============================================================================= -// JSON output -// ============================================================================= - -fn showJson(): Unit with {Console, Process} = { - // Note: JSON output with quotes is difficult in Lux due to escape sequence limitations - // Using a simple key=value format instead - let nbOk = hasCommand("nb") - let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0" - let nbStatus = if nbOk then "healthy" else "error" - let nbMsg = if nbOk then nbCount + " notebooks" else "not installed" - - let stOk = hasCommand("syncthing") - let stRunning = if stOk then syncthingRunning() else false - let stStatus = if stOk == false then "error" else if stRunning then "healthy" else "warning" - let stMsg = if stOk == false then "not installed" else if stRunning then "running" else "not running" - - let resticOk = hasCommand("restic") - let timerResult = execQuiet("systemctl is-active restic-backup.timer") - let timerActive = if timerResult == "active" then true else false - let backupStatus = if resticOk == false then "error" else if timerActive then "healthy" else "warning" - let backupMsg = if resticOk == false then "not installed" else if timerActive then "timer active" else "timer inactive" - - // Output in a simple parseable format - Console.print("nb.status=" + nbStatus) - Console.print("nb.message=" + nbMsg) - Console.print("syncthing.status=" + stStatus) - Console.print("syncthing.message=" + stMsg) - Console.print("backup.status=" + backupStatus) - Console.print("backup.message=" + backupMsg) -} - -// ============================================================================= -// Sync command -// ============================================================================= - -fn doSync(): Unit with {Console, Process} = { - Console.print("Syncing...") - - // Sync nb notebooks - if hasCommand("nb") then { - Console.print("-> nb sync --all") - let result = Process.exec("nb sync --all 2>&1 || true") - if String.contains(result, "error") then - Console.print(statusIcon(StatusWarn) + " nb sync had issues") - else - Console.print(statusIcon(StatusOk) + " nb synced") - } else () - - // Trigger Syncthing scan - if hasCommand("syncthing") then { - if syncthingRunning() then { - Console.print("-> syncthing cli scan") - let scanResult = Process.exec("syncthing cli scan 2>/dev/null || true") - Console.print(statusIcon(StatusOk) + " Syncthing scan triggered") - } else () - } else () - - Console.print("Done!") -} - -// ============================================================================= -// Backup command -// ============================================================================= - -fn doBackup(): Unit with {Console, Process} = { - Console.print("Running backup...") - - if hasCommand("restic") then { - let hasService = execQuiet("systemctl cat restic-backup.service") - if String.length(hasService) > 0 then { - Console.print("-> sudo systemctl start restic-backup.service") - let startResult = Process.exec("sudo systemctl start restic-backup.service 2>&1 || true") - Console.print(statusIcon(StatusOk) + " Backup service triggered") + // 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) + " No systemd backup service configured") - Console.print(" Configure in modules/backup.nix") + Console.print(statusIcon(StatusWarn) + " age: no key at " + graphoDir() + "/age-key.txt") + Console.print(" Fix: grapho setup") } } else { - Console.print(statusIcon(StatusErr) + " restic not installed") + Console.print(statusIcon(StatusErr) + " age: not installed") } + + // Directory structure + Console.print("") + Console.print("Directory structure:") + let hasGraphoDir = execQuiet("test -d " + graphoDir() + " && echo yes || echo no") + if hasGraphoDir |> isYes then { + Console.print(statusIcon(StatusOk) + " " + graphoDir()) + checkDir("config-repo") + checkDir("syncthing") + checkDir("sync") + checkDir("restic") + checkDir("server") + } else { + Console.print(statusIcon(StatusErr) + " " + graphoDir() + " does not exist") + Console.print(" Fix: grapho setup") + } + + Console.print("") +} + +fn checkDir(dir: String): Unit with {Console, Process} = { + let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no") + if exists |> isYes then + Console.print(" " + statusIcon(StatusOk) + " " + dir + "/") + else + Console.print(" " + statusIcon(StatusNone) + " " + dir + "/ (missing)") } // ============================================================================= // Help // ============================================================================= -fn showHelp(): Unit with {Console} = { +fn showHelp(): Unit with {Console, Process} = { Console.print("grapho - Personal Data Infrastructure") Console.print("") Console.print("Usage:") - Console.print(" grapho Health check (one-liner)") - Console.print(" grapho status Component status") - Console.print(" grapho status -v Verbose status with details") - Console.print(" grapho doctor Diagnose issues and fixes") - Console.print(" grapho sync Sync all (nb + syncthing)") - Console.print(" grapho backup Run backup now") - Console.print(" grapho --json Machine-readable output (key=value format)") - Console.print(" grapho help Show this help") + 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("Quick start:") - Console.print(" nb add Create a new note") - Console.print(" nb search Search notes") - Console.print(" grapho sync Sync to all devices") + Console.print("Sync (Type 2 - Real-time bidirectional):") + Console.print(" grapho sync Trigger sync") + Console.print(" grapho sync status Show sync status") + Console.print(" grapho sync setup Configure Syncthing") + Console.print("") + Console.print("Backup (Type 3 - Periodic snapshots):") + Console.print(" grapho backup Run backup now") + Console.print(" grapho backup list List snapshots") + Console.print(" grapho backup init Configure backup repository") + Console.print("") + Console.print("Server (Type 4 - Central storage):") + Console.print(" grapho server Show server status") + Console.print("") + Console.print("Status:") + Console.print(" grapho status Full status dashboard") + Console.print(" grapho doctor Diagnose issues") + Console.print(" grapho help Show this help") + Console.print("") + Console.print("Data directory: " + graphoDir()) Console.print("") - Console.print("More info: https://git.qrty.ink/blu/grapho") } // ============================================================================= @@ -421,30 +641,37 @@ fn main(): Unit with {Console, Process} = { Some(c) => c, None => "" } + let subcmd = match List.get(args, 2) { + Some(s) => s, + None => "" + } match cmd { "" => showHealthCheck(), - "status" => { - let subcmd = match List.get(args, 2) { - Some(s) => s, - None => "" - } + "init" => doInit(subcmd), + "setup" => doSetup(), + "sync" => { match subcmd { - "-v" => showStatus(true), - "--verbose" => showStatus(true), - "verbose" => showStatus(true), - _ => showStatus(false) + "status" => doSyncStatus(), + "setup" => doSyncSetup(), + "" => doSync(), + _ => doSyncStatus() } }, + "backup" => { + match subcmd { + "init" => doBackupInit(), + "list" => doBackupList(), + "" => doBackup(), + _ => doBackup() + } + }, + "server" => doServerStatus(), + "status" => showStatus(), "doctor" => showDoctor(), - "sync" => doSync(), - "backup" => doBackup(), "help" => showHelp(), "-h" => showHelp(), "--help" => showHelp(), - "--json" => showJson(), - "-j" => showJson(), - "json" => showJson(), _ => showHelp() } } diff --git a/docs/LUX-LIMITATIONS.md b/docs/LUX-LIMITATIONS.md index e6be597..31cbc2f 100644 --- a/docs/LUX-LIMITATIONS.md +++ b/docs/LUX-LIMITATIONS.md @@ -116,15 +116,93 @@ let count = Int.parse(someString) // ERROR: Unknown effect operation --- +## C Backend Issues + +### 7. String Equality Comparison Generates Incorrect C Code +**Severity:** High +**Status:** Bug + +Using `==` to compare strings generates C code that compares pointers instead of string contents. + +```lux +let result = execQuiet("echo yes") +if result == "yes" then ... // C code: (result == "yes") - pointer comparison! +``` + +**Impact:** String comparisons fail in compiled binaries. + +**Workaround:** Use `String.contains` for comparison: +```lux +fn isYes(s: String): Bool = String.contains(s, "yes") +if result |> isYes then ... +``` + +### 8. String.startsWith Not Available in C Backend +**Severity:** Medium +**Status:** Bug + +`String.startsWith` works in interpreter but generates undefined function calls in C. + +```lux +String.startsWith(s, "prefix") // C error: lux_string__startsWith undefined +``` + +**Workaround:** Use `String.contains` instead. + +### 9. `let _ = expr` Pattern Not Supported +**Severity:** Low +**Status:** Bug + +The underscore wildcard pattern for discarding results doesn't work. + +```lux +let _ = Process.exec("...") // ERROR: Expected identifier +``` + +**Workaround:** Use a named binding: +```lux +let ignore = Process.exec("...") +``` + +### 10. List Literals and Recursion Cause Segfaults +**Severity:** High +**Status:** Bug + +Combining list literals with recursive functions can cause segmentation faults in compiled binaries while working fine in interpreter. + +```lux +// This crashes when compiled: +let dirs = ["a", "b", "c"] +fn processDirs(dirs: List): Unit = + match List.head(dirs) { + Some(d) => { ...; match List.tail(dirs) { Some(rest) => processDirs(rest), ... } } + None => () + } +``` + +**Workaround:** Avoid list literals with recursive processing. Inline the operations: +```lux +fn processA(): Unit = ... +fn processB(): Unit = ... +fn processC(): Unit = ... +// Call each individually +``` + +--- + ## Suggestions for Lux 1. **Add escape sequence support** - At minimum `\"`, `\\`, `\n`, `\t` 2. **Fix String.fromChar** to return String, not Int 3. **Add raw string literals** - Something like `r"..."` or `'''...'''` for shell commands -4. **Fix the double execution bug** in the runtime +4. **Fix the double execution bug** in the runtime (DONE) 5. **Support record type literals** matching their declared type 6. **Add Int.parse and Float.parse** for string-to-number conversion 7. **Consider a heredoc syntax** for multi-line strings with special characters +8. **Fix string equality** - Use strcmp in C backend for string == +9. **Support `let _ = `** - Allow underscore as discard binding +10. **Fix String.startsWith** in C backend +11. **Fix list literals with recursion** causing segfaults --- @@ -135,3 +213,6 @@ let count = Int.parse(someString) // ERROR: Unknown effect operation 3. **Quotes in output:** Avoided entirely or generated via shell 4. **Structured types:** Using individual variables instead of records 5. **Numeric parsing:** Keeping counts as strings throughout +6. **String comparison:** Using `String.contains` with helper functions instead of `==` +7. **Discarding results:** Using `let ignore = ...` instead of `let _ = ...` +8. **Lists with recursion:** Replaced with individual function calls diff --git a/flake.nix b/flake.nix index 820c289..f2f56d6 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,11 @@ inputs.nixpkgs.follows = "nixpkgs"; }; + lux = { + url = "path:/home/blu/src/lux"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + # Optional: Neovim distribution # nixvim = { # url = "github:nix-community/nixvim"; @@ -21,7 +26,7 @@ # }; }; - outputs = { self, nixpkgs, home-manager, sops-nix, ... }@inputs: + outputs = { self, nixpkgs, home-manager, sops-nix, lux, ... }@inputs: let # Supported systems supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; @@ -37,6 +42,7 @@ # Shared modules for all hosts sharedModules = [ + ./modules/grapho.nix ./modules/nb.nix ./modules/syncthing.nix ./modules/backup.nix @@ -60,6 +66,39 @@ }; in { + # Grapho CLI package + packages = forAllSystems (system: + let + pkgs = nixpkgsFor.${system}; + luxPkg = lux.packages.${system}.default; + in { + grapho = pkgs.stdenv.mkDerivation { + pname = "grapho"; + version = "0.1.0"; + src = ./cli; + + nativeBuildInputs = [ luxPkg pkgs.gcc ]; + + buildPhase = '' + ${luxPkg}/bin/lux compile grapho.lux -o grapho + ''; + + installPhase = '' + mkdir -p $out/bin + cp grapho $out/bin/ + ''; + + meta = { + description = "Personal data infrastructure CLI"; + homepage = "https://github.com/user/grapho"; + license = pkgs.lib.licenses.mit; + }; + }; + + default = self.packages.${system}.grapho; + } + ); + # NixOS configurations # Uncomment and customize for your hosts: # @@ -132,7 +171,9 @@ default = pkgs.mkShell { name = "unsbs-dev"; - packages = with pkgs; [ + packages = [ + lux.packages.${system}.default + ] ++ (with pkgs; [ # Tier 2: Notes & Sync nb # Notebook CLI syncthing # File sync @@ -159,7 +200,7 @@ # Nix tools nil # Nix LSP nixpkgs-fmt # Nix formatter - ]; + ]); shellHook = '' printf '\033[1m%s\033[0m\n' "Ultimate Notetaking, Sync & Backup System" @@ -175,6 +216,7 @@ # Export modules for use in other flakes nixosModules = { + grapho = import ./modules/grapho.nix; nb = import ./modules/nb.nix; syncthing = import ./modules/syncthing.nix; backup = import ./modules/backup.nix; diff --git a/grapho b/grapho index eba82db..d758a2b 100755 Binary files a/grapho and b/grapho differ diff --git a/modules/grapho.nix b/modules/grapho.nix new file mode 100644 index 0000000..2c76514 --- /dev/null +++ b/modules/grapho.nix @@ -0,0 +1,341 @@ +# Grapho Module +# +# Unified personal data infrastructure module for NixOS. +# Sets up Syncthing (isolated) + Restic backup + directory structure. +# +# Usage: +# services.grapho.enable = true; +# services.grapho.user = "youruser"; +# +# This creates: +# - ~/.config/grapho/ directory structure +# - Isolated Syncthing on port 8385 (separate from system Syncthing) +# - Restic backup timer for grapho data + +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.grapho; + home = config.users.users.${cfg.user}.home; + graphoDir = "${home}/.config/grapho"; +in { + options.services.grapho = { + enable = mkEnableOption "grapho personal data infrastructure"; + + user = mkOption { + type = types.str; + description = "User to run grapho services as."; + example = "alice"; + }; + + group = mkOption { + type = types.str; + default = "users"; + description = "Group to run grapho services as."; + }; + + # Syncthing options + syncthing = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable grapho's isolated Syncthing instance."; + }; + + guiPort = mkOption { + type = types.port; + default = 8385; + description = "Port for Syncthing web GUI (separate from system Syncthing)."; + }; + + syncPort = mkOption { + type = types.port; + default = 22001; + description = "Port for Syncthing file sync (separate from system Syncthing)."; + }; + + discoveryPort = mkOption { + type = types.port; + default = 21028; + description = "Port for Syncthing local discovery."; + }; + + openFirewall = mkOption { + type = types.bool; + default = true; + description = "Open firewall for grapho's Syncthing ports."; + }; + + devices = mkOption { + type = types.attrsOf (types.submodule { + options = { + id = mkOption { + type = types.str; + description = "Device ID."; + }; + name = mkOption { + type = types.nullOr types.str; + default = null; + description = "Friendly name."; + }; + addresses = mkOption { + type = types.listOf types.str; + default = [ "dynamic" ]; + description = "Connection addresses."; + }; + }; + }); + default = {}; + description = "Devices to connect to."; + }; + + extraFolders = mkOption { + type = types.attrsOf (types.submodule { + options = { + path = mkOption { + type = types.str; + description = "Local path to sync."; + }; + devices = mkOption { + type = types.listOf types.str; + default = []; + description = "Devices to share with."; + }; + type = mkOption { + type = types.enum [ "sendreceive" "sendonly" "receiveonly" ]; + default = "sendreceive"; + description = "Sync type."; + }; + }; + }); + default = {}; + description = "Additional folders to sync (beyond default notes/documents/dotfiles)."; + }; + }; + + # Backup options + backup = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable restic backup of grapho data."; + }; + + repository = mkOption { + type = types.str; + default = ""; + description = "Restic repository location (e.g., 'sftp:server:/backups/grapho')."; + example = "sftp:backup-server:/backups/grapho"; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to file containing restic repository password."; + }; + + schedule = mkOption { + type = types.str; + default = "hourly"; + description = "Backup schedule (systemd timer OnCalendar syntax)."; + example = "*-*-* *:00:00"; + }; + + extraPaths = mkOption { + type = types.listOf types.str; + default = []; + description = "Additional paths to include in backup."; + }; + + pruneOpts = mkOption { + type = types.listOf types.str; + default = [ + "--keep-hourly 24" + "--keep-daily 7" + "--keep-weekly 4" + "--keep-monthly 12" + ]; + description = "Restic prune/forget options."; + }; + }; + + # Server mount options + server = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable server data mount."; + }; + + type = mkOption { + type = types.enum [ "nfs" "sshfs" "syncthing" ]; + default = "syncthing"; + description = "Type of server mount."; + }; + + host = mkOption { + type = types.str; + default = ""; + description = "Server hostname (for NFS/SSHFS)."; + }; + + remotePath = mkOption { + type = types.str; + default = ""; + description = "Path on server to mount."; + }; + }; + }; + + config = mkIf cfg.enable { + # Ensure user exists + users.users.${cfg.user} = { + isNormalUser = true; + }; + + # Create grapho directory structure + systemd.tmpfiles.rules = [ + "d ${graphoDir} 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/config-repo 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/syncthing/config 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/syncthing/db 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/sync 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/sync/notes 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/sync/documents 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/sync/dotfiles 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/restic/cache 0755 ${cfg.user} ${cfg.group} -" + "d ${graphoDir}/server 0755 ${cfg.user} ${cfg.group} -" + ]; + + # Isolated Syncthing for grapho + services.syncthing = mkIf cfg.syncthing.enable { + enable = true; + user = cfg.user; + group = cfg.group; + dataDir = "${graphoDir}/sync"; + configDir = "${graphoDir}/syncthing/config"; + guiAddress = "127.0.0.1:${toString cfg.syncthing.guiPort}"; + + overrideDevices = true; + overrideFolders = true; + + settings = { + devices = mapAttrs (name: device: { + inherit (device) id addresses; + name = if device.name != null then device.name else name; + }) cfg.syncthing.devices; + + folders = { + # Default grapho folders + "grapho-notes" = { + path = "${graphoDir}/sync/notes"; + id = "grapho-notes"; + devices = attrNames cfg.syncthing.devices; + type = "sendreceive"; + fsWatcherEnabled = true; + }; + "grapho-documents" = { + path = "${graphoDir}/sync/documents"; + id = "grapho-documents"; + devices = attrNames cfg.syncthing.devices; + type = "sendreceive"; + fsWatcherEnabled = true; + }; + "grapho-dotfiles" = { + path = "${graphoDir}/sync/dotfiles"; + id = "grapho-dotfiles"; + devices = attrNames cfg.syncthing.devices; + type = "sendreceive"; + fsWatcherEnabled = true; + }; + } // (mapAttrs (name: folder: { + inherit (folder) path type; + id = name; + devices = if folder.devices == [] then attrNames cfg.syncthing.devices else folder.devices; + fsWatcherEnabled = true; + }) cfg.syncthing.extraFolders); + + options = { + urAccepted = -1; + relaysEnabled = true; + globalAnnounceEnabled = true; + localAnnounceEnabled = true; + localAnnouncePort = cfg.syncthing.discoveryPort; + listenAddresses = [ + "tcp://0.0.0.0:${toString cfg.syncthing.syncPort}" + "quic://0.0.0.0:${toString cfg.syncthing.syncPort}" + ]; + }; + }; + }; + + # Firewall for grapho Syncthing + networking.firewall = mkIf (cfg.syncthing.enable && cfg.syncthing.openFirewall) { + allowedTCPPorts = [ cfg.syncthing.syncPort ]; + allowedUDPPorts = [ cfg.syncthing.syncPort cfg.syncthing.discoveryPort ]; + }; + + # Restic backup service + systemd.services.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { + description = "Grapho data backup"; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + ExecStart = let + paths = [ "${graphoDir}/sync" ] ++ cfg.backup.extraPaths; + pathArgs = concatMapStringsSep " " (p: "'${p}'") paths; + in '' + ${pkgs.restic}/bin/restic backup \ + --cache-dir ${graphoDir}/restic/cache \ + ${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \ + -r ${cfg.backup.repository} \ + ${pathArgs} + ''; + ExecStartPost = '' + ${pkgs.restic}/bin/restic forget \ + --cache-dir ${graphoDir}/restic/cache \ + ${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \ + -r ${cfg.backup.repository} \ + ${concatStringsSep " " cfg.backup.pruneOpts} + ''; + }; + }; + + systemd.timers.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { + description = "Grapho backup timer"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.backup.schedule; + Persistent = true; + RandomizedDelaySec = "5min"; + }; + }; + + # Server mount (NFS) + fileSystems."${graphoDir}/server" = mkIf (cfg.server.enable && cfg.server.type == "nfs" && cfg.server.host != "") { + device = "${cfg.server.host}:${cfg.server.remotePath}"; + fsType = "nfs"; + options = [ + "x-systemd.automount" + "noauto" + "x-systemd.idle-timeout=600" + "soft" + "timeo=15" + ]; + }; + + # Install grapho CLI and dependencies + environment.systemPackages = with pkgs; [ + syncthing + restic + age + jq + ]; + }; +}