Implement self-contained grapho architecture with four data types
Major rewrite of grapho CLI to support: - Type 1 (Config): grapho init <repo-url> clones NixOS config - Type 2 (Sync): Isolated Syncthing on port 8385 (separate from system) - Type 3 (Backup): Restic integration with systemd timer - Type 4 (Server): Mount point for central server data New features: - Welcome flow on first run (detects ~/.config/grapho/grapho.toml) - grapho setup wizard creates directory structure - grapho sync/backup/server subcommands - grapho status shows all four data types - grapho doctor checks system health Added modules/grapho.nix NixOS module: - Configures isolated Syncthing (ports 8385, 22001, 21028) - Sets up grapho-backup systemd service and timer - Creates directory structure via tmpfiles - Optional NFS server mount Updated flake.nix: - Export grapho NixOS module - Add grapho CLI package (nix build .#grapho) Documented additional Lux language limitations: - String == comparison broken in C backend - let _ = pattern not supported - List literals with recursion cause segfaults Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
805
cli/grapho.lux
805
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 <repo-url> 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 <url> # 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 <repo-url>")
|
||||
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 .#<hostname>")
|
||||
} 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 <repo> 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 <your-repo> 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 <repo> 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 <repo-url>")
|
||||
}
|
||||
} 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<String>): 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<String>): 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<String>): 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 <repo-url> 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 <term> 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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user