Files
grapho/cli/grapho.lux
Brandon Lucas 13fe22a804 Improve server mount help with detailed instructions
- Add SSHFS section with common options explained
- Add NFS mount example
- Reference NixOS wiki for declarative mounts
- Show unmount hint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 22:58:19 -05:00

1315 lines
50 KiB
Plaintext

// grapho - Your personal data, everywhere
//
// A modern CLI for managing your digital life across all devices.
// =============================================================================
// Types
// =============================================================================
type Status =
| Ok
| Warn
| Err
| None
// =============================================================================
// Color output helpers (using shell printf for ANSI codes)
// =============================================================================
fn print(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '%s\\n' \"" + msg + "\" >&2")
()
}
fn printColor(color: String, msg: String): Unit with {Process} = {
let code = match color {
"green" => "32",
"yellow" => "33",
"red" => "31",
"blue" => "34",
"magenta" => "35",
"cyan" => "36",
"bold" => "1",
"dim" => "2",
_ => "0"
}
let ignore = Process.exec("printf '\\033[" + code + "m%s\\033[0m\\n' \"" + msg + "\" >&2")
()
}
fn printBold(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[1m%s\\033[0m\\n' \"" + msg + "\" >&2")
()
}
fn printSuccess(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[32m✓\\033[0m %s\\n' \"" + msg + "\" >&2")
()
}
fn printWarn(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[33m⚠\\033[0m %s\\n' \"" + msg + "\" >&2")
()
}
fn printErr(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[31m✗\\033[0m %s\\n' \"" + msg + "\" >&2")
()
}
fn printInfo(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[36m→\\033[0m %s\\n' \"" + msg + "\" >&2")
()
}
fn printDim(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[2m%s\\033[0m\\n' \"" + msg + "\" >&2")
()
}
fn printBox(title: String, content: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[36m┌─\\033[1m %s \\033[0m\\033[36m─┐\\033[0m\\n' \"" + title + "\" >&2")
let ignore2 = Process.exec("printf '\\033[36m│\\033[0m %s\\n' \"" + content + "\" >&2")
let ignore3 = Process.exec("printf '\\033[36m└──────────────────────────────────────────┘\\033[0m\\n' >&2")
()
}
fn spinner(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[36m⠋\\033[0m %s\\r' \"" + msg + "\" >&2")
()
}
fn clearLine(): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[2K\\r' >&2")
()
}
// =============================================================================
// Modern output helpers (gh/cargo style)
// =============================================================================
// Print a step with aligned status: " Creating directories done"
fn printStep(label: String, status: String): Unit with {Process} = {
// Pad label to 24 chars for alignment
let ignore = Process.exec("printf ' %-24s \\033[32m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2")
()
}
fn printStepPending(label: String): Unit with {Process} = {
let ignore = Process.exec("printf ' %-24s \\033[2mrunning...\\033[0m\\r' \"" + label + "\" >&2")
()
}
fn printStepWarn(label: String, status: String): Unit with {Process} = {
let ignore = Process.exec("printf ' %-24s \\033[33m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2")
()
}
fn printStepErr(label: String, status: String): Unit with {Process} = {
let ignore = Process.exec("printf ' %-24s \\033[31m%s\\033[0m\\n' \"" + label + "\" \"" + status + "\" >&2")
()
}
// Print a status line: " sync Running 3 folders, 2 devices"
fn printStatusLine(name: String, state: String, detail: String): Unit with {Process} = {
let stateColor = match state {
"Running" => "32",
"Ready" => "32",
"Mounted" => "32",
"Configured" => "32",
"Stopped" => "33",
"Not running" => "33",
"Not mounted" => "2",
"Not configured" => "2",
_ => "0"
}
let ignore = Process.exec("printf ' \\033[1m%-10s\\033[0m \\033[" + stateColor + "m%-16s\\033[0m \\033[2m%s\\033[0m\\n' \"" + name + "\" \"" + state + "\" \"" + detail + "\" >&2")
()
}
// Ask for confirmation: returns true if user confirms
fn askConfirm(question: String, defaultYes: Bool): Bool with {Process} = {
let prompt = if defaultYes then " [Y/n] " else " [y/N] "
let ignore = Process.exec("printf '%s%s' \"" + question + "\" \"" + prompt + "\" >&2")
// Read user input and convert to lowercase
let response = String.trim(Process.exec("head -n1"))
let lower = execQuiet("echo '" + response + "' | tr A-Z a-z")
if String.length(lower) == 0 then
defaultYes
else if lower == "y" || lower == "yes" then
true
else if lower == "n" || lower == "no" then
false
else
defaultYes
}
// Print section header
fn printHeader(title: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[1m%s\\033[0m\\n\\n' \"" + title + "\" >&2")
()
}
// Print a hint/suggestion
fn printHint(msg: String): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[2m%s\\033[0m\\n' \"" + msg + "\" >&2")
()
}
// Print a command suggestion
fn printCmd(cmd: String): Unit with {Process} = {
let ignore = Process.exec("printf ' \\033[32m\\044\\033[0m %s\\n' \"" + cmd + "\" >&2")
()
}
// Print a QR code for easy scanning (e.g., device IDs)
fn printQR(data: String): Unit with {Process} = {
if hasCommand("qrencode") then {
// Generate small terminal QR code and redirect to stderr
let ignore = Process.exec("qrencode -t ANSIUTF8 -m 1 '" + data + "' -o - >&2")
()
} else {
// Fallback: just show the data
printHint("(Install qrencode for QR codes)")
}
}
// =============================================================================
// 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"
}
fn isYes(s: String): Bool = String.contains(s, "yes")
fn isNo(s: String): Bool = String.contains(s, "no")
fn isActive(s: String): Bool = String.contains(s, "active")
fn graphoDir(): String with {Process} = homeDir() + "/.config/grapho"
fn graphoConfig(): String with {Process} = graphoDir() + "/grapho.toml"
fn stateDb(): String with {Process} = graphoDir() + "/state.db"
// =============================================================================
// Initialization
// =============================================================================
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)
()
}
// =============================================================================
// State tracking
// =============================================================================
fn initStateDb(): Unit with {Process} = {
let db = stateDb()
let schema = "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, timestamp TEXT, type TEXT, message TEXT);"
let ignore = Process.exec("sqlite3 " + db + " \"" + schema + "\" 2>/dev/null || true")
()
}
fn logEvent(eventType: String, message: String): Unit with {Process} = {
let db = stateDb()
let timestamp = execQuiet("date -Iseconds")
let sql = "INSERT INTO events (timestamp, type, message) VALUES ('" + timestamp + "', '" + eventType + "', '" + message + "');"
let ignore = Process.exec("sqlite3 " + db + " \"" + sql + "\" 2>/dev/null || true")
()
}
fn getRecentEvents(limit: String): String with {Process} = {
let db = stateDb()
execQuiet("sqlite3 -separator ' | ' " + db + " \"SELECT timestamp, type, message FROM events ORDER BY id DESC LIMIT " + limit + ";\" 2>/dev/null")
}
// =============================================================================
// Syncthing helpers
// =============================================================================
fn graphoSyncthingRunning(): Bool with {Process} = {
let result = execQuiet("curl -s http://127.0.0.1:8385/rest/system/status 2>/dev/null | head -c 1")
String.length(result) > 0
}
fn getSyncFolderCount(): String with {Process} =
execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0")
fn getSyncDeviceCount(): String with {Process} =
execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0")
// =============================================================================
// Backup helpers
// =============================================================================
fn getBackupRepo(): String with {Process} =
execQuiet("cat " + graphoDir() + "/restic/repository 2>/dev/null")
fn getBackupPassword(): String with {Process} =
graphoDir() + "/restic/password"
// =============================================================================
// Logo and branding
// =============================================================================
fn showLogo(): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[36m __ _ _ __ __ _ _ __ | |__ ___\\n' >&2")
let ignore2 = Process.exec("printf ' / _ | __/ _ | _ \\\\ | _ \\\\ / _ \\\\\\n' >&2")
let ignore3 = Process.exec("printf ' | (_| | | | (_| | |_) || | | || (_) |\\n' >&2")
let ignore4 = Process.exec("printf ' \\\\__, |_| \\\\__,_| .__/ |_| |_| \\\\___/\\n' >&2")
let ignore5 = Process.exec("printf ' __/ | | |\\n' >&2")
let ignore6 = Process.exec("printf ' |___/ |_|\\n\\033[0m' >&2")
print("")
printDim(" Your personal data, everywhere.")
print("")
}
fn showMiniLogo(): Unit with {Process} = {
let ignore = Process.exec("printf '\\033[1;36mgrapho\\033[0m ' >&2")
()
}
// =============================================================================
// Welcome flow (Interactive)
// =============================================================================
fn showInteractiveWelcome(): Unit with {Process} = {
print("")
showLogo()
print("")
printBold("Welcome to grapho!")
print("")
printHint("grapho manages your data across all your devices:")
print("")
let ignore = Process.exec("printf ' \\033[1mconfig\\033[0m Machine configuration (git-managed)\\n' >&2")
let ignore2 = Process.exec("printf ' \\033[1msync\\033[0m Notes & documents (real-time sync)\\n' >&2")
let ignore3 = Process.exec("printf ' \\033[1mbackup\\033[0m Point-in-time snapshots\\n' >&2")
let ignore4 = Process.exec("printf ' \\033[1mserver\\033[0m Large files on your server\\n' >&2")
print("")
// Interactive choice
printBold("What would you like to do?")
print("")
let ignore5 = Process.exec("printf ' \\033[36m1\\033[0m Set up a new system\\n' >&2")
let ignore6 = Process.exec("printf ' \\033[36m2\\033[0m Join an existing setup (import)\\n' >&2")
let ignore7 = Process.exec("printf ' \\033[36m3\\033[0m Just explore (show help)\\n' >&2")
print("")
let ignore8 = Process.exec("printf 'Choice [1]: ' >&2")
let choice = String.trim(Process.exec("head -n1"))
match choice {
"2" => {
print("")
printBold("Import from archive")
print("")
printHint("Provide a grapho export archive:")
printCmd("grapho import grapho-export.tar.zst")
print("")
},
"3" => showHelp(),
_ => {
print("")
doSetup()
}
}
}
// Non-interactive welcome for scripts
fn showWelcome(): Unit with {Process} = {
print("")
showLogo()
print("")
printBold("Not initialized. Run:")
printCmd("grapho setup")
print("")
}
// =============================================================================
// Setup wizard
// =============================================================================
fn createDirectories(): Unit with {Process} = {
let base = graphoDir()
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")
}
fn doSetup(): Unit with {Process} = {
print("")
printHeader("Setting up grapho")
// Step 1: Directories
printStepPending("Creating directories")
createDirectories()
printStep("Creating directories", "done")
// Step 2: Config
printStepPending("Writing configuration")
let hostname = execQuiet("hostname")
let configContent = "[grapho]\nversion = 1\ndevice_name = \"" + hostname + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\n\n[backup]\nschedule = \"hourly\"\n"
let writeCmd = "cat > " + graphoConfig() + " << 'EOF'\n" + configContent + "EOF"
let ignore = Process.exec(writeCmd)
printStep("Writing configuration", "done")
// Step 3: Age key
printStepPending("Setting up encryption")
let hasAgeKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
if hasAgeKey |> isYes then
printStep("Setting up encryption", "exists")
else {
let ignore2 = Process.exec("age-keygen -o " + graphoDir() + "/age-key.txt 2>&1 || true")
printStep("Setting up encryption", "done")
}
// Step 4: Syncthing
printStepPending("Initializing sync")
if hasCommand("syncthing") then {
let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no")
if hasConfig |> isNo then {
let ignore3 = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1")
printStep("Initializing sync", "done")
} else {
printStep("Initializing sync", "ready")
}
} else {
printStepWarn("Initializing sync", "not installed")
}
// State DB
if hasCommand("sqlite3") then {
initStateDb()
logEvent("setup", "grapho initialized")
} else ()
print("")
printSuccess("Setup complete!")
print("")
printHint("Your data lives in: " + graphoDir())
print("")
printBold("Next steps:")
printCmd("grapho sync setup # Pair with other devices")
printCmd("grapho backup init # Configure backups")
print("")
}
// =============================================================================
// Init from repo
// =============================================================================
fn doInit(repoUrl: String): Unit with {Process} = {
print("")
if String.length(repoUrl) == 0 then {
printHeader("Initialize from repository")
printHint("Clone a config repository to set up this machine:")
print("")
printCmd("grapho init https://github.com/you/grapho-config")
print("")
} else {
printHeader("Initializing from repository")
printStepPending("Creating directories")
createDirectories()
printStep("Creating directories", "done")
printStepPending("Cloning repository")
let cloneResult = Process.exec("git clone " + repoUrl + " " + graphoDir() + "/config-repo 2>&1")
if String.contains(cloneResult, "fatal") then {
printStepErr("Cloning repository", "failed")
print("")
printDim(cloneResult)
} else {
printStep("Cloning repository", "done")
let hasFlake = execQuiet("test -f " + graphoDir() + "/config-repo/flake.nix && echo yes || echo no")
if hasFlake |> isYes then {
printStep("Found flake.nix", "yes")
print("")
printBold("Apply your config:")
printCmd("cd " + graphoDir() + "/config-repo")
printCmd("sudo nixos-rebuild switch --flake .")
} else {
printStepWarn("Found flake.nix", "no")
}
logEvent("init", "Cloned " + repoUrl)
}
print("")
}
}
// =============================================================================
// Sync commands
// =============================================================================
fn doSyncStatus(): Unit with {Process} = {
print("")
if hasCommand("syncthing") == false then {
printErr("Syncthing not installed")
printHint("Install syncthing and run 'grapho sync setup'")
} else if graphoSyncthingRunning() then {
let folders = getSyncFolderCount()
let devices = getSyncDeviceCount()
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
print("")
let ignore = Process.exec("printf ' Web UI: \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2")
} else {
printStatusLine("sync", "Stopped", "")
print("")
printCmd("grapho sync start")
}
print("")
}
fn doSyncSetup(): Unit with {Process} = {
print("")
if hasCommand("syncthing") == false then {
printErr("Syncthing not installed")
printHint("Add to NixOS: services.grapho.enable = true;")
print("")
} else {
printHeader("Setting up sync")
let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no")
if hasConfig |> isNo then {
printStepPending("Creating config")
let ignore = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1")
printStep("Creating config", "done")
} else {
printStep("Creating config", "exists")
}
// Start if not running
if graphoSyncthingRunning() == false then {
printStepPending("Starting daemon")
let ignore = Process.exec("syncthing serve --home=" + graphoDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &")
let ignore2 = Process.exec("sleep 2")
printStep("Starting daemon", "done")
} else {
printStep("Starting daemon", "running")
}
let deviceId = execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4")
if String.length(deviceId) > 10 then {
print("")
printBold("Your Device ID")
let ignore = Process.exec("printf '\\033[33m%s\\033[0m\\n' \"" + deviceId + "\" >&2")
print("")
printQR(deviceId)
print("")
printHint("Scan the QR code or share this ID to pair devices.")
logEvent("sync", "Device ID displayed")
} else {
printStepWarn("Getting device ID", "retry in a moment")
}
print("")
}
}
fn doSyncStart(): Unit with {Process} = {
print("")
if graphoSyncthingRunning() then {
printSuccess("Syncthing is already running")
let ignore = Process.exec("printf ' \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2")
} else {
printStepPending("Starting Syncthing")
let ignore = Process.exec("syncthing serve --home=" + graphoDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &")
let ignore2 = Process.exec("sleep 2")
if graphoSyncthingRunning() then {
printStep("Starting Syncthing", "done")
print("")
let ignore3 = Process.exec("printf ' Web UI: \\033[4;36mhttp://127.0.0.1:8385\\033[0m\\n' >&2")
} else {
printStepErr("Starting Syncthing", "failed")
}
}
print("")
}
fn doSync(): Unit with {Process} = {
print("")
if graphoSyncthingRunning() then {
let ignore = Process.exec("syncthing cli --home=" + graphoDir() + "/syncthing/config scan 2>/dev/null || true")
printSuccess("Sync triggered")
logEvent("sync", "Manual sync")
} else {
printWarn("Syncthing not running")
printCmd("grapho sync start")
}
print("")
}
// =============================================================================
// Backup commands
// =============================================================================
fn doBackupInit(repoArg: String): Unit with {Process} = {
print("")
if hasCommand("restic") == false then {
printErr("Restic not installed")
print("")
} else if String.length(repoArg) == 0 then {
printHeader("Initialize backup repository")
printHint("Provide a repository location:")
print("")
printCmd("grapho backup init /path/to/backup")
printCmd("grapho backup init sftp:server:/backups")
printCmd("grapho backup init b2:bucket:path")
print("")
} else {
printHeader("Initializing backup")
printHint("Repository: " + repoArg)
print("")
let passwordFile = graphoDir() + "/restic/password"
let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no")
if hasPassword |> isNo then {
printStepPending("Generating password")
let ignore = Process.exec("head -c 32 /dev/urandom | base64 > " + passwordFile + " && chmod 600 " + passwordFile)
printStep("Generating password", "done")
} else {
printStep("Generating password", "exists")
}
printStepPending("Creating repository")
let initResult = Process.exec("restic -r " + repoArg + " --password-file " + passwordFile + " init 2>&1")
if String.contains(initResult, "created restic repository") then {
printStep("Creating repository", "done")
let ignore = Process.exec("echo '" + repoArg + "' > " + graphoDir() + "/restic/repository")
printStep("Saving config", "done")
print("")
printSuccess("Backup initialized!")
printCmd("grapho backup # Create your first snapshot")
logEvent("backup", "Initialized " + repoArg)
} else if String.contains(initResult, "already initialized") then {
printStep("Creating repository", "exists")
let ignore = Process.exec("echo '" + repoArg + "' > " + graphoDir() + "/restic/repository")
} else {
printStepErr("Creating repository", "failed")
print("")
printDim(initResult)
}
print("")
}
}
fn doBackupList(): Unit with {Process} = {
print("")
let repo = getBackupRepo()
if String.length(repo) == 0 then {
printWarn("No backup configured")
printCmd("grapho backup init <repo>")
} else {
printHeader("Backup snapshots")
printHint("Repository: " + repo)
print("")
// Format snapshots nicely
let ignore = Process.exec("printf ' \\033[2m%-10s %-20s %s\\033[0m\\n' 'ID' 'DATE' 'PATHS' >&2")
let ignore2 = Process.exec("printf ' \\033[2m%s\\033[0m\\n' '─────────────────────────────────────────────────' >&2")
let snapshots = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots --json 2>&1 | jq -r '.[] | \" \" + .short_id + \" \" + .time[:19] + \" \" + (.paths | join(\", \"))' 2>/dev/null || echo ' No snapshots'")
print(snapshots)
}
print("")
}
fn doBackup(): Unit with {Process} = {
print("")
let repo = getBackupRepo()
if String.length(repo) == 0 then {
printWarn("No backup configured")
printCmd("grapho backup init <repo>")
} else {
printStepPending("Creating snapshot")
let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + graphoDir() + "/sync 2>&1")
if String.contains(backupResult, "snapshot") then {
let snapshotId = execQuiet("echo '" + backupResult + "' | grep -o 'snapshot [a-f0-9]*' | cut -d' ' -f2")
if String.length(snapshotId) > 0 then {
printStep("Creating snapshot", snapshotId)
} else {
printStep("Creating snapshot", "done")
}
logEvent("backup", "Backup completed")
} else {
printStepErr("Creating snapshot", "failed")
print("")
printDim(backupResult)
}
}
print("")
}
// =============================================================================
// Server commands
// =============================================================================
fn doServerStatus(): Unit with {Process} = {
print("")
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
let dfOut = execQuiet("df -h " + serverDir + " 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f3,2 | sed 's/ / \\/ /'")
printStatusLine("server", "Mounted", dfOut)
printHint(" " + serverDir)
} else {
printStatusLine("server", "Not mounted", "")
print("")
printCmd("grapho server mount")
}
print("")
}
fn doServerMount(): Unit with {Process} = {
print("")
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
printSuccess("Already mounted at " + serverDir)
} else {
printHeader("Mount server")
printHint("Mount a remote filesystem to access large files, media, and archives.")
print("")
// SSHFS section
printBold("SSHFS (recommended for most users)")
printHint("Mounts remote directories over SSH. No server config needed.")
print("")
printCmd("sshfs user@server:/path " + serverDir)
print("")
printHint("Useful options:")
print(" -o reconnect Auto-reconnect on connection loss")
print(" -o follow_symlinks Follow symbolic links")
print(" -o cache=yes Enable caching for better performance")
print("")
printHint("Example with options:")
printCmd("sshfs -o reconnect,cache=yes user@server:/data " + serverDir)
print("")
// NFS section
printBold("NFS (for local network)")
printHint("Faster than SSHFS but requires NFS server setup.")
print("")
printCmd("sudo mount -t nfs server:/export " + serverDir)
print("")
// NixOS section
printBold("NixOS declarative mount")
printHint("Add to configuration.nix for automatic mounting.")
printHint("See: nixos.wiki/wiki/SSHFS")
print("")
// Unmount hint
printHint("To unmount: grapho server unmount")
}
print("")
}
fn doServerUnmount(): Unit with {Process} = {
print("")
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
printStepPending("Unmounting")
let ignore = Process.exec("fusermount -u " + serverDir + " 2>&1 || umount " + serverDir + " 2>&1 || true")
let stillMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if stillMounted |> isNo then
printStep("Unmounting", "done")
else
printStepErr("Unmounting", "failed")
} else {
printHint("Server is not mounted")
}
print("")
}
// =============================================================================
// Export/Import
// =============================================================================
fn doExport(): Unit with {Process} = {
print("")
let timestamp = execQuiet("date +%Y-%m-%d")
let exportFile = "grapho-export-" + timestamp + ".tar.zst"
let exportPath = execQuiet("pwd") + "/" + exportFile
printStepPending("Creating archive")
let ignore = Process.exec("tar -C " + graphoDir() + " -cf - . 2>/dev/null | zstd -q > " + exportPath + " 2>&1")
let fileExists = execQuiet("test -f " + exportPath + " && echo yes || echo no")
if fileExists |> isYes then {
let fileSize = execQuiet("du -h " + exportPath + " | cut -f1")
printStep("Creating archive", fileSize)
print("")
printSuccess("Export created: " + exportFile)
print("")
printHint("Restore with:")
printCmd("grapho import " + exportFile)
logEvent("export", "Created " + exportFile)
} else {
printStepErr("Creating archive", "failed")
}
print("")
}
fn doImport(archivePath: String): Unit with {Process} = {
print("")
if String.length(archivePath) == 0 then {
printHeader("Import grapho data")
printHint("Restore from a grapho export archive:")
print("")
printCmd("grapho import grapho-export.tar.zst")
print("")
} else {
let fileExists = execQuiet("test -f " + archivePath + " && echo yes || echo no")
if fileExists |> isNo then {
printErr("File not found: " + archivePath)
print("")
} else {
// Check if there's existing data
let hasGrapho = execQuiet("test -d " + graphoDir() + " && echo yes || echo no")
if hasGrapho |> isYes then {
print("")
printWarn("This will replace your existing grapho data.")
printHint("Existing data will be moved to ~/.config/grapho.bak")
print("")
let confirmed = askConfirm("Continue?", false)
if confirmed == false then {
print("")
printHint("Import cancelled.")
print("")
} else {
print("")
printHeader("Importing")
printStepPending("Backing up existing")
let ignore = Process.exec("mv " + graphoDir() + " " + graphoDir() + ".bak 2>&1")
printStep("Backing up existing", "done")
printStepPending("Extracting archive")
let ignore2 = Process.exec("mkdir -p " + graphoDir())
let ignore3 = Process.exec("zstd -d < " + archivePath + " | tar -C " + graphoDir() + " -xf - 2>&1")
printStep("Extracting archive", "done")
printStepPending("Verifying config")
let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
if configExists |> isYes then {
printStep("Verifying config", "done")
print("")
printSuccess("Import complete.")
logEvent("import", "Imported from " + archivePath)
} else {
printStepErr("Verifying config", "failed")
print("")
printErr("Import failed - config not found in archive")
}
print("")
}
} else {
// No existing data, proceed without confirmation
printHeader("Importing")
printStepPending("Extracting archive")
let ignore = Process.exec("mkdir -p " + graphoDir())
let ignore2 = Process.exec("zstd -d < " + archivePath + " | tar -C " + graphoDir() + " -xf - 2>&1")
printStep("Extracting archive", "done")
printStepPending("Verifying config")
let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
if configExists |> isYes then {
printStep("Verifying config", "done")
print("")
printSuccess("Import complete.")
logEvent("import", "Imported from " + archivePath)
} else {
printStepErr("Verifying config", "failed")
print("")
printErr("Import failed - config not found in archive")
}
print("")
}
}
}
}
// =============================================================================
// Status dashboard
// =============================================================================
fn showStatus(): Unit with {Process} = {
print("")
if isInitialized() == false then {
printWarn("Not initialized")
printCmd("grapho setup")
} else {
printHeader("Status")
// Config
let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no")
if hasRepo |> isYes then {
let repoRemote = execQuiet("cd " + graphoDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'")
printStatusLine("config", "Linked", repoRemote)
} else {
printStatusLine("config", "Not linked", "")
}
// Sync
if graphoSyncthingRunning() then {
let folders = getSyncFolderCount()
let devices = getSyncDeviceCount()
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
} else {
printStatusLine("sync", "Not running", "")
}
// Backup
let repo = getBackupRepo()
if String.length(repo) > 0 then {
let repoShort = execQuiet("echo '" + repo + "' | sed 's/.*\\///'")
printStatusLine("backup", "Configured", repoShort)
} else {
printStatusLine("backup", "Not configured", "")
}
// Server
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
printStatusLine("server", "Mounted", serverDir)
} else {
printStatusLine("server", "Not mounted", "")
}
}
print("")
}
// =============================================================================
// Compact Status (default command when initialized)
// =============================================================================
fn showCompactStatus(): Unit with {Process} = {
let syncOk = graphoSyncthingRunning()
let hasBackup = String.length(getBackupRepo()) > 0
let folders = if syncOk then getSyncFolderCount() else "0"
let devices = if syncOk then getSyncDeviceCount() else "0"
// Header line
let ignore = Process.exec("printf '\\033[1;36mgrapho\\033[0m ' >&2")
if syncOk && hasBackup then {
let ignore2 = Process.exec("printf '\\033[32mAll systems operational\\033[0m\\n' >&2")
} else {
let ignore2 = Process.exec("printf '\\033[33mPartially configured\\033[0m\\n' >&2")
}
print("")
// Sync status
if syncOk then {
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
} else {
printStatusLine("sync", "Not running", "Run: grapho sync start")
}
// Backup status
if hasBackup then {
let snapCount = execQuiet("restic -r " + getBackupRepo() + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r 'length' 2>/dev/null || echo 0")
let lastBackup = execQuiet("restic -r " + getBackupRepo() + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r '.[-1].time[:10]' 2>/dev/null || echo 'unknown'")
printStatusLine("backup", "Ready", snapCount + " snapshots, last " + lastBackup)
} else {
printStatusLine("backup", "Not configured", "Run: grapho backup init")
}
// Server status
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
printStatusLine("server", "Mounted", serverDir)
} else {
printStatusLine("server", "Not mounted", "")
}
print("")
}
// =============================================================================
// Health check (default command)
// =============================================================================
fn showHealthCheck(): Unit with {Process} = {
// Check if this is an interactive terminal
let isTty = execQuiet("test -t 0 && echo yes || echo no")
if isInitialized() == false then {
// Interactive wizard for first run
if isTty |> isYes then
showInteractiveWelcome()
else
showWelcome()
} else {
showCompactStatus()
}
}
// =============================================================================
// Doctor
// =============================================================================
fn showDoctor(): Unit with {Process} = {
print("")
printHeader("System check")
// Check tools
let ignore = Process.exec("printf '\\033[2mDependencies:\\033[0m\\n' >&2")
if hasCommand("nix") then {
let ver = execQuiet("nix --version | cut -d' ' -f3")
printStep("nix", ver)
} else {
printStepErr("nix", "not found")
}
if hasCommand("git") then {
let ver = execQuiet("git --version | cut -d' ' -f3")
printStep("git", ver)
} else {
printStepErr("git", "not found")
}
if hasCommand("syncthing") then {
if graphoSyncthingRunning() then
printStep("syncthing", "running")
else
printStepWarn("syncthing", "installed, not running")
} else {
printStepErr("syncthing", "not found")
}
if hasCommand("restic") then {
printStep("restic", "installed")
} else {
printStepErr("restic", "not found")
}
if hasCommand("age") then {
let hasKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
if hasKey |> isYes then
printStep("age", "key exists")
else
printStepWarn("age", "no key")
} else {
printStepErr("age", "not found")
}
if hasCommand("qrencode") then {
printStep("qrencode", "installed")
} else {
printStepWarn("qrencode", "not found (QR codes disabled)")
}
// Directory check
print("")
let ignore2 = Process.exec("printf '\\033[2mDirectories:\\033[0m\\n' >&2")
checkDir("config-repo")
checkDir("syncthing")
checkDir("sync")
checkDir("restic")
checkDir("server")
print("")
}
fn checkDir(dir: String): Unit with {Process} = {
let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no")
if exists |> isYes then {
printStep(dir + "/", "exists")
} else {
printStepWarn(dir + "/", "missing")
}
}
// =============================================================================
// History
// =============================================================================
fn showHistory(): Unit with {Process} = {
print("")
if hasCommand("sqlite3") == false then {
printErr("sqlite3 not installed")
} else {
let dbExists = execQuiet("test -f " + stateDb() + " && echo yes || echo no")
if dbExists |> isYes then {
printHeader("Recent events")
let ignore = Process.exec("printf ' \\033[2m%-20s %-10s %s\\033[0m\\n' 'TIME' 'TYPE' 'DETAILS' >&2")
let ignore2 = Process.exec("printf ' \\033[2m%s\\033[0m\\n' '──────────────────────────────────────────────────' >&2")
// Format events nicely
let events = execQuiet("sqlite3 -separator ' ' " + stateDb() + " \"SELECT substr(timestamp,1,16), type, message FROM events ORDER BY id DESC LIMIT 15;\" 2>/dev/null | sed 's/^/ /'")
if String.length(events) > 0 then {
print(events)
} else {
printHint(" No events yet")
}
} else {
printHint("No history")
}
}
print("")
}
// =============================================================================
// Dashboard
// =============================================================================
fn doDashboard(): Unit with {Process} = {
// Show terminal dashboard instead of HTML
print("")
printHeader("Dashboard")
let deviceName = execQuiet("hostname")
printHint("Device: " + deviceName)
print("")
// Sync
if graphoSyncthingRunning() then {
let folders = getSyncFolderCount()
let devices = getSyncDeviceCount()
printStatusLine("sync", "Running", folders + " folders, " + devices + " devices")
} else {
printStatusLine("sync", "Stopped", "")
}
// Backup
let repo = getBackupRepo()
if String.length(repo) > 0 then {
let snapCount = execQuiet("restic -r " + repo + " --password-file " + getBackupPassword() + " snapshots --json 2>/dev/null | jq -r 'length' 2>/dev/null || echo 0")
printStatusLine("backup", "Ready", snapCount + " snapshots")
} else {
printStatusLine("backup", "Not configured", "")
}
// Server
let serverDir = graphoDir() + "/server"
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
if isMounted |> isYes then {
let dfOut = execQuiet("df -h " + serverDir + " 2>/dev/null | tail -1 | tr -s ' ' | cut -d' ' -f3,2 | sed 's/ / \\/ /'")
printStatusLine("server", "Mounted", dfOut)
} else {
printStatusLine("server", "Not mounted", "")
}
// Config
let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no")
if hasRepo |> isYes then {
let repoRemote = execQuiet("cd " + graphoDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'")
printStatusLine("config", "Linked", repoRemote)
} else {
printStatusLine("config", "Not linked", "")
}
print("")
}
// =============================================================================
// Version
// =============================================================================
fn showVersion(): Unit with {Process} = {
let ignore = Process.exec("printf 'grapho 0.2.0\\n' >&2")
()
}
// =============================================================================
// Help
// =============================================================================
fn showHelp(): Unit with {Process} = {
print("")
let ignore = Process.exec("printf '\\033[1mgrapho\\033[0m - Your personal data, everywhere\\n' >&2")
print("")
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
let ignore3 = Process.exec("printf ' grapho [COMMAND]\\n' >&2")
print("")
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
print("")
let ignore5 = Process.exec("printf ' \\033[36msetup\\033[0m Initialize grapho on this machine\\n' >&2")
let ignore6 = Process.exec("printf ' \\033[36minit\\033[0m <repo> Clone config from git repository\\n' >&2")
print("")
let ignore7 = Process.exec("printf ' \\033[36msync\\033[0m Trigger a rescan\\n' >&2")
let ignore8 = Process.exec("printf ' \\033[36msync status\\033[0m Show sync status\\n' >&2")
let ignore9 = Process.exec("printf ' \\033[36msync setup\\033[0m Initialize and show device ID\\n' >&2")
let ignore10 = Process.exec("printf ' \\033[36msync start\\033[0m Start Syncthing daemon\\n' >&2")
print("")
let ignore11 = Process.exec("printf ' \\033[36mbackup\\033[0m Create a backup snapshot\\n' >&2")
let ignore12 = Process.exec("printf ' \\033[36mbackup list\\033[0m List snapshots\\n' >&2")
let ignore13 = Process.exec("printf ' \\033[36mbackup init\\033[0m <repo> Initialize backup repository\\n' >&2")
print("")
let ignore14 = Process.exec("printf ' \\033[36mserver\\033[0m Show server mount status\\n' >&2")
let ignore15 = Process.exec("printf ' \\033[36mserver mount\\033[0m Mount remote server\\n' >&2")
let ignore16 = Process.exec("printf ' \\033[36mserver unmount\\033[0m Unmount server\\n' >&2")
print("")
let ignore17 = Process.exec("printf ' \\033[36mstatus\\033[0m Show detailed status\\n' >&2")
let ignore18 = Process.exec("printf ' \\033[36mdoctor\\033[0m Check system dependencies\\n' >&2")
let ignore19 = Process.exec("printf ' \\033[36mhistory\\033[0m Show recent events\\n' >&2")
let ignore20 = Process.exec("printf ' \\033[36mexport\\033[0m Create portable archive\\n' >&2")
let ignore21 = Process.exec("printf ' \\033[36mimport\\033[0m <file> Restore from archive\\n' >&2")
print("")
let ignore22 = Process.exec("printf '\\033[1mOPTIONS\\033[0m\\n' >&2")
let ignore23 = Process.exec("printf ' -h, --help Show this help\\n' >&2")
let ignore24 = Process.exec("printf ' -V, --version Show version\\n' >&2")
print("")
printHint("Data directory: " + graphoDir())
print("")
}
// Subcommand-specific help
fn showSyncHelp(): Unit with {Process} = {
print("")
let ignore = Process.exec("printf '\\033[1mgrapho sync\\033[0m - Real-time file synchronization\\n' >&2")
print("")
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
let ignore3 = Process.exec("printf ' grapho sync [COMMAND]\\n' >&2")
print("")
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
let ignore5 = Process.exec("printf ' (default) Trigger a rescan of all folders\\n' >&2")
let ignore6 = Process.exec("printf ' status Show Syncthing status\\n' >&2")
let ignore7 = Process.exec("printf ' setup Initialize and display device ID for pairing\\n' >&2")
let ignore8 = Process.exec("printf ' start Start Syncthing daemon\\n' >&2")
print("")
}
fn showBackupHelp(): Unit with {Process} = {
print("")
let ignore = Process.exec("printf '\\033[1mgrapho backup\\033[0m - Point-in-time snapshots\\n' >&2")
print("")
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
let ignore3 = Process.exec("printf ' grapho backup [COMMAND]\\n' >&2")
print("")
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
let ignore5 = Process.exec("printf ' (default) Create a new backup snapshot\\n' >&2")
let ignore6 = Process.exec("printf ' list List all snapshots\\n' >&2")
let ignore7 = Process.exec("printf ' init <repo> Initialize backup repository\\n' >&2")
print("")
let ignore8 = Process.exec("printf '\\033[1mEXAMPLES\\033[0m\\n' >&2")
let ignore9 = Process.exec("printf ' grapho backup init /mnt/backup\\n' >&2")
let ignore10 = Process.exec("printf ' grapho backup init sftp:user@server:/backups\\n' >&2")
let ignore11 = Process.exec("printf ' grapho backup init b2:bucket-name:path\\n' >&2")
print("")
}
fn showServerHelp(): Unit with {Process} = {
print("")
let ignore = Process.exec("printf '\\033[1mgrapho server\\033[0m - Remote server storage\\n' >&2")
print("")
let ignore2 = Process.exec("printf '\\033[1mUSAGE\\033[0m\\n' >&2")
let ignore3 = Process.exec("printf ' grapho server [COMMAND]\\n' >&2")
print("")
let ignore4 = Process.exec("printf '\\033[1mCOMMANDS\\033[0m\\n' >&2")
let ignore5 = Process.exec("printf ' (default) Show mount status\\n' >&2")
let ignore6 = Process.exec("printf ' mount Mount remote server\\n' >&2")
let ignore7 = Process.exec("printf ' unmount Unmount server\\n' >&2")
print("")
}
// =============================================================================
// Main
// =============================================================================
fn main(): Unit with {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 => ""
}
let arg3 = match List.get(args, 3) {
Some(a) => a,
None => ""
}
match cmd {
"" => showHealthCheck(),
"-V" => showVersion(),
"--version" => showVersion(),
"version" => showVersion(),
"init" => doInit(subcmd),
"setup" => doSetup(),
"sync" => {
match subcmd {
"-h" => showSyncHelp(),
"--help" => showSyncHelp(),
"help" => showSyncHelp(),
"status" => doSyncStatus(),
"setup" => doSyncSetup(),
"start" => doSyncStart(),
"" => doSync(),
_ => doSyncStatus()
}
},
"backup" => {
match subcmd {
"-h" => showBackupHelp(),
"--help" => showBackupHelp(),
"help" => showBackupHelp(),
"init" => doBackupInit(arg3),
"list" => doBackupList(),
"" => doBackup(),
_ => doBackup()
}
},
"server" => {
match subcmd {
"-h" => showServerHelp(),
"--help" => showServerHelp(),
"help" => showServerHelp(),
"mount" => doServerMount(),
"unmount" => doServerUnmount(),
"" => doServerStatus(),
_ => doServerStatus()
}
},
"export" => doExport(),
"import" => doImport(subcmd),
"status" => showStatus(),
"doctor" => showDoctor(),
"history" => showHistory(),
"dashboard" => doDashboard(),
"help" => showHelp(),
"-h" => showHelp(),
"--help" => showHelp(),
_ => {
// Unknown command - suggest similar
printErr("Unknown command: " + cmd)
print("")
printHint("Run 'grapho --help' for usage.")
print("")
}
}
}
let result = run main() with {}