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:
2026-02-16 06:12:58 -05:00
parent 63fedfb525
commit 117e6af528
5 changed files with 984 additions and 293 deletions

View File

@@ -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: // Usage:
// grapho One-line health check (default) // grapho Health check (or welcome if uninitialized)
// grapho status Full status dashboard // grapho init <repo-url> Initialize from config repo
// grapho status -v Verbose status with all details // grapho setup Interactive setup wizard
// grapho doctor Diagnose issues and suggest fixes // grapho sync Sync all folders
// grapho sync Sync all (nb + syncthing) // grapho sync status Show sync status
// grapho backup Run backup now // grapho backup Run backup now
// grapho backup list List snapshots
// grapho status Full status dashboard
// grapho doctor Diagnose issues
// grapho help Show help // grapho help Show help
// grapho --json Machine-readable JSON output
// ============================================================================= // =============================================================================
// Types // Types
@@ -58,70 +66,396 @@ fn homeDir(): String with {Process} =
None => "/tmp" 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} = { fn isInitialized(): Bool with {Process} = {
let result = Process.exec("syncthing cli show system 2>/dev/null | jq -r '.myID' 2>/dev/null || true") let result = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
String.length(String.trim(result)) > 10 result |> isYes
} }
fn getSyncthingUptime(): String with {Process} = fn ensureDir(path: String): Unit with {Process} = {
Process.exec("syncthing cli show system 2>/dev/null | jq -r '.uptime // 0' 2>/dev/null || echo 0") let ignore = Process.exec("mkdir -p " + path)
()
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"))
// ============================================================================= // =============================================================================
// 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} = { fn showHealthCheck(): Unit with {Console, Process} = {
let hasIssues = false // Check if initialized
let hasWarnings = false if isInitialized() == false then {
let summary = "" showWelcome()
// 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 ()
} else { } 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,97 +463,54 @@ fn showHealthCheck(): Unit with {Console, Process} = {
// Status dashboard // Status dashboard
// ============================================================================= // =============================================================================
fn showStatus(verbose: Bool): Unit with {Console, Process} = { fn showStatus(): Unit with {Console, Process} = {
Console.print("grapho status") Console.print("grapho status")
Console.print("") Console.print("")
// nb if isInitialized() == false then {
if hasCommand("nb") then { Console.print(statusIcon(StatusNone) + " grapho not initialized")
let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l") Console.print(" Run: grapho setup")
Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks") Console.print("")
if verbose then {
let notebooks = execQuiet("nb notebooks --names")
if String.length(notebooks) > 0 then {
let lines = String.lines(notebooks)
printNotebooks(lines)
} else ()
} else ()
} else { } else {
Console.print(statusIcon(StatusErr) + " nb: not installed") // Config
Console.print(" Fix: nix develop") 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>")
} }
// Syncthing // Sync
if hasCommand("syncthing") then { if graphoSyncthingRunning() then {
if syncthingRunning() then { let folders = getSyncFolderCount()
let folders = getSyncthingFolderCount() let devices = getSyncDeviceCount()
let devices = getSyncthingDeviceCount() Console.print(statusIcon(StatusOk) + " sync: " + folders + " folders, " + devices + " devices")
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 ()
} else { } else {
Console.print(statusIcon(StatusWarn) + " syncthing: not running") Console.print(statusIcon(StatusWarn) + " sync: not running")
Console.print(" Fix: systemctl --user start syncthing") Console.print(" Run: grapho sync setup")
}
} else {
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
Console.print(" Fix: nix develop")
} }
// Backup // Backup
if hasCommand("restic") then { let timerActive = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null")
let timerActive = execQuiet("systemctl is-active restic-backup.timer") if timerActive |> isActive then {
if timerActive == "active" then {
Console.print(statusIcon(StatusOk) + " backup: timer active") Console.print(statusIcon(StatusOk) + " backup: timer active")
} else { } else {
Console.print(statusIcon(StatusWarn) + " backup: timer inactive") 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 { } else {
Console.print(statusIcon(StatusErr) + " backup: restic not installed") Console.print(statusIcon(StatusNone) + " server: not configured")
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 => ()
} }
// ============================================================================= // =============================================================================
@@ -229,8 +520,6 @@ fn printLinesIndented(lines: List<String>): Unit with {Console} =
fn showDoctor(): Unit with {Console, Process} = { fn showDoctor(): Unit with {Console, Process} = {
Console.print("grapho doctor") Console.print("grapho doctor")
Console.print("") Console.print("")
Console.print("Checking system health...")
Console.print("")
// Check nix // Check nix
if hasCommand("nix") then { if hasCommand("nix") then {
@@ -249,166 +538,97 @@ fn showDoctor(): Unit with {Console, Process} = {
Console.print(statusIcon(StatusErr) + " git: not found") 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 // Check syncthing
if hasCommand("syncthing") then { if hasCommand("syncthing") then {
if syncthingRunning() then { if graphoSyncthingRunning() then {
let folders = getSyncthingFolderCount() Console.print(statusIcon(StatusOk) + " syncthing: running on port 8385")
Console.print(statusIcon(StatusOk) + " syncthing: running (" + folders + " folders)")
} else { } else {
Console.print(statusIcon(StatusWarn) + " syncthing: not running") Console.print(statusIcon(StatusWarn) + " syncthing: installed but not running")
Console.print(" Fix: systemctl --user start syncthing") Console.print(" Fix: grapho sync setup")
} }
} else { } else {
Console.print(statusIcon(StatusErr) + " syncthing: not installed") Console.print(statusIcon(StatusErr) + " syncthing: not installed")
Console.print(" Fix: nix develop")
} }
// Check backup // Check restic
if hasCommand("restic") then { if hasCommand("restic") then {
let timerActive = execQuiet("systemctl is-active restic-backup.timer") Console.print(statusIcon(StatusOk) + " restic: installed")
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")
}
} else { } else {
Console.print(statusIcon(StatusErr) + " restic: not installed") Console.print(statusIcon(StatusErr) + " restic: not installed")
Console.print(" Fix: nix develop")
} }
// Additional checks // Check age
Console.print("") if hasCommand("age") then {
Console.print("Additional checks:") 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")
}
// Check age key // Directory structure
let ageKey = execQuiet("test -f ~/.config/sops/age/keys.txt && echo yes") Console.print("")
if ageKey == "yes" then Console.print("Directory structure:")
Console.print(statusIcon(StatusOk) + " Age encryption key exists") let hasGraphoDir = execQuiet("test -d " + graphoDir() + " && echo yes || echo no")
else { if hasGraphoDir |> isYes then {
Console.print(statusIcon(StatusWarn) + " No age key") Console.print(statusIcon(StatusOk) + " " + graphoDir())
Console.print(" Fix: age-keygen -o ~/.config/sops/age/keys.txt") 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("") Console.print("")
Console.print("Run 'grapho status -v' for detailed component info")
} }
// ============================================================================= fn checkDir(dir: String): Unit with {Console, Process} = {
// JSON output let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no")
// ============================================================================= if exists |> isYes then
Console.print(" " + statusIcon(StatusOk) + " " + dir + "/")
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 else
Console.print(statusIcon(StatusOk) + " nb synced") Console.print(" " + statusIcon(StatusNone) + " " + dir + "/ (missing)")
} 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")
} else {
Console.print(statusIcon(StatusWarn) + " No systemd backup service configured")
Console.print(" Configure in modules/backup.nix")
}
} else {
Console.print(statusIcon(StatusErr) + " restic not installed")
}
} }
// ============================================================================= // =============================================================================
// Help // Help
// ============================================================================= // =============================================================================
fn showHelp(): Unit with {Console} = { fn showHelp(): Unit with {Console, Process} = {
Console.print("grapho - Personal Data Infrastructure") Console.print("grapho - Personal Data Infrastructure")
Console.print("") Console.print("")
Console.print("Usage:") Console.print("Usage:")
Console.print(" grapho Health check (one-liner)") Console.print(" grapho Health check (or welcome if new)")
Console.print(" grapho status Component status") Console.print(" grapho init <repo-url> Initialize from config repo")
Console.print(" grapho status -v Verbose status with details") Console.print(" grapho setup Interactive setup wizard")
Console.print(" grapho doctor Diagnose issues and fixes") Console.print("")
Console.print(" grapho sync Sync all (nb + syncthing)") 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 Run backup now")
Console.print(" grapho --json Machine-readable output (key=value format)") 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(" grapho help Show this help")
Console.print("") Console.print("")
Console.print("Quick start:") Console.print("Data directory: " + graphoDir())
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("") 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, Some(c) => c,
None => "" None => ""
} }
match cmd {
"" => showHealthCheck(),
"status" => {
let subcmd = match List.get(args, 2) { let subcmd = match List.get(args, 2) {
Some(s) => s, Some(s) => s,
None => "" None => ""
} }
match cmd {
"" => showHealthCheck(),
"init" => doInit(subcmd),
"setup" => doSetup(),
"sync" => {
match subcmd { match subcmd {
"-v" => showStatus(true), "status" => doSyncStatus(),
"--verbose" => showStatus(true), "setup" => doSyncSetup(),
"verbose" => showStatus(true), "" => doSync(),
_ => showStatus(false) _ => doSyncStatus()
} }
}, },
"backup" => {
match subcmd {
"init" => doBackupInit(),
"list" => doBackupList(),
"" => doBackup(),
_ => doBackup()
}
},
"server" => doServerStatus(),
"status" => showStatus(),
"doctor" => showDoctor(), "doctor" => showDoctor(),
"sync" => doSync(),
"backup" => doBackup(),
"help" => showHelp(), "help" => showHelp(),
"-h" => showHelp(), "-h" => showHelp(),
"--help" => showHelp(), "--help" => showHelp(),
"--json" => showJson(),
"-j" => showJson(),
"json" => showJson(),
_ => showHelp() _ => showHelp()
} }
} }

View File

@@ -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<String>): 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 ## Suggestions for Lux
1. **Add escape sequence support** - At minimum `\"`, `\\`, `\n`, `\t` 1. **Add escape sequence support** - At minimum `\"`, `\\`, `\n`, `\t`
2. **Fix String.fromChar** to return String, not Int 2. **Fix String.fromChar** to return String, not Int
3. **Add raw string literals** - Something like `r"..."` or `'''...'''` for shell commands 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 5. **Support record type literals** matching their declared type
6. **Add Int.parse and Float.parse** for string-to-number conversion 6. **Add Int.parse and Float.parse** for string-to-number conversion
7. **Consider a heredoc syntax** for multi-line strings with special characters 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 3. **Quotes in output:** Avoided entirely or generated via shell
4. **Structured types:** Using individual variables instead of records 4. **Structured types:** Using individual variables instead of records
5. **Numeric parsing:** Keeping counts as strings throughout 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

View File

@@ -14,6 +14,11 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
lux = {
url = "path:/home/blu/src/lux";
inputs.nixpkgs.follows = "nixpkgs";
};
# Optional: Neovim distribution # Optional: Neovim distribution
# nixvim = { # nixvim = {
# url = "github:nix-community/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 let
# Supported systems # Supported systems
supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
@@ -37,6 +42,7 @@
# Shared modules for all hosts # Shared modules for all hosts
sharedModules = [ sharedModules = [
./modules/grapho.nix
./modules/nb.nix ./modules/nb.nix
./modules/syncthing.nix ./modules/syncthing.nix
./modules/backup.nix ./modules/backup.nix
@@ -60,6 +66,39 @@
}; };
in { 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 # NixOS configurations
# Uncomment and customize for your hosts: # Uncomment and customize for your hosts:
# #
@@ -132,7 +171,9 @@
default = pkgs.mkShell { default = pkgs.mkShell {
name = "unsbs-dev"; name = "unsbs-dev";
packages = with pkgs; [ packages = [
lux.packages.${system}.default
] ++ (with pkgs; [
# Tier 2: Notes & Sync # Tier 2: Notes & Sync
nb # Notebook CLI nb # Notebook CLI
syncthing # File sync syncthing # File sync
@@ -159,7 +200,7 @@
# Nix tools # Nix tools
nil # Nix LSP nil # Nix LSP
nixpkgs-fmt # Nix formatter nixpkgs-fmt # Nix formatter
]; ]);
shellHook = '' shellHook = ''
printf '\033[1m%s\033[0m\n' "Ultimate Notetaking, Sync & Backup System" printf '\033[1m%s\033[0m\n' "Ultimate Notetaking, Sync & Backup System"
@@ -175,6 +216,7 @@
# Export modules for use in other flakes # Export modules for use in other flakes
nixosModules = { nixosModules = {
grapho = import ./modules/grapho.nix;
nb = import ./modules/nb.nix; nb = import ./modules/nb.nix;
syncthing = import ./modules/syncthing.nix; syncthing = import ./modules/syncthing.nix;
backup = import ./modules/backup.nix; backup = import ./modules/backup.nix;

BIN
grapho

Binary file not shown.

341
modules/grapho.nix Normal file
View File

@@ -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
];
};
}