Files
grapho/cli/grapho.lux
Brandon Lucas 117e6af528 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>
2026-02-16 06:12:58 -05:00

680 lines
26 KiB
Plaintext

// grapho - Unified Personal Data Infrastructure
//
// Four data types:
// 1. Config - Declarative machine setup (NixOS + Git)
// 2. Sync - Real-time bidirectional sync (Syncthing)
// 3. Backup - Periodic snapshots (Restic)
// 4. Server - Large files on central server
//
// Usage:
// grapho Health check (or welcome if uninitialized)
// grapho init <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
// =============================================================================
type SyncStatus =
| StatusOk
| StatusWarn
| StatusErr
| StatusNone
// =============================================================================
// Status icons
// =============================================================================
fn statusIcon(s: SyncStatus): String =
match s {
StatusOk => "[ok]",
StatusWarn => "[!!]",
StatusErr => "[ERR]",
StatusNone => "[--]"
}
fn statusEmoji(s: SyncStatus): String =
match s {
StatusOk => "✓",
StatusWarn => "⚠",
StatusErr => "✗",
StatusNone => "-"
}
// =============================================================================
// Shell helpers
// =============================================================================
fn hasCommand(cmd: String): Bool with {Process} = {
let result = Process.exec("command -v " + cmd + " 2>/dev/null || true")
String.length(String.trim(result)) > 0
}
fn execQuiet(cmd: String): String with {Process} =
String.trim(Process.exec(cmd + " 2>/dev/null || true"))
fn homeDir(): String with {Process} =
match Process.env("HOME") {
Some(h) => h,
None => "/tmp"
}
// String comparison helpers (workaround for Lux C backend bug with == on strings)
fn isYes(s: String): Bool = String.contains(s, "yes")
fn isNo(s: String): Bool = String.contains(s, "no")
fn isActive(s: String): Bool = String.contains(s, "active")
fn graphoDir(): String with {Process} =
homeDir() + "/.config/grapho"
fn graphoConfig(): String with {Process} =
graphoDir() + "/grapho.toml"
// =============================================================================
// Initialization detection
// =============================================================================
fn isInitialized(): Bool with {Process} = {
let result = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
result |> isYes
}
fn ensureDir(path: String): Unit with {Process} = {
let ignore = Process.exec("mkdir -p " + path)
()
}
// =============================================================================
// Directory structure
// =============================================================================
fn createDirectories(): Unit with {Process, Console} = {
let base = graphoDir()
Console.print("Creating directory structure...")
ensureDir(base)
ensureDir(base + "/config-repo")
ensureDir(base + "/syncthing/config")
ensureDir(base + "/syncthing/db")
ensureDir(base + "/sync/notes")
ensureDir(base + "/sync/documents")
ensureDir(base + "/sync/dotfiles")
ensureDir(base + "/restic/cache")
ensureDir(base + "/server")
Console.print(statusIcon(StatusOk) + " Directories created")
}
// =============================================================================
// Welcome flow
// =============================================================================
fn showWelcome(): Unit with {Console, Process} = {
Console.print("")
Console.print(" Welcome to grapho")
Console.print("")
Console.print(" grapho manages your data across all your devices:")
Console.print("")
Console.print(" * Config - Your machine setup, restored anywhere")
Console.print(" * Sync - Notes & docs synced across devices")
Console.print(" * Backup - Snapshots you can restore anytime")
Console.print(" * Server - Large files on your server")
Console.print("")
Console.print(" To get started, run:")
Console.print("")
Console.print(" grapho setup # Interactive setup wizard")
Console.print(" grapho init <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} = {
// Check if initialized
if isInitialized() == false then {
showWelcome()
} else {
let configOk = true
let syncOk = graphoSyncthingRunning()
let backupOk = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null") |> isActive
let folders = if syncOk then getSyncFolderCount() else "0"
let devices = if syncOk then getSyncDeviceCount() else "0"
// Determine issues
if syncOk == false || backupOk == false then {
if syncOk == false && backupOk == false then {
Console.print(statusEmoji(StatusWarn) + " Warnings")
Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho")
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init")
} else if syncOk == false then {
Console.print(statusEmoji(StatusWarn) + " Sync not running")
Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho")
} else {
Console.print(statusEmoji(StatusWarn) + " Backup timer inactive")
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init")
}
} else {
Console.print(statusEmoji(StatusOk) + " All systems healthy (" + folders + " folders, " + devices + " devices, backup active)")
}
}
}
// =============================================================================
// Status dashboard
// =============================================================================
fn showStatus(): Unit with {Console, Process} = {
Console.print("grapho status")
Console.print("")
if isInitialized() == false then {
Console.print(statusIcon(StatusNone) + " grapho not initialized")
Console.print(" Run: grapho setup")
Console.print("")
} else {
// Config
let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no")
if hasRepo |> isYes then {
let repoRemote = execQuiet("cd " + graphoDir() + "/config-repo && git remote get-url origin 2>/dev/null || echo local")
Console.print(statusIcon(StatusOk) + " config: " + repoRemote)
} else {
Console.print(statusIcon(StatusNone) + " config: no repo linked")
Console.print(" Run: grapho init <repo-url>")
}
// Sync
if graphoSyncthingRunning() then {
let folders = getSyncFolderCount()
let devices = getSyncDeviceCount()
Console.print(statusIcon(StatusOk) + " sync: " + folders + " folders, " + devices + " devices")
} else {
Console.print(statusIcon(StatusWarn) + " sync: not running")
Console.print(" Run: grapho sync setup")
}
// Backup
let timerActive = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null")
if timerActive |> isActive then {
Console.print(statusIcon(StatusOk) + " backup: timer active")
} else {
Console.print(statusIcon(StatusWarn) + " backup: timer inactive")
Console.print(" Run: grapho backup init")
}
// Server
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
Console.print(statusIcon(StatusOk) + " server: mounted")
} else {
Console.print(statusIcon(StatusNone) + " server: not configured")
}
}
Console.print("")
}
// =============================================================================
// Doctor command
// =============================================================================
fn showDoctor(): Unit with {Console, Process} = {
Console.print("grapho doctor")
Console.print("")
// Check nix
if hasCommand("nix") then {
let nixVer = execQuiet("nix --version")
Console.print(statusIcon(StatusOk) + " nix: " + nixVer)
} else {
Console.print(statusIcon(StatusErr) + " nix: not found")
Console.print(" Install: https://nixos.org/download")
}
// Check git
if hasCommand("git") then {
let gitVer = execQuiet("git --version | cut -d' ' -f3")
Console.print(statusIcon(StatusOk) + " git: " + gitVer)
} else {
Console.print(statusIcon(StatusErr) + " git: not found")
}
// Check syncthing
if hasCommand("syncthing") then {
if graphoSyncthingRunning() then {
Console.print(statusIcon(StatusOk) + " syncthing: running on port 8385")
} else {
Console.print(statusIcon(StatusWarn) + " syncthing: installed but not running")
Console.print(" Fix: grapho sync setup")
}
} else {
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
}
// Check restic
if hasCommand("restic") then {
Console.print(statusIcon(StatusOk) + " restic: installed")
} else {
Console.print(statusIcon(StatusErr) + " restic: not installed")
}
// Check age
if hasCommand("age") then {
let hasKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
if hasKey |> isYes then {
Console.print(statusIcon(StatusOk) + " age: key exists")
} else {
Console.print(statusIcon(StatusWarn) + " age: no key at " + graphoDir() + "/age-key.txt")
Console.print(" Fix: grapho setup")
}
} else {
Console.print(statusIcon(StatusErr) + " age: not installed")
}
// Directory structure
Console.print("")
Console.print("Directory structure:")
let hasGraphoDir = execQuiet("test -d " + graphoDir() + " && echo yes || echo no")
if hasGraphoDir |> isYes then {
Console.print(statusIcon(StatusOk) + " " + graphoDir())
checkDir("config-repo")
checkDir("syncthing")
checkDir("sync")
checkDir("restic")
checkDir("server")
} else {
Console.print(statusIcon(StatusErr) + " " + graphoDir() + " does not exist")
Console.print(" Fix: grapho setup")
}
Console.print("")
}
fn checkDir(dir: String): Unit with {Console, Process} = {
let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no")
if exists |> isYes then
Console.print(" " + statusIcon(StatusOk) + " " + dir + "/")
else
Console.print(" " + statusIcon(StatusNone) + " " + dir + "/ (missing)")
}
// =============================================================================
// Help
// =============================================================================
fn showHelp(): Unit with {Console, Process} = {
Console.print("grapho - Personal Data Infrastructure")
Console.print("")
Console.print("Usage:")
Console.print(" grapho Health check (or welcome if new)")
Console.print(" grapho init <repo-url> Initialize from config repo")
Console.print(" grapho setup Interactive setup wizard")
Console.print("")
Console.print("Sync (Type 2 - Real-time bidirectional):")
Console.print(" grapho sync Trigger sync")
Console.print(" grapho sync status Show sync status")
Console.print(" grapho sync setup Configure Syncthing")
Console.print("")
Console.print("Backup (Type 3 - Periodic snapshots):")
Console.print(" grapho backup Run backup now")
Console.print(" grapho backup list List snapshots")
Console.print(" grapho backup init Configure backup repository")
Console.print("")
Console.print("Server (Type 4 - Central storage):")
Console.print(" grapho server Show server status")
Console.print("")
Console.print("Status:")
Console.print(" grapho status Full status dashboard")
Console.print(" grapho doctor Diagnose issues")
Console.print(" grapho help Show this help")
Console.print("")
Console.print("Data directory: " + graphoDir())
Console.print("")
}
// =============================================================================
// Main
// =============================================================================
fn main(): Unit with {Console, Process} = {
let args = Process.args()
let cmd = match List.get(args, 1) {
Some(c) => c,
None => ""
}
let subcmd = match List.get(args, 2) {
Some(s) => s,
None => ""
}
match cmd {
"" => showHealthCheck(),
"init" => doInit(subcmd),
"setup" => doSetup(),
"sync" => {
match subcmd {
"status" => doSyncStatus(),
"setup" => doSyncSetup(),
"" => doSync(),
_ => doSyncStatus()
}
},
"backup" => {
match subcmd {
"init" => doBackupInit(),
"list" => doBackupList(),
"" => doBackup(),
_ => doBackup()
}
},
"server" => doServerStatus(),
"status" => showStatus(),
"doctor" => showDoctor(),
"help" => showHelp(),
"-h" => showHelp(),
"--help" => showHelp(),
_ => showHelp()
}
}
let result = run main() with {}