New CLI features: - One-liner health check as default (grapho) - Component status dashboard (grapho status) - Verbose mode with details (grapho status -v) - System diagnostics with fix commands (grapho doctor) - Machine-readable output (grapho --json) - Actionable fix suggestions for all warnings/errors Also adds documentation: - docs/MARKDOWN-EDITORS.md - Editor recommendations for mobile/desktop - docs/LUX-LIMITATIONS.md - Tracking Lux language issues Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
453 lines
17 KiB
Plaintext
453 lines
17 KiB
Plaintext
// grapho - Unified CLI for the Ultimate Notetaking, Sync & Backup System
|
|
//
|
|
// Usage:
|
|
// grapho One-line health check (default)
|
|
// grapho status Full status dashboard
|
|
// grapho status -v Verbose status with all details
|
|
// grapho doctor Diagnose issues and suggest fixes
|
|
// grapho sync Sync all (nb + syncthing)
|
|
// grapho backup Run backup now
|
|
// grapho help Show help
|
|
// grapho --json Machine-readable JSON output
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
type SyncStatus =
|
|
| StatusOk
|
|
| StatusWarn
|
|
| StatusErr
|
|
| StatusNone
|
|
|
|
// =============================================================================
|
|
// Status icons
|
|
// =============================================================================
|
|
|
|
fn statusIcon(s: SyncStatus): String =
|
|
match s {
|
|
StatusOk => "[ok]",
|
|
StatusWarn => "[!!]",
|
|
StatusErr => "[ERR]",
|
|
StatusNone => "[--]"
|
|
}
|
|
|
|
fn statusEmoji(s: SyncStatus): String =
|
|
match s {
|
|
StatusOk => "✓",
|
|
StatusWarn => "⚠",
|
|
StatusErr => "✗",
|
|
StatusNone => "-"
|
|
}
|
|
|
|
// =============================================================================
|
|
// Shell helpers
|
|
// =============================================================================
|
|
|
|
fn hasCommand(cmd: String): Bool with {Process} = {
|
|
let result = Process.exec("command -v " + cmd + " 2>/dev/null || true")
|
|
String.length(String.trim(result)) > 0
|
|
}
|
|
|
|
fn execQuiet(cmd: String): String with {Process} =
|
|
String.trim(Process.exec(cmd + " 2>/dev/null || true"))
|
|
|
|
fn homeDir(): String with {Process} =
|
|
match Process.env("HOME") {
|
|
Some(h) => h,
|
|
None => "/tmp"
|
|
}
|
|
|
|
// =============================================================================
|
|
// Syncthing helpers
|
|
// =============================================================================
|
|
|
|
fn syncthingRunning(): Bool with {Process} = {
|
|
let result = Process.exec("syncthing cli show system 2>/dev/null | jq -r '.myID' 2>/dev/null || true")
|
|
String.length(String.trim(result)) > 10
|
|
}
|
|
|
|
fn getSyncthingUptime(): String with {Process} =
|
|
Process.exec("syncthing cli show system 2>/dev/null | jq -r '.uptime // 0' 2>/dev/null || echo 0")
|
|
|
|
fn getSyncthingFolderCount(): String with {Process} =
|
|
String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0"))
|
|
|
|
fn getSyncthingDeviceCount(): String with {Process} =
|
|
String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0"))
|
|
|
|
// =============================================================================
|
|
// One-liner health check (new default)
|
|
// =============================================================================
|
|
|
|
fn showHealthCheck(): Unit with {Console, Process} = {
|
|
let hasIssues = false
|
|
let hasWarnings = false
|
|
let summary = ""
|
|
|
|
// Check nb
|
|
let nbOk = hasCommand("nb")
|
|
let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0"
|
|
|
|
// Check syncthing
|
|
let stOk = hasCommand("syncthing")
|
|
let stRunning = if stOk then syncthingRunning() else false
|
|
let stFolders = if stRunning then getSyncthingFolderCount() else "0"
|
|
let stDevices = if stRunning then getSyncthingDeviceCount() else "0"
|
|
|
|
// Check backup
|
|
let resticOk = hasCommand("restic")
|
|
let timerActive = execQuiet("systemctl is-active restic-backup.timer")
|
|
let backupOk = if timerActive == "active" then true else false
|
|
|
|
// Determine overall status and print appropriate message
|
|
if nbOk == false || stOk == false || resticOk == false then {
|
|
Console.print(statusEmoji(StatusErr) + " Issues detected")
|
|
if nbOk == false then
|
|
Console.print(" " + statusIcon(StatusErr) + " nb: not installed -> nix develop")
|
|
else ()
|
|
if stOk == false then
|
|
Console.print(" " + statusIcon(StatusErr) + " syncthing: not installed -> nix develop")
|
|
else ()
|
|
if resticOk == false then
|
|
Console.print(" " + statusIcon(StatusErr) + " restic: not installed -> nix develop")
|
|
else ()
|
|
} else if stRunning == false || backupOk == false then {
|
|
Console.print(statusEmoji(StatusWarn) + " Warnings")
|
|
if stRunning == false then
|
|
Console.print(" " + statusIcon(StatusWarn) + " syncthing: not running -> systemctl --user start syncthing")
|
|
else ()
|
|
if backupOk == false then
|
|
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> sudo systemctl enable --now restic-backup.timer")
|
|
else ()
|
|
} else {
|
|
Console.print(statusEmoji(StatusOk) + " All systems healthy (" + nbCount + " notebooks, " + stFolders + " folders, " + stDevices + " devices, backup active)")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Status dashboard
|
|
// =============================================================================
|
|
|
|
fn showStatus(verbose: Bool): Unit with {Console, Process} = {
|
|
Console.print("grapho status")
|
|
Console.print("")
|
|
|
|
// nb
|
|
if hasCommand("nb") then {
|
|
let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l")
|
|
Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks")
|
|
if verbose then {
|
|
let notebooks = execQuiet("nb notebooks --names")
|
|
if String.length(notebooks) > 0 then {
|
|
let lines = String.lines(notebooks)
|
|
printNotebooks(lines)
|
|
} else ()
|
|
} else ()
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " nb: not installed")
|
|
Console.print(" Fix: nix develop")
|
|
}
|
|
|
|
// Syncthing
|
|
if hasCommand("syncthing") then {
|
|
if syncthingRunning() then {
|
|
let folders = getSyncthingFolderCount()
|
|
let devices = getSyncthingDeviceCount()
|
|
Console.print(statusIcon(StatusOk) + " syncthing: " + folders + " folders, " + devices + " devices")
|
|
if verbose then {
|
|
let folderLabels = execQuiet("syncthing cli show config | jq -r '.folders[].label'")
|
|
if String.length(folderLabels) > 0 then {
|
|
Console.print(" Folders:")
|
|
let flines = String.lines(folderLabels)
|
|
printLinesIndented(flines)
|
|
} else ()
|
|
let deviceNames = execQuiet("syncthing cli show config | jq -r '.devices[].name'")
|
|
if String.length(deviceNames) > 0 then {
|
|
Console.print(" Devices:")
|
|
let dlines = String.lines(deviceNames)
|
|
printLinesIndented(dlines)
|
|
} else ()
|
|
} else ()
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " syncthing: not running")
|
|
Console.print(" Fix: systemctl --user start syncthing")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
|
|
Console.print(" Fix: nix develop")
|
|
}
|
|
|
|
// Backup
|
|
if hasCommand("restic") then {
|
|
let timerActive = execQuiet("systemctl is-active restic-backup.timer")
|
|
if timerActive == "active" then {
|
|
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 {
|
|
Console.print(statusIcon(StatusErr) + " backup: restic not installed")
|
|
Console.print(" Fix: nix develop")
|
|
}
|
|
}
|
|
|
|
fn printNotebooks(lines: List<String>): Unit with {Console, Process} =
|
|
match List.head(lines) {
|
|
Some(name) => {
|
|
if String.length(name) > 0 then {
|
|
let count = execQuiet("find ~/.nb/" + name + " -name '*.md' 2>/dev/null | wc -l")
|
|
Console.print(" " + name + ": " + count + " notes")
|
|
} else ()
|
|
match List.tail(lines) {
|
|
Some(rest) => printNotebooks(rest),
|
|
None => ()
|
|
}
|
|
},
|
|
None => ()
|
|
}
|
|
|
|
fn printLinesIndented(lines: List<String>): Unit with {Console} =
|
|
match List.head(lines) {
|
|
Some(line) => {
|
|
if String.length(line) > 0 then
|
|
Console.print(" - " + line)
|
|
else ()
|
|
match List.tail(lines) {
|
|
Some(rest) => printLinesIndented(rest),
|
|
None => ()
|
|
}
|
|
},
|
|
None => ()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Doctor command
|
|
// =============================================================================
|
|
|
|
fn showDoctor(): Unit with {Console, Process} = {
|
|
Console.print("grapho doctor")
|
|
Console.print("")
|
|
Console.print("Checking system health...")
|
|
Console.print("")
|
|
|
|
// Check nix
|
|
if hasCommand("nix") then {
|
|
let nixVer = execQuiet("nix --version")
|
|
Console.print(statusIcon(StatusOk) + " nix: " + nixVer)
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " nix: not found")
|
|
Console.print(" Install: https://nixos.org/download")
|
|
}
|
|
|
|
// Check git
|
|
if hasCommand("git") then {
|
|
let gitVer = execQuiet("git --version | cut -d' ' -f3")
|
|
Console.print(statusIcon(StatusOk) + " git: " + gitVer)
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " git: not found")
|
|
}
|
|
|
|
// Check nb
|
|
if hasCommand("nb") then {
|
|
let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l")
|
|
Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks")
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " nb: not installed")
|
|
Console.print(" Fix: nix develop")
|
|
}
|
|
|
|
// Check syncthing
|
|
if hasCommand("syncthing") then {
|
|
if syncthingRunning() then {
|
|
let folders = getSyncthingFolderCount()
|
|
Console.print(statusIcon(StatusOk) + " syncthing: running (" + folders + " folders)")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " syncthing: not running")
|
|
Console.print(" Fix: systemctl --user start syncthing")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
|
|
Console.print(" Fix: nix develop")
|
|
}
|
|
|
|
// Check backup
|
|
if hasCommand("restic") then {
|
|
let timerActive = execQuiet("systemctl is-active restic-backup.timer")
|
|
if timerActive == "active" then {
|
|
Console.print(statusIcon(StatusOk) + " backup: timer active")
|
|
} else {
|
|
Console.print(statusIcon(StatusWarn) + " backup: timer inactive")
|
|
Console.print(" Fix: sudo systemctl enable --now restic-backup.timer")
|
|
}
|
|
} else {
|
|
Console.print(statusIcon(StatusErr) + " restic: not installed")
|
|
Console.print(" Fix: nix develop")
|
|
}
|
|
|
|
// Additional checks
|
|
Console.print("")
|
|
Console.print("Additional checks:")
|
|
|
|
// Check age key
|
|
let ageKey = execQuiet("test -f ~/.config/sops/age/keys.txt && echo yes")
|
|
if ageKey == "yes" then
|
|
Console.print(statusIcon(StatusOk) + " Age encryption key exists")
|
|
else {
|
|
Console.print(statusIcon(StatusWarn) + " No age key")
|
|
Console.print(" Fix: age-keygen -o ~/.config/sops/age/keys.txt")
|
|
}
|
|
|
|
Console.print("")
|
|
Console.print("Run 'grapho status -v' for detailed component info")
|
|
}
|
|
|
|
// =============================================================================
|
|
// JSON output
|
|
// =============================================================================
|
|
|
|
fn showJson(): Unit with {Console, Process} = {
|
|
// Note: JSON output with quotes is difficult in Lux due to escape sequence limitations
|
|
// Using a simple key=value format instead
|
|
let nbOk = hasCommand("nb")
|
|
let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0"
|
|
let nbStatus = if nbOk then "healthy" else "error"
|
|
let nbMsg = if nbOk then nbCount + " notebooks" else "not installed"
|
|
|
|
let stOk = hasCommand("syncthing")
|
|
let stRunning = if stOk then syncthingRunning() else false
|
|
let stStatus = if stOk == false then "error" else if stRunning then "healthy" else "warning"
|
|
let stMsg = if stOk == false then "not installed" else if stRunning then "running" else "not running"
|
|
|
|
let resticOk = hasCommand("restic")
|
|
let timerResult = execQuiet("systemctl is-active restic-backup.timer")
|
|
let timerActive = if timerResult == "active" then true else false
|
|
let backupStatus = if resticOk == false then "error" else if timerActive then "healthy" else "warning"
|
|
let backupMsg = if resticOk == false then "not installed" else if timerActive then "timer active" else "timer inactive"
|
|
|
|
// Output in a simple parseable format
|
|
Console.print("nb.status=" + nbStatus)
|
|
Console.print("nb.message=" + nbMsg)
|
|
Console.print("syncthing.status=" + stStatus)
|
|
Console.print("syncthing.message=" + stMsg)
|
|
Console.print("backup.status=" + backupStatus)
|
|
Console.print("backup.message=" + backupMsg)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Sync command
|
|
// =============================================================================
|
|
|
|
fn doSync(): Unit with {Console, Process} = {
|
|
Console.print("Syncing...")
|
|
|
|
// Sync nb notebooks
|
|
if hasCommand("nb") then {
|
|
Console.print("-> nb sync --all")
|
|
let result = Process.exec("nb sync --all 2>&1 || true")
|
|
if String.contains(result, "error") then
|
|
Console.print(statusIcon(StatusWarn) + " nb sync had issues")
|
|
else
|
|
Console.print(statusIcon(StatusOk) + " nb synced")
|
|
} else ()
|
|
|
|
// Trigger Syncthing scan
|
|
if hasCommand("syncthing") then {
|
|
if syncthingRunning() then {
|
|
Console.print("-> syncthing cli scan")
|
|
let scanResult = Process.exec("syncthing cli scan 2>/dev/null || true")
|
|
Console.print(statusIcon(StatusOk) + " Syncthing scan triggered")
|
|
} else ()
|
|
} else ()
|
|
|
|
Console.print("Done!")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Backup command
|
|
// =============================================================================
|
|
|
|
fn doBackup(): Unit with {Console, Process} = {
|
|
Console.print("Running backup...")
|
|
|
|
if hasCommand("restic") then {
|
|
let hasService = execQuiet("systemctl cat restic-backup.service")
|
|
if String.length(hasService) > 0 then {
|
|
Console.print("-> sudo systemctl start restic-backup.service")
|
|
let startResult = Process.exec("sudo systemctl start restic-backup.service 2>&1 || true")
|
|
Console.print(statusIcon(StatusOk) + " Backup service triggered")
|
|
} 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
|
|
// =============================================================================
|
|
|
|
fn showHelp(): Unit with {Console} = {
|
|
Console.print("grapho - Personal Data Infrastructure")
|
|
Console.print("")
|
|
Console.print("Usage:")
|
|
Console.print(" grapho Health check (one-liner)")
|
|
Console.print(" grapho status Component status")
|
|
Console.print(" grapho status -v Verbose status with details")
|
|
Console.print(" grapho doctor Diagnose issues and fixes")
|
|
Console.print(" grapho sync Sync all (nb + syncthing)")
|
|
Console.print(" grapho backup Run backup now")
|
|
Console.print(" grapho --json Machine-readable output (key=value format)")
|
|
Console.print(" grapho help Show this help")
|
|
Console.print("")
|
|
Console.print("Quick start:")
|
|
Console.print(" nb add Create a new note")
|
|
Console.print(" nb search <term> Search notes")
|
|
Console.print(" grapho sync Sync to all devices")
|
|
Console.print("")
|
|
Console.print("More info: https://git.qrty.ink/blu/grapho")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main
|
|
// =============================================================================
|
|
|
|
fn main(): Unit with {Console, Process} = {
|
|
let args = Process.args()
|
|
let cmd = match List.get(args, 1) {
|
|
Some(c) => c,
|
|
None => ""
|
|
}
|
|
|
|
match cmd {
|
|
"" => showHealthCheck(),
|
|
"status" => {
|
|
let subcmd = match List.get(args, 2) {
|
|
Some(s) => s,
|
|
None => ""
|
|
}
|
|
match subcmd {
|
|
"-v" => showStatus(true),
|
|
"--verbose" => showStatus(true),
|
|
"verbose" => showStatus(true),
|
|
_ => showStatus(false)
|
|
}
|
|
},
|
|
"doctor" => showDoctor(),
|
|
"sync" => doSync(),
|
|
"backup" => doBackup(),
|
|
"help" => showHelp(),
|
|
"-h" => showHelp(),
|
|
"--help" => showHelp(),
|
|
"--json" => showJson(),
|
|
"-j" => showJson(),
|
|
"json" => showJson(),
|
|
_ => showHelp()
|
|
}
|
|
}
|
|
|
|
let result = run main() with {}
|