From d30d2efa4eac2cb9283ea2291636f720bfa00948 Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Sun, 22 Feb 2026 22:38:37 -0500 Subject: [PATCH] Rename grapho to pal, add onboard command, fix interactive input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename grapho → pal across entire codebase (CLI, NixOS module, flake, docs, config paths) - Add `pal onboard` interactive setup wizard with 4 steps: device, config repo, sync, backups - Shows current setup summary when re-running onboard on an existing installation with warnings about what will change - Fix askConfirm/askInput to read from /dev/tty so interactive prompts work correctly - Remove || operator usage in askConfirm (not reliable in Lux) - Add pal package to nix develop shell - Document ~/.config/pal/ directory structure in README Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- README.md | 66 +++- cli/{grapho.lux => pal.lux} | 629 +++++++++++++++++++++++++------- docs/MARKDOWN-EDITORS.md | 16 +- flake.nix | 30 +- modules/{grapho.nix => pal.nix} | 100 ++--- setup | 2 +- 7 files changed, 637 insertions(+), 208 deletions(-) rename cli/{grapho.lux => pal.lux} (62%) rename modules/{grapho.nix => pal.nix} (75%) diff --git a/.gitignore b/.gitignore index d67efc3..6ae983a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ result-* .direnv/ # Compiled binaries -grapho +pal *_test # Secrets (NEVER commit unencrypted secrets) diff --git a/README.md b/README.md index 4cd7157..617b95d 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ A NixOS-based system for managing the three types of data across devices: ```bash # One-command setup (public repo, no SSH key needed) -nix run 'git+https://git.qrty.ink/blu/grapho.git' +nix run 'git+https://git.qrty.ink/blu/pal.git' # Or clone first, then run -git clone https://git.qrty.ink/blu/grapho.git -cd grapho +git clone https://git.qrty.ink/blu/pal.git +cd pal nix run . ``` @@ -26,8 +26,8 @@ nix run . ```bash # 1. Clone the repo -git clone https://git.qrty.ink/blu/grapho.git -cd grapho +git clone https://git.qrty.ink/blu/pal.git +cd pal # 2. Run setup (one command - includes all dependencies) nix run . @@ -39,18 +39,18 @@ nix run . # 4. (Optional) Set up SSH for push access # Add your SSH key to Gitea: https://git.qrty.ink/user/settings/keys # Then switch to SSH remote: -git remote set-url origin ssh://git@git.qrty.ink:2222/blu/grapho.git +git remote set-url origin ssh://git@git.qrty.ink:2222/blu/pal.git ``` ### Additional Computers (Joining) ```bash # One command (no SSH key needed for public repo) -nix run 'git+https://git.qrty.ink/blu/grapho.git' +nix run 'git+https://git.qrty.ink/blu/pal.git' # Choose option [2], enter your config git URL and age key # Or clone first: -git clone https://git.qrty.ink/blu/grapho.git && cd grapho +git clone https://git.qrty.ink/blu/pal.git && cd pal nix run . -- ``` @@ -78,10 +78,10 @@ Add to your flake.nix inputs, then import the module: ```nix { - inputs.grapho.url = "git+https://git.qrty.ink/blu/grapho.git"; + inputs.pal.url = "git+https://git.qrty.ink/blu/pal.git"; # In your configuration: - imports = [ inputs.grapho.nixosModules.default ]; + imports = [ inputs.pal.nixosModules.default ]; } ``` @@ -306,19 +306,22 @@ No. See [our research](./docs/research/sync-tools-comparison.md). Common issues But cloud is fine too. This system works with GitHub, Backblaze B2, etc. -## Directory Structure +## Repository Structure ``` . ├── flake.nix # Entry point ├── flake.lock ├── README.md +├── cli/ +│ └── pal.lux # CLI source (Lux language) ├── docs/ │ ├── research/ │ │ └── sync-tools-comparison.md │ ├── ARCHITECTURE.md │ └── LLM-CONTEXT.md # For AI assistants ├── modules/ +│ ├── pal.nix # Core pal NixOS module │ ├── nb.nix │ ├── syncthing.nix │ ├── neovim.nix @@ -342,6 +345,47 @@ But cloud is fine too. This system works with GitHub, Backblaze B2, etc. └── ustatus ``` +## Data Directory (`~/.config/pal/`) + +When you run `pal onboard` or `pal setup`, this directory is created to hold all your data and configuration. Everything is human-readable unless noted. + +``` +~/.config/pal/ +│ +├── pal.toml # Main config (TOML) — device name, ports, schedule +├── age-key.txt # Age encryption private key +├── state.db # Event history (SQLite) +│ +├── config-repo/ # Your system config (git-managed) +│ └── .git/ +│ +├── sync/ # Syncthing-managed data (syncs across devices) +│ ├── notes/ # Your notes +│ ├── documents/ # Your documents +│ └── dotfiles/ # Your dotfiles +│ +├── syncthing/ # Syncthing runtime (auto-generated) +│ ├── config/ # config.xml, TLS certs, keys +│ └── db/ # Syncthing index database +│ +├── restic/ # Backup settings +│ ├── password # Repository password (auto-generated, plaintext) +│ ├── repository # Repository URL/path (plaintext) +│ └── cache/ # Local cache for faster operations +│ +├── backups/ # Restic repository (if backing up locally) +│ ├── config # ⚠ Encrypted — restic internal, not human-readable +│ ├── data/ # Encrypted, deduplicated backup chunks +│ ├── index/ # Backup index +│ ├── keys/ # Repository encryption keys +│ ├── locks/ # Lock files +│ └── snapshots/ # Snapshot metadata +│ +└── server/ # Mount point for remote server storage +``` + +**What's human-readable?** `pal.toml`, `restic/password`, `restic/repository`, and everything in `sync/` and `config-repo/`. The `syncthing/config/` directory is auto-generated XML. The `backups/` directory is a restic repository where everything is encrypted by design — use `pal backup list` to inspect snapshots. + ## Contributing PRs welcome! Please read [ARCHITECTURE.md](./docs/ARCHITECTURE.md) first. diff --git a/cli/grapho.lux b/cli/pal.lux similarity index 62% rename from cli/grapho.lux rename to cli/pal.lux index 3274ad2..0856715 100644 --- a/cli/grapho.lux +++ b/cli/pal.lux @@ -1,4 +1,4 @@ -// grapho - Your personal data, everywhere +// pal - Your personal data, everywhere // // A modern CLI for managing your digital life across all devices. @@ -131,19 +131,33 @@ fn printStatusLine(name: String, state: String, detail: String): Unit with {Proc 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 response = String.trim(Process.exec("head -n1 0 then + "printf '%s [%s]: ' \"" + prompt + "\" \"" + defaultVal + "\" >&2" + else + "printf '%s: ' \"" + prompt + "\" >&2" + let ignore = Process.exec(display) + let input = String.trim(Process.exec("head -n1 &2") @@ -196,16 +210,16 @@ 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" +fn palDir(): String with {Process} = homeDir() + "/.config/pal" +fn palConfig(): String with {Process} = palDir() + "/pal.toml" +fn stateDb(): String with {Process} = palDir() + "/state.db" // ============================================================================= // Initialization // ============================================================================= fn isInitialized(): Bool with {Process} = { - let result = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no") + let result = execQuiet("test -f " + palConfig() + " && echo yes || echo no") result |> isYes } @@ -242,45 +256,45 @@ fn getRecentEvents(limit: String): String with {Process} = { // Syncthing helpers // ============================================================================= -fn graphoSyncthingRunning(): Bool with {Process} = { +fn palSyncthingRunning(): 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") + execQuiet("syncthing cli --home=" + palDir() + "/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") + execQuiet("syncthing cli --home=" + palDir() + "/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") + execQuiet("cat " + palDir() + "/restic/repository 2>/dev/null") fn getBackupPassword(): String with {Process} = - graphoDir() + "/restic/password" + palDir() + "/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") + 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") + let ignore = Process.exec("printf '\\033[1;36mpal\\033[0m ' >&2") () } @@ -292,9 +306,9 @@ fn showInteractiveWelcome(): Unit with {Process} = { print("") showLogo() print("") - printBold("Welcome to grapho!") + printBold("Welcome to pal!") print("") - printHint("grapho manages your data across all your devices:") + printHint("pal 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") @@ -311,15 +325,15 @@ fn showInteractiveWelcome(): Unit with {Process} = { print("") let ignore8 = Process.exec("printf 'Choice [1]: ' >&2") - let choice = String.trim(Process.exec("head -n1")) + let choice = String.trim(Process.exec("head -n1 { print("") printBold("Import from archive") print("") - printHint("Provide a grapho export archive:") - printCmd("grapho import grapho-export.tar.zst") + printHint("Provide a pal export archive:") + printCmd("pal import pal-export.tar.zst") print("") }, "3" => showHelp(), @@ -336,7 +350,7 @@ fn showWelcome(): Unit with {Process} = { showLogo() print("") printBold("Not initialized. Run:") - printCmd("grapho setup") + printCmd("pal setup") print("") } @@ -345,7 +359,7 @@ fn showWelcome(): Unit with {Process} = { // ============================================================================= fn createDirectories(): Unit with {Process} = { - let base = graphoDir() + let base = palDir() ensureDir(base) ensureDir(base + "/config-repo") ensureDir(base + "/syncthing/config") @@ -359,7 +373,7 @@ fn createDirectories(): Unit with {Process} = { fn doSetup(): Unit with {Process} = { print("") - printHeader("Setting up grapho") + printHeader("Setting up pal") // Step 1: Directories printStepPending("Creating directories") @@ -369,27 +383,27 @@ fn doSetup(): Unit with {Process} = { // 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 configContent = "[pal]\nversion = 1\ndevice_name = \"" + hostname + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\n\n[backup]\nschedule = \"hourly\"\n" + let writeCmd = "cat > " + palConfig() + " << '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") + let hasAgeKey = execQuiet("test -f " + palDir() + "/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") + let ignore2 = Process.exec("age-keygen -o " + palDir() + "/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") + let hasConfig = execQuiet("test -f " + palDir() + "/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") + let ignore3 = Process.exec("syncthing generate --home=" + palDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1") printStep("Initializing sync", "done") } else { printStep("Initializing sync", "ready") @@ -401,17 +415,17 @@ fn doSetup(): Unit with {Process} = { // State DB if hasCommand("sqlite3") then { initStateDb() - logEvent("setup", "grapho initialized") + logEvent("setup", "pal initialized") } else () print("") printSuccess("Setup complete!") print("") - printHint("Your data lives in: " + graphoDir()) + printHint("Your data lives in: " + palDir()) print("") printBold("Next steps:") - printCmd("grapho sync setup # Pair with other devices") - printCmd("grapho backup init # Configure backups") + printCmd("pal sync setup # Pair with other devices") + printCmd("pal backup init # Configure backups") print("") } @@ -425,7 +439,7 @@ fn doInit(repoUrl: String): Unit with {Process} = { printHeader("Initialize from repository") printHint("Clone a config repository to set up this machine:") print("") - printCmd("grapho init https://github.com/you/grapho-config") + printCmd("pal init https://github.com/you/pal-config") print("") } else { printHeader("Initializing from repository") @@ -435,7 +449,7 @@ fn doInit(repoUrl: String): Unit with {Process} = { printStep("Creating directories", "done") printStepPending("Cloning repository") - let cloneResult = Process.exec("git clone " + repoUrl + " " + graphoDir() + "/config-repo 2>&1") + let cloneResult = Process.exec("git clone " + repoUrl + " " + palDir() + "/config-repo 2>&1") if String.contains(cloneResult, "fatal") then { printStepErr("Cloning repository", "failed") print("") @@ -443,12 +457,12 @@ fn doInit(repoUrl: String): Unit with {Process} = { } else { printStep("Cloning repository", "done") - let hasFlake = execQuiet("test -f " + graphoDir() + "/config-repo/flake.nix && echo yes || echo no") + let hasFlake = execQuiet("test -f " + palDir() + "/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("cd " + palDir() + "/config-repo") printCmd("sudo nixos-rebuild switch --flake .") } else { printStepWarn("Found flake.nix", "no") @@ -467,8 +481,8 @@ 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 { + printHint("Install syncthing and run 'pal sync setup'") + } else if palSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") @@ -477,7 +491,7 @@ fn doSyncStatus(): Unit with {Process} = { } else { printStatusLine("sync", "Stopped", "") print("") - printCmd("grapho sync start") + printCmd("pal sync start") } print("") } @@ -486,31 +500,31 @@ fn doSyncSetup(): Unit with {Process} = { print("") if hasCommand("syncthing") == false then { printErr("Syncthing not installed") - printHint("Add to NixOS: services.grapho.enable = true;") + printHint("Add to NixOS: services.pal.enable = true;") print("") } else { printHeader("Setting up sync") - let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no") + let hasConfig = execQuiet("test -f " + palDir() + "/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") + let ignore = Process.exec("syncthing generate --home=" + palDir() + "/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 { + if palSyncthingRunning() == 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 ignore = Process.exec("syncthing serve --home=" + palDir() + "/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") + let deviceId = execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4") if String.length(deviceId) > 10 then { print("") printBold("Your Device ID") @@ -529,14 +543,14 @@ fn doSyncSetup(): Unit with {Process} = { fn doSyncStart(): Unit with {Process} = { print("") - if graphoSyncthingRunning() then { + if palSyncthingRunning() 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 ignore = Process.exec("syncthing serve --home=" + palDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &") let ignore2 = Process.exec("sleep 2") - if graphoSyncthingRunning() then { + if palSyncthingRunning() 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") @@ -549,13 +563,13 @@ fn doSyncStart(): Unit with {Process} = { fn doSync(): Unit with {Process} = { print("") - if graphoSyncthingRunning() then { - let ignore = Process.exec("syncthing cli --home=" + graphoDir() + "/syncthing/config scan 2>/dev/null || true") + if palSyncthingRunning() then { + let ignore = Process.exec("syncthing cli --home=" + palDir() + "/syncthing/config scan 2>/dev/null || true") printSuccess("Sync triggered") logEvent("sync", "Manual sync") } else { printWarn("Syncthing not running") - printCmd("grapho sync start") + printCmd("pal sync start") } print("") } @@ -573,16 +587,16 @@ fn doBackupInit(repoArg: String): Unit with {Process} = { 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") + printCmd("pal backup init /path/to/backup") + printCmd("pal backup init sftp:server:/backups") + printCmd("pal backup init b2:bucket:path") print("") } else { printHeader("Initializing backup") printHint("Repository: " + repoArg) print("") - let passwordFile = graphoDir() + "/restic/password" + let passwordFile = palDir() + "/restic/password" let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no") if hasPassword |> isNo then { printStepPending("Generating password") @@ -597,15 +611,15 @@ fn doBackupInit(repoArg: String): Unit with {Process} = { if String.contains(initResult, "created restic repository") then { printStep("Creating repository", "done") - let ignore = Process.exec("echo '" + repoArg + "' > " + graphoDir() + "/restic/repository") + let ignore = Process.exec("echo '" + repoArg + "' > " + palDir() + "/restic/repository") printStep("Saving config", "done") print("") printSuccess("Backup initialized!") - printCmd("grapho backup # Create your first snapshot") + printCmd("pal 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") + let ignore = Process.exec("echo '" + repoArg + "' > " + palDir() + "/restic/repository") } else { printStepErr("Creating repository", "failed") print("") @@ -620,7 +634,7 @@ fn doBackupList(): Unit with {Process} = { let repo = getBackupRepo() if String.length(repo) == 0 then { printWarn("No backup configured") - printCmd("grapho backup init ") + printCmd("pal backup init ") } else { printHeader("Backup snapshots") printHint("Repository: " + repo) @@ -639,10 +653,10 @@ fn doBackup(): Unit with {Process} = { let repo = getBackupRepo() if String.length(repo) == 0 then { printWarn("No backup configured") - printCmd("grapho backup init ") + printCmd("pal backup init ") } else { printStepPending("Creating snapshot") - let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + graphoDir() + "/sync 2>&1") + let backupResult = Process.exec("restic -r " + repo + " --password-file " + getBackupPassword() + " backup " + palDir() + "/sync 2>&1") if String.contains(backupResult, "snapshot") then { let snapshotId = execQuiet("echo '" + backupResult + "' | grep -o 'snapshot [a-f0-9]*' | cut -d' ' -f2") @@ -667,7 +681,7 @@ fn doBackup(): Unit with {Process} = { fn doServerStatus(): Unit with {Process} = { print("") - let serverDir = graphoDir() + "/server" + let serverDir = palDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { @@ -677,14 +691,14 @@ fn doServerStatus(): Unit with {Process} = { } else { printStatusLine("server", "Not mounted", "") print("") - printCmd("grapho server mount") + printCmd("pal server mount") } print("") } fn doServerMount(): Unit with {Process} = { print("") - let serverDir = graphoDir() + "/server" + let serverDir = palDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { @@ -723,14 +737,14 @@ fn doServerMount(): Unit with {Process} = { print("") // Unmount hint - printHint("To unmount: grapho server unmount") + printHint("To unmount: pal server unmount") } print("") } fn doServerUnmount(): Unit with {Process} = { print("") - let serverDir = graphoDir() + "/server" + let serverDir = palDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { @@ -754,11 +768,11 @@ fn doServerUnmount(): Unit with {Process} = { fn doExport(): Unit with {Process} = { print("") let timestamp = execQuiet("date +%Y-%m-%d") - let exportFile = "grapho-export-" + timestamp + ".tar.zst" + let exportFile = "pal-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 ignore = Process.exec("tar -C " + palDir() + " -cf - . 2>/dev/null | zstd -q > " + exportPath + " 2>&1") let fileExists = execQuiet("test -f " + exportPath + " && echo yes || echo no") if fileExists |> isYes then { @@ -768,7 +782,7 @@ fn doExport(): Unit with {Process} = { printSuccess("Export created: " + exportFile) print("") printHint("Restore with:") - printCmd("grapho import " + exportFile) + printCmd("pal import " + exportFile) logEvent("export", "Created " + exportFile) } else { printStepErr("Creating archive", "failed") @@ -780,10 +794,10 @@ 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:") + printHeader("Import pal data") + printHint("Restore from a pal export archive:") print("") - printCmd("grapho import grapho-export.tar.zst") + printCmd("pal import pal-export.tar.zst") print("") } else { let fileExists = execQuiet("test -f " + archivePath + " && echo yes || echo no") @@ -792,11 +806,11 @@ fn doImport(archivePath: String): Unit with {Process} = { print("") } else { // Check if there's existing data - let hasGrapho = execQuiet("test -d " + graphoDir() + " && echo yes || echo no") - if hasGrapho |> isYes then { + let hasPal = execQuiet("test -d " + palDir() + " && echo yes || echo no") + if hasPal |> isYes then { print("") - printWarn("This will replace your existing grapho data.") - printHint("Existing data will be moved to ~/.config/grapho.bak") + printWarn("This will replace your existing pal data.") + printHint("Existing data will be moved to ~/.config/pal.bak") print("") let confirmed = askConfirm("Continue?", false) @@ -809,16 +823,16 @@ fn doImport(archivePath: String): Unit with {Process} = { printHeader("Importing") printStepPending("Backing up existing") - let ignore = Process.exec("mv " + graphoDir() + " " + graphoDir() + ".bak 2>&1") + let ignore = Process.exec("mv " + palDir() + " " + palDir() + ".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") + let ignore2 = Process.exec("mkdir -p " + palDir()) + let ignore3 = Process.exec("zstd -d < " + archivePath + " | tar -C " + palDir() + " -xf - 2>&1") printStep("Extracting archive", "done") printStepPending("Verifying config") - let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no") + let configExists = execQuiet("test -f " + palConfig() + " && echo yes || echo no") if configExists |> isYes then { printStep("Verifying config", "done") print("") @@ -836,12 +850,12 @@ fn doImport(archivePath: String): Unit with {Process} = { 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") + let ignore = Process.exec("mkdir -p " + palDir()) + let ignore2 = Process.exec("zstd -d < " + archivePath + " | tar -C " + palDir() + " -xf - 2>&1") printStep("Extracting archive", "done") printStepPending("Verifying config") - let configExists = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no") + let configExists = execQuiet("test -f " + palConfig() + " && echo yes || echo no") if configExists |> isYes then { printStep("Verifying config", "done") print("") @@ -866,21 +880,21 @@ fn showStatus(): Unit with {Process} = { print("") if isInitialized() == false then { printWarn("Not initialized") - printCmd("grapho setup") + printCmd("pal setup") } else { printHeader("Status") // Config - let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no") + let hasRepo = execQuiet("test -d " + palDir() + "/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//'") + let repoRemote = execQuiet("cd " + palDir() + "/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 { + if palSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") @@ -898,7 +912,7 @@ fn showStatus(): Unit with {Process} = { } // Server - let serverDir = graphoDir() + "/server" + let serverDir = palDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { printStatusLine("server", "Mounted", serverDir) @@ -914,13 +928,13 @@ fn showStatus(): Unit with {Process} = { // ============================================================================= fn showCompactStatus(): Unit with {Process} = { - let syncOk = graphoSyncthingRunning() + let syncOk = palSyncthingRunning() 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") + let ignore = Process.exec("printf '\\033[1;36mpal\\033[0m ' >&2") if syncOk && hasBackup then { let ignore2 = Process.exec("printf '\\033[32mAll systems operational\\033[0m\\n' >&2") } else { @@ -932,7 +946,7 @@ fn showCompactStatus(): Unit with {Process} = { if syncOk then { printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") } else { - printStatusLine("sync", "Not running", "Run: grapho sync start") + printStatusLine("sync", "Not running", "Run: pal sync start") } // Backup status @@ -941,11 +955,11 @@ fn showCompactStatus(): Unit with {Process} = { 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") + printStatusLine("backup", "Not configured", "Run: pal backup init") } // Server status - let serverDir = graphoDir() + "/server" + let serverDir = palDir() + "/server" let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no") if isMounted |> isYes then { printStatusLine("server", "Mounted", serverDir) @@ -1000,7 +1014,7 @@ fn showDoctor(): Unit with {Process} = { } if hasCommand("syncthing") then { - if graphoSyncthingRunning() then + if palSyncthingRunning() then printStep("syncthing", "running") else printStepWarn("syncthing", "installed, not running") @@ -1015,7 +1029,7 @@ fn showDoctor(): Unit with {Process} = { } if hasCommand("age") then { - let hasKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no") + let hasKey = execQuiet("test -f " + palDir() + "/age-key.txt && echo yes || echo no") if hasKey |> isYes then printStep("age", "key exists") else @@ -1042,7 +1056,7 @@ fn showDoctor(): Unit with {Process} = { } fn checkDir(dir: String): Unit with {Process} = { - let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no") + let exists = execQuiet("test -d " + palDir() + "/" + dir + " && echo yes || echo no") if exists |> isYes then { printStep(dir + "/", "exists") } else { @@ -1092,7 +1106,7 @@ fn doDashboard(): Unit with {Process} = { print("") // Sync - if graphoSyncthingRunning() then { + if palSyncthingRunning() then { let folders = getSyncFolderCount() let devices = getSyncDeviceCount() printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") @@ -1110,7 +1124,7 @@ fn doDashboard(): Unit with {Process} = { } // Server - let serverDir = graphoDir() + "/server" + let serverDir = palDir() + "/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/ / \\/ /'") @@ -1120,9 +1134,9 @@ fn doDashboard(): Unit with {Process} = { } // Config - let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no") + let hasRepo = execQuiet("test -d " + palDir() + "/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//'") + let repoRemote = execQuiet("cd " + palDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'") printStatusLine("config", "Linked", repoRemote) } else { printStatusLine("config", "Not linked", "") @@ -1131,12 +1145,381 @@ fn doDashboard(): Unit with {Process} = { print("") } +// ============================================================================= +// Onboard wizard +// ============================================================================= + +fn showOnboardSummary(deviceName: String): Unit with {Process} = { + let ignore = Process.exec("printf '\\n\\033[1m── Setup Complete ──\\033[0m\\n\\n' >&2") + + // Device info + let osInfo = execQuiet("uname -sr") + printStatusLine("device", deviceName, osInfo) + + // Config status + let hasRepo = execQuiet("test -d " + palDir() + "/config-repo/.git && echo yes || echo no") + if hasRepo |> isYes then { + let repoRemote = execQuiet("cd " + palDir() + "/config-repo && git remote get-url origin 2>/dev/null | sed 's/.*\\///' | sed 's/.git//'") + if String.length(repoRemote) > 0 then + printStatusLine("config", "Linked", repoRemote) + else + printStatusLine("config", "Initialized", "local repo") + } else { + printStatusLine("config", "Not linked", "") + } + + // Sync status + if palSyncthingRunning() then { + let folders = getSyncFolderCount() + let devices = getSyncDeviceCount() + printStatusLine("sync", "Running", folders + " folders, " + devices + " devices") + } else if hasCommand("syncthing") then { + printStatusLine("sync", "Ready", "not running") + } else { + printStatusLine("sync", "Not installed", "") + } + + // Backup status + let finalRepo = getBackupRepo() + if String.length(finalRepo) > 0 then { + printStatusLine("backup", "Configured", finalRepo) + } else { + printStatusLine("backup", "Not configured", "") + } + + print("") + + // Connected devices + if palSyncthingRunning() then { + printBold("Connected Devices") + let ignore2 = Process.exec("printf ' \\033[32m●\\033[0m %s \\033[2m(this device)\\033[0m\\n' \"" + deviceName + "\" >&2") + let otherDevices = execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices[] | .name // .deviceID[:8]' 2>/dev/null | grep -v '^$'") + if String.length(otherDevices) > 1 then { + let ignore3 = Process.exec("echo '" + otherDevices + "' | while read -r dev; do printf ' \\033[2m●\\033[0m %s\\n' \"$dev\" >&2; done") + () + } else () + print("") + } else { + printBold("Connected Devices") + let ignore2 = Process.exec("printf ' \\033[2m●\\033[0m %s \\033[2m(this device, sync not running)\\033[0m\\n' \"" + deviceName + "\" >&2") + print("") + } + + // Synced data + let syncDir = palDir() + "/sync" + let hasSyncDir = execQuiet("test -d " + syncDir + " && echo yes || echo no") + if hasSyncDir |> isYes then { + printBold("Synced Data") + let notesCount = execQuiet("find " + syncDir + "/notes -type f 2>/dev/null | wc -l | tr -d ' '") + let docsCount = execQuiet("find " + syncDir + "/documents -type f 2>/dev/null | wc -l | tr -d ' '") + let dotfilesCount = execQuiet("find " + syncDir + "/dotfiles -type f 2>/dev/null | wc -l | tr -d ' '") + let ignore4 = Process.exec("printf ' %-16s %s files\\n' 'notes/' '" + notesCount + "' >&2") + let ignore5 = Process.exec("printf ' %-16s %s files\\n' 'documents/' '" + docsCount + "' >&2") + let ignore6 = Process.exec("printf ' %-16s %s files\\n' 'dotfiles/' '" + dotfilesCount + "' >&2") + print("") + } else () + + printSuccess("Onboarding complete!") + print("") + printHint("Data directory: " + palDir()) + print("") +} + +fn doOnboardSteps(): Unit with {Process} = { + // ── Step 1: Device ────────────────────────────────────────────────── + let ignore = Process.exec("printf '\\033[1m── Step 1: Device ──\\033[0m\\n\\n' >&2") + + printStepPending("Creating directories") + createDirectories() + printStep("Creating directories", "done") + + let hostname = execQuiet("hostname") + let deviceName = askInput("Device name", hostname) + print("") + + printStepPending("Writing configuration") + let configContent = "[pal]\nversion = 1\ndevice_name = \"" + deviceName + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\n\n[backup]\nschedule = \"hourly\"\n" + let writeCmd = "cat > " + palConfig() + " << 'EOF'\n" + configContent + "EOF" + let ignore2 = Process.exec(writeCmd) + printStep("Writing configuration", "done") + + printStepPending("Setting up encryption") + let hasAgeKey = execQuiet("test -f " + palDir() + "/age-key.txt && echo yes || echo no") + if hasAgeKey |> isYes then + printStep("Setting up encryption", "exists") + else if hasCommand("age-keygen") then { + let ignore3 = Process.exec("age-keygen -o " + palDir() + "/age-key.txt 2>&1 || true") + printStep("Setting up encryption", "done") + } else { + printStepWarn("Setting up encryption", "age not installed") + } + + if hasCommand("sqlite3") then { + initStateDb() + logEvent("onboard", "Started onboarding for " + deviceName) + } else () + + print("") + + // ── Step 2: Config Repository ─────────────────────────────────────── + let ignore4 = Process.exec("printf '\\033[1m── Step 2: Config Repository ──\\033[0m\\n\\n' >&2") + printHint("A git repo lets you version-control your system config") + printHint("(NixOS flakes, dotfiles, etc.) and share it across machines.") + print("") + + let ignore5 = Process.exec("printf ' \\033[36m1\\033[0m I have a git repo URL\\n' >&2") + let ignore6 = Process.exec("printf ' \\033[36m2\\033[0m Initialize a new repo in a directory\\n' >&2") + let ignore7 = Process.exec("printf ' \\033[36m3\\033[0m Skip for now\\n' >&2") + print("") + + let ignore8 = Process.exec("printf 'Choice [3]: ' >&2") + let configChoice = String.trim(Process.exec("head -n1 { + let repoUrl = askInput("Repository URL", "") + if String.length(repoUrl) == 0 then { + printHint("No URL provided, skipping.") + } else { + print("") + printStepPending("Cloning repository") + let cloneResult = Process.exec("git clone " + repoUrl + " " + palDir() + "/config-repo 2>&1") + if String.contains(cloneResult, "fatal") then { + printStepErr("Cloning repository", "failed") + printDim(cloneResult) + print("") + printHint("You can try again later with:") + printCmd("pal init " + repoUrl) + } else { + printStep("Cloning repository", "done") + logEvent("onboard", "Cloned config: " + repoUrl) + } + } + }, + "2" => { + let defaultDir = palDir() + "/config-repo" + let repoDir = askInput("Directory", defaultDir) + print("") + printStepPending("Initializing repository") + let ignore9 = Process.exec("mkdir -p " + repoDir) + let initResult = Process.exec("cd " + repoDir + " && git init 2>&1") + if String.contains(initResult, "nitialized") then { + printStep("Initializing repository", "done") + printHint("Add a remote later:") + printCmd("git -C " + repoDir + " remote add origin ") + logEvent("onboard", "Init config repo: " + repoDir) + } else { + printStepErr("Initializing repository", "failed") + printDim(initResult) + } + }, + _ => { + printHint("Skipping config repository.") + printHint("Set this up later with:") + printCmd("pal init ") + } + } + + print("") + + // ── Step 3: File Sync ─────────────────────────────────────────────── + let ignore10 = Process.exec("printf '\\033[1m── Step 3: File Sync ──\\033[0m\\n\\n' >&2") + printHint("Syncthing keeps notes, documents, and dotfiles") + printHint("in sync across all your devices in real-time.") + print("") + + if hasCommand("syncthing") == false then { + printStepWarn("Syncthing", "not installed") + printHint("Install syncthing and run 'pal sync setup' later.") + } else { + let setupSync = askConfirm("Set up sync?", true) + print("") + if setupSync then { + let hasConfig = execQuiet("test -f " + palDir() + "/syncthing/config/config.xml && echo yes || echo no") + if hasConfig |> isNo then { + printStepPending("Creating sync config") + let ignore11 = Process.exec("syncthing generate --home=" + palDir() + "/syncthing/config --no-default-folder --gui-listen=127.0.0.1:8385 2>&1") + printStep("Creating sync config", "done") + } else { + printStep("Creating sync config", "exists") + } + + if palSyncthingRunning() == false then { + printStepPending("Starting sync daemon") + let ignore12 = Process.exec("syncthing serve --home=" + palDir() + "/syncthing/config --no-browser --gui-address=127.0.0.1:8385 >/dev/null 2>&1 &") + let ignore13 = Process.exec("sleep 2") + if palSyncthingRunning() then + printStep("Starting sync daemon", "done") + else + printStepWarn("Starting sync daemon", "may need a moment") + } else { + printStep("Starting sync daemon", "running") + } + + let deviceId = execQuiet("syncthing cli --home=" + palDir() + "/syncthing/config show system 2>/dev/null | grep myID | cut -d'\"' -f4") + if String.length(deviceId) > 10 then { + print("") + printBold("Your Device ID") + let ignore14 = Process.exec("printf '\\033[33m%s\\033[0m\\n' \"" + deviceId + "\" >&2") + print("") + printQR(deviceId) + print("") + printHint("Share this ID or scan the QR code to pair devices.") + logEvent("onboard", "Sync configured") + } else { + printHint("Device ID not ready yet. Try 'pal sync setup' in a moment.") + } + } else { + printHint("Skipping sync setup.") + printHint("Set this up later with:") + printCmd("pal sync setup") + } + } + + print("") + + // ── Step 4: Backups ───────────────────────────────────────────────── + let ignore15 = Process.exec("printf '\\033[1m── Step 4: Backups ──\\033[0m\\n\\n' >&2") + printHint("Restic creates encrypted, deduplicated snapshots of") + printHint("your synced data. Supports local, SFTP, S3, B2, and more.") + print("") + + if hasCommand("restic") == false then { + printStepWarn("Restic", "not installed") + printHint("Install restic and run 'pal backup init ' later.") + } else { + let existingRepo = getBackupRepo() + if String.length(existingRepo) > 0 then { + printStep("Backup repository", "already configured") + printHint("Repository: " + existingRepo) + } else { + let setupBackup = askConfirm("Set up backups?", true) + print("") + if setupBackup then { + printHint("Enter a path or URL where backup snapshots will be stored.") + print("") + let ignore15b = Process.exec("printf ' \\033[36mLocal directory\\033[0m /mnt/backup, /media/usb/backups\\n' >&2") + let ignore15c = Process.exec("printf ' \\033[36mSFTP (SSH)\\033[0m sftp:user@host:/backups\\n' >&2") + let ignore15d = Process.exec("printf ' \\033[36mBackblaze B2\\033[0m b2:bucket-name:path\\n' >&2") + let ignore15e = Process.exec("printf ' \\033[36mAmazon S3\\033[0m s3:bucket-name/path\\n' >&2") + print("") + let backupRepo = askInput("Backup location", "") + if String.length(backupRepo) == 0 then { + printHint("Skipping backup setup.") + printCmd("pal backup init ") + } else { + print("") + let passwordFile = palDir() + "/restic/password" + let hasPassword = execQuiet("test -f " + passwordFile + " && echo yes || echo no") + if hasPassword |> isNo then { + printStepPending("Generating password") + let ignore16 = Process.exec("head -c 32 /dev/urandom | base64 > " + passwordFile + " && chmod 600 " + passwordFile) + printStep("Generating password", "done") + } else { + printStep("Generating password", "exists") + } + + printStepPending("Initializing repository") + let initResult = Process.exec("restic -r " + backupRepo + " --password-file " + passwordFile + " init 2>&1") + if String.contains(initResult, "created restic repository") || String.contains(initResult, "already initialized") then { + printStep("Initializing repository", "done") + let ignore17 = Process.exec("echo '" + backupRepo + "' > " + palDir() + "/restic/repository") + logEvent("onboard", "Backup: " + backupRepo) + } else { + printStepErr("Initializing repository", "failed") + printDim(initResult) + printHint("Try again later with:") + printCmd("pal backup init " + backupRepo) + } + } + } else { + printHint("Skipping backup setup.") + printHint("Set this up later with:") + printCmd("pal backup init ") + } + } + } + + print("") + + // ── Summary ───────────────────────────────────────────────────────── + showOnboardSummary(deviceName) + + if hasCommand("sqlite3") then { + logEvent("onboard", "Onboarding completed for " + deviceName) + } else () +} + +fn doOnboard(): Unit with {Process} = { + print("") + + if isInitialized() then { + printWarn("pal is already set up on this device.") + print("") + + // Show current state so user knows what exists + printBold("Current setup:") + print("") + + let deviceName = execQuiet("grep device_name " + palConfig() + " 2>/dev/null | cut -d'\"' -f2") + if String.length(deviceName) > 0 then + printStep("Device", deviceName) + else () + + let hasRepo = execQuiet("test -d " + palDir() + "/config-repo/.git && echo yes || echo no") + if hasRepo |> isYes then + printStep("Config repo", "configured") + else + printStepWarn("Config repo", "not set up") + + if palSyncthingRunning() then + printStep("Sync", "running") + else if hasCommand("syncthing") then + printStepWarn("Sync", "not running") + else + printStepErr("Sync", "not installed") + + let repo = getBackupRepo() + if String.length(repo) > 0 then + printStep("Backup", repo) + else + printStepWarn("Backup", "not configured") + + print("") + printWarn("Re-running will overwrite your device name and config file.") + printHint("Existing sync, backup, and repo settings will be kept if you skip those steps.") + print("") + + let rerun = askConfirm("Continue?", false) + if rerun then { + print("") + doOnboardSteps() + } else { + print("") + printHint("No changes made.") + print("") + } + } else { + showLogo() + printBold("Welcome! Let's get your system set up.") + print("") + printHint("This wizard will walk you through configuring:") + print(" - Device identity") + print(" - Config repository (git-managed)") + print(" - File sync (Syncthing)") + print(" - Backups (restic)") + print("") + doOnboardSteps() + } +} + // ============================================================================= // Version // ============================================================================= fn showVersion(): Unit with {Process} = { - let ignore = Process.exec("printf 'grapho 0.2.0\\n' >&2") + let ignore = Process.exec("printf 'pal 0.2.0\\n' >&2") () } @@ -1146,15 +1529,16 @@ fn showVersion(): Unit with {Process} = { fn showHelp(): Unit with {Process} = { print("") - let ignore = Process.exec("printf '\\033[1mgrapho\\033[0m - Your personal data, everywhere\\n' >&2") + let ignore = Process.exec("printf '\\033[1mpal\\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") + let ignore3 = Process.exec("printf ' pal [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 ignore5 = Process.exec("printf ' \\033[36msetup\\033[0m Initialize pal on this machine\\n' >&2") let ignore6 = Process.exec("printf ' \\033[36minit\\033[0m Clone config from git repository\\n' >&2") + let ignore6b = Process.exec("printf ' \\033[36monboard\\033[0m Interactive setup wizard\\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") @@ -1179,17 +1563,17 @@ fn showHelp(): Unit with {Process} = { 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()) + printHint("Data directory: " + palDir()) 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") + let ignore = Process.exec("printf '\\033[1mpal 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") + let ignore3 = Process.exec("printf ' pal 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") @@ -1201,10 +1585,10 @@ fn showSyncHelp(): Unit with {Process} = { fn showBackupHelp(): Unit with {Process} = { print("") - let ignore = Process.exec("printf '\\033[1mgrapho backup\\033[0m - Point-in-time snapshots\\n' >&2") + let ignore = Process.exec("printf '\\033[1mpal 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") + let ignore3 = Process.exec("printf ' pal 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") @@ -1212,18 +1596,18 @@ fn showBackupHelp(): Unit with {Process} = { let ignore7 = Process.exec("printf ' init 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") + let ignore9 = Process.exec("printf ' pal backup init /mnt/backup\\n' >&2") + let ignore10 = Process.exec("printf ' pal backup init sftp:user@server:/backups\\n' >&2") + let ignore11 = Process.exec("printf ' pal 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") + let ignore = Process.exec("printf '\\033[1mpal 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") + let ignore3 = Process.exec("printf ' pal 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") @@ -1258,6 +1642,7 @@ fn main(): Unit with {Process} = { "version" => showVersion(), "init" => doInit(subcmd), "setup" => doSetup(), + "onboard" => doOnboard(), "sync" => { match subcmd { "-h" => showSyncHelp(), @@ -1305,7 +1690,7 @@ fn main(): Unit with {Process} = { // Unknown command - suggest similar printErr("Unknown command: " + cmd) print("") - printHint("Run 'grapho --help' for usage.") + printHint("Run 'pal --help' for usage.") print("") } } diff --git a/docs/MARKDOWN-EDITORS.md b/docs/MARKDOWN-EDITORS.md index 48bac7c..6f8cbf9 100644 --- a/docs/MARKDOWN-EDITORS.md +++ b/docs/MARKDOWN-EDITORS.md @@ -1,6 +1,6 @@ -# Markdown Editors for grapho +# Markdown Editors for pal -This document covers recommended markdown editors for use with grapho across desktop and mobile platforms. +This document covers recommended markdown editors for use with pal across desktop and mobile platforms. ## Recommended: md (PWA) @@ -19,7 +19,7 @@ A lightweight, browser-based markdown editor that works on both desktop and mobi - Syntax highlighting for code blocks - Keyboard shortcuts (Ctrl+S to download, Ctrl+B/I for formatting) -### Why It's Good for grapho +### Why It's Good for pal - Works on any device with a browser - Can be installed as a PWA on mobile home screen - No account required @@ -156,7 +156,7 @@ The best open-source markdown editor for Android. - Supports markdown, todo.txt, and more - Offline-first -**Best for:** grapho users on Android. +**Best for:** pal users on Android. ### iA Writer (iOS/Android) **Paid** | [Website](https://ia.net/writer) @@ -187,12 +187,12 @@ Mobile companion to Obsidian desktop. --- -## Recommendation for grapho Users +## Recommendation for pal Users ### Simple Setup (Recommended) 1. **Desktop:** MarkText or VS Code 2. **Mobile:** md PWA (https://md-ashy.vercel.app) or Markor (Android) -3. **Sync:** Syncthing (already part of grapho) +3. **Sync:** Syncthing (already part of pal) ### Power User Setup 1. **Desktop:** Obsidian with Syncthing sync @@ -206,7 +206,7 @@ Mobile companion to Obsidian desktop. --- -## Integration with grapho +## Integration with pal All recommended editors work with plain markdown files, which means: @@ -225,7 +225,7 @@ marktext ~/.nb/home/meeting-notes.md # Or on mobile, open the same file via Syncthing folder # Sync happens automatically -grapho sync +pal sync ``` ## Sources diff --git a/flake.nix b/flake.nix index f2f56d6..8c6b9c7 100644 --- a/flake.nix +++ b/flake.nix @@ -42,7 +42,7 @@ # Shared modules for all hosts sharedModules = [ - ./modules/grapho.nix + ./modules/pal.nix ./modules/nb.nix ./modules/syncthing.nix ./modules/backup.nix @@ -66,36 +66,36 @@ }; in { - # Grapho CLI package + # Pal CLI package packages = forAllSystems (system: let pkgs = nixpkgsFor.${system}; luxPkg = lux.packages.${system}.default; in { - grapho = pkgs.stdenv.mkDerivation { - pname = "grapho"; + pal = pkgs.stdenv.mkDerivation { + pname = "pal"; version = "0.1.0"; src = ./cli; nativeBuildInputs = [ luxPkg pkgs.gcc ]; buildPhase = '' - ${luxPkg}/bin/lux compile grapho.lux -o grapho + ${luxPkg}/bin/lux compile pal.lux -o pal ''; installPhase = '' mkdir -p $out/bin - cp grapho $out/bin/ + cp pal $out/bin/ ''; meta = { description = "Personal data infrastructure CLI"; - homepage = "https://github.com/user/grapho"; + homepage = "https://github.com/user/pal"; license = pkgs.lib.licenses.mit; }; }; - default = self.packages.${system}.grapho; + default = self.packages.${system}.pal; } ); @@ -144,7 +144,7 @@ else echo "Error: Cannot find setup script" echo "Run from the repo directory, or clone it first:" - echo " git clone ssh://git@your-server:2222/you/grapho.git" + echo " git clone ssh://git@your-server:2222/you/pal.git" exit 1 fi fi @@ -173,6 +173,7 @@ packages = [ lux.packages.${system}.default + self.packages.${system}.pal ] ++ (with pkgs; [ # Tier 2: Notes & Sync nb # Notebook CLI @@ -203,12 +204,11 @@ ]); shellHook = '' - printf '\033[1m%s\033[0m\n' "Ultimate Notetaking, Sync & Backup System" + printf '\033[1m%s\033[0m\n' "pal - Your personal data, everywhere" echo "" - echo "Get started: ./setup" - echo "Pair mobile: ./setup mobile" - echo "Sync: ./scripts/usync" - echo "Status: ./scripts/ustatus" + echo "Get started: pal onboard" + echo "Status: pal status" + echo "Help: pal help" ''; }; } @@ -216,7 +216,7 @@ # Export modules for use in other flakes nixosModules = { - grapho = import ./modules/grapho.nix; + pal = import ./modules/pal.nix; nb = import ./modules/nb.nix; syncthing = import ./modules/syncthing.nix; backup = import ./modules/backup.nix; diff --git a/modules/grapho.nix b/modules/pal.nix similarity index 75% rename from modules/grapho.nix rename to modules/pal.nix index 2c76514..8ab026c 100644 --- a/modules/grapho.nix +++ b/modules/pal.nix @@ -1,39 +1,39 @@ -# Grapho Module +# Pal 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"; +# services.pal.enable = true; +# services.pal.user = "youruser"; # # This creates: -# - ~/.config/grapho/ directory structure +# - ~/.config/pal/ directory structure # - Isolated Syncthing on port 8385 (separate from system Syncthing) -# - Restic backup timer for grapho data +# - Restic backup timer for pal data { config, lib, pkgs, ... }: with lib; let - cfg = config.services.grapho; + cfg = config.services.pal; home = config.users.users.${cfg.user}.home; - graphoDir = "${home}/.config/grapho"; + palDir = "${home}/.config/pal"; in { - options.services.grapho = { - enable = mkEnableOption "grapho personal data infrastructure"; + options.services.pal = { + enable = mkEnableOption "pal personal data infrastructure"; user = mkOption { type = types.str; - description = "User to run grapho services as."; + description = "User to run pal services as."; example = "alice"; }; group = mkOption { type = types.str; default = "users"; - description = "Group to run grapho services as."; + description = "Group to run pal services as."; }; # Syncthing options @@ -41,7 +41,7 @@ in { enable = mkOption { type = types.bool; default = true; - description = "Enable grapho's isolated Syncthing instance."; + description = "Enable pal's isolated Syncthing instance."; }; guiPort = mkOption { @@ -65,7 +65,7 @@ in { openFirewall = mkOption { type = types.bool; default = true; - description = "Open firewall for grapho's Syncthing ports."; + description = "Open firewall for pal's Syncthing ports."; }; devices = mkOption { @@ -120,14 +120,14 @@ in { enable = mkOption { type = types.bool; default = false; - description = "Enable restic backup of grapho data."; + description = "Enable restic backup of pal data."; }; repository = mkOption { type = types.str; default = ""; - description = "Restic repository location (e.g., 'sftp:server:/backups/grapho')."; - example = "sftp:backup-server:/backups/grapho"; + description = "Restic repository location (e.g., 'sftp:server:/backups/pal')."; + example = "sftp:backup-server:/backups/pal"; }; passwordFile = mkOption { @@ -195,27 +195,27 @@ in { isNormalUser = true; }; - # Create grapho directory structure + # Create pal 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} -" + "d ${palDir} 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/config-repo 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/syncthing/config 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/syncthing/db 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/sync 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/sync/notes 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/sync/documents 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/sync/dotfiles 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/restic/cache 0755 ${cfg.user} ${cfg.group} -" + "d ${palDir}/server 0755 ${cfg.user} ${cfg.group} -" ]; - # Isolated Syncthing for grapho + # Isolated Syncthing for pal services.syncthing = mkIf cfg.syncthing.enable { enable = true; user = cfg.user; group = cfg.group; - dataDir = "${graphoDir}/sync"; - configDir = "${graphoDir}/syncthing/config"; + dataDir = "${palDir}/sync"; + configDir = "${palDir}/syncthing/config"; guiAddress = "127.0.0.1:${toString cfg.syncthing.guiPort}"; overrideDevices = true; @@ -228,24 +228,24 @@ in { }) cfg.syncthing.devices; folders = { - # Default grapho folders - "grapho-notes" = { - path = "${graphoDir}/sync/notes"; - id = "grapho-notes"; + # Default pal folders + "pal-notes" = { + path = "${palDir}/sync/notes"; + id = "pal-notes"; devices = attrNames cfg.syncthing.devices; type = "sendreceive"; fsWatcherEnabled = true; }; - "grapho-documents" = { - path = "${graphoDir}/sync/documents"; - id = "grapho-documents"; + "pal-documents" = { + path = "${palDir}/sync/documents"; + id = "pal-documents"; devices = attrNames cfg.syncthing.devices; type = "sendreceive"; fsWatcherEnabled = true; }; - "grapho-dotfiles" = { - path = "${graphoDir}/sync/dotfiles"; - id = "grapho-dotfiles"; + "pal-dotfiles" = { + path = "${palDir}/sync/dotfiles"; + id = "pal-dotfiles"; devices = attrNames cfg.syncthing.devices; type = "sendreceive"; fsWatcherEnabled = true; @@ -271,15 +271,15 @@ in { }; }; - # Firewall for grapho Syncthing + # Firewall for pal 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"; + systemd.services.pal-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { + description = "Pal data backup"; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; @@ -288,18 +288,18 @@ in { User = cfg.user; Group = cfg.group; ExecStart = let - paths = [ "${graphoDir}/sync" ] ++ cfg.backup.extraPaths; + paths = [ "${palDir}/sync" ] ++ cfg.backup.extraPaths; pathArgs = concatMapStringsSep " " (p: "'${p}'") paths; in '' ${pkgs.restic}/bin/restic backup \ - --cache-dir ${graphoDir}/restic/cache \ + --cache-dir ${palDir}/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 \ + --cache-dir ${palDir}/restic/cache \ ${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \ -r ${cfg.backup.repository} \ ${concatStringsSep " " cfg.backup.pruneOpts} @@ -307,8 +307,8 @@ in { }; }; - systemd.timers.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { - description = "Grapho backup timer"; + systemd.timers.pal-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { + description = "Pal backup timer"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = cfg.backup.schedule; @@ -318,7 +318,7 @@ in { }; # Server mount (NFS) - fileSystems."${graphoDir}/server" = mkIf (cfg.server.enable && cfg.server.type == "nfs" && cfg.server.host != "") { + fileSystems."${palDir}/server" = mkIf (cfg.server.enable && cfg.server.type == "nfs" && cfg.server.host != "") { device = "${cfg.server.host}:${cfg.server.remotePath}"; fsType = "nfs"; options = [ @@ -330,7 +330,7 @@ in { ]; }; - # Install grapho CLI and dependencies + # Install pal CLI and dependencies environment.systemPackages = with pkgs; [ syncthing restic diff --git a/setup b/setup index 5e2d030..76cf96c 100755 --- a/setup +++ b/setup @@ -133,7 +133,7 @@ EOF age_key=$(grep 'AGE-SECRET-KEY' "$AGE_KEY_FILE") # Build join command for other devices - local join_cmd="nix run '' -- '${config_url}' '${age_key}'" + local join_cmd="nix run '' -- '${config_url}' '${age_key}'" # Summary echo ""