diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 6288c72..257f637 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -16,6 +16,7 @@ use heph_core::{Node, RankedTask, Task}; use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; mod datespec; +mod service; #[derive(Parser, Debug)] #[command(name = "heph", version, about)] @@ -220,6 +221,11 @@ enum Command { /// Destination directory (created if needed). dir: PathBuf, }, + /// Manage the hephd daemon as an OS service (launchd / systemd). + Daemon { + #[command(subcommand)] + action: service::DaemonAction, + }, /// Authenticate this device with a sync hub (OAuth 2.0 device-code flow). Auth { #[command(subcommand)] @@ -344,6 +350,10 @@ fn run_auth(action: AuthAction) -> Result<()> { fn main() -> Result<()> { let cli = Cli::parse(); + // `daemon` manages the OS service; it must not connect to a daemon. + if let Command::Daemon { action } = &cli.command { + return service::run(action); + } // `auth` runs locally (device-code flow + keyring); it needs no daemon. if let Command::Auth { action } = cli.command { return run_auth(action); @@ -626,6 +636,7 @@ fn main() -> Result<()> { println!("Exported {count} nodes to {}", dir.display()); } Command::Auth { .. } => unreachable!("auth is handled before connecting"), + Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), } Ok(()) } diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs new file mode 100644 index 0000000..3e8f96c --- /dev/null +++ b/crates/heph/src/service.rs @@ -0,0 +1,414 @@ +//! `heph daemon` — manage `hephd` as an OS service (tech-spec §3, [[design]] §4). +//! +//! Surfaces are connect-only; the daemon runs as an explicit service so it can +//! be shared by the CLI, TUI, and `heph.nvim` without any one of them owning its +//! lifecycle. macOS uses a launchd **LaunchAgent**, Linux a **systemd user +//! service**. All verbs are idempotent. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use clap::Subcommand; + +use hephd::{default_db_path, default_socket_path}; + +/// launchd label / systemd-independent identifier. +const LABEL: &str = "org.hephaestus.hephd"; + +#[derive(Subcommand, Debug)] +pub enum DaemonAction { + /// Install (if needed) and start the daemon service. + Start, + /// Stop the daemon now (it may restart at next login; use `uninstall` to + /// stop it for good). + Stop, + /// Restart the daemon — run this after upgrading the binary. + Restart, + /// Show whether the service is installed and running. + Status, + /// Stop and remove the service entirely. + Uninstall, +} + +/// Resolved locations the service definition needs. +struct Paths { + hephd: PathBuf, + db: PathBuf, + socket: PathBuf, + log: PathBuf, +} + +fn paths() -> Result { + let db = default_db_path(); + let log = db + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("hephd.log"); + Ok(Paths { + hephd: resolve_hephd()?, + db, + socket: default_socket_path(), + log, + }) +} + +/// Find `hephd`: next to the running `heph` binary first, else on `PATH`. +fn resolve_hephd() -> Result { + if let Ok(exe) = std::env::current_exe() { + if let Some(sib) = exe.parent().map(|d| d.join("hephd")) { + if sib.is_file() { + return Ok(sib); + } + } + } + which("hephd").context("`hephd` not found next to `heph` or on PATH") +} + +/// Minimal `which`: first PATH entry containing an executable `name`. +fn which(name: &str) -> Result { + let path = std::env::var_os("PATH").context("PATH is unset")?; + for dir in std::env::split_paths(&path) { + let cand = dir.join(name); + if cand.is_file() { + return Ok(cand); + } + } + bail!("{name} not found on PATH") +} + +enum Manager { + Launchd, + Systemd, +} + +fn manager() -> Result { + if cfg!(target_os = "macos") { + Ok(Manager::Launchd) + } else if cfg!(target_os = "linux") { + if Path::new("/run/systemd/system").exists() { + Ok(Manager::Systemd) + } else { + bail!("no systemd detected — run `hephd` yourself (e.g. via your init system)") + } + } else { + bail!("`heph daemon` supports macOS (launchd) and systemd Linux; run `hephd` yourself") + } +} + +pub fn run(action: &DaemonAction) -> Result<()> { + let p = paths()?; + match manager()? { + Manager::Launchd => launchd(action, &p), + Manager::Systemd => systemd(action, &p), + } +} + +// -------------------------------------------------------------------------- +// Rendering (pure — unit-tested) +// -------------------------------------------------------------------------- + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { + let arg = |p: &Path| xml_escape(&p.to_string_lossy()); + format!( + r#" + + + + Label + {label} + ProgramArguments + + {hephd} + --mode + local + --db + {db} + --socket + {socket} + + RunAtLoad + + KeepAlive + + StandardOutPath + {log} + StandardErrorPath + {log} + + +"#, + label = LABEL, + hephd = arg(hephd), + db = arg(db), + socket = arg(socket), + log = arg(log), + ) +} + +fn systemd_unit(hephd: &Path, db: &Path, socket: &Path) -> String { + format!( + "[Unit]\n\ + Description=heph daemon (hephd)\n\ + After=default.target\n\ + \n\ + [Service]\n\ + ExecStart={hephd} --mode local --db {db} --socket {socket}\n\ + Restart=on-failure\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + hephd = hephd.display(), + db = db.display(), + socket = socket.display(), + ) +} + +// -------------------------------------------------------------------------- +// Shared helpers +// -------------------------------------------------------------------------- + +/// Write `contents` to `path` (creating parents) only if it differs — keeps the +/// service file stable so reloads aren't forced needlessly. Returns whether it +/// changed. +fn write_if_changed(path: &Path, contents: &str) -> Result { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + if std::fs::read_to_string(path).ok().as_deref() == Some(contents) { + return Ok(false); + } + std::fs::write(path, contents)?; + Ok(true) +} + +/// Run a command, returning success and captured stderr (for benign-failure +/// tolerance like "service not loaded"). +fn run_cmd(prog: &str, args: &[&str]) -> Result<(bool, String)> { + let out = Command::new(prog) + .args(args) + .output() + .with_context(|| format!("failed to run `{prog}`"))?; + Ok(( + out.status.success(), + String::from_utf8_lossy(&out.stderr).into_owned(), + )) +} + +// -------------------------------------------------------------------------- +// launchd (macOS) +// -------------------------------------------------------------------------- + +fn launchd_plist_path() -> Result { + let home = std::env::var_os("HOME").context("HOME is unset")?; + Ok(PathBuf::from(home) + .join("Library/LaunchAgents") + .join(format!("{LABEL}.plist"))) +} + +fn uid() -> Result { + let out = Command::new("id") + .arg("-u") + .output() + .context("failed to run `id -u`")?; + if !out.status.success() { + bail!("could not determine uid"); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn launchd_loaded(domain_target: &str) -> bool { + run_cmd("launchctl", &["print", domain_target]) + .map(|(ok, _)| ok) + .unwrap_or(false) +} + +fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { + let plist = launchd_plist_path()?; + let uid = uid()?; + let domain = format!("gui/{uid}"); + let target = format!("gui/{uid}/{LABEL}"); + + match action { + DaemonAction::Start => { + write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; + if launchd_loaded(&target) { + println!("heph daemon already running ({LABEL})."); + } else { + let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; + if !ok { + bail!("launchctl bootstrap failed: {}", err.trim()); + } + println!("heph daemon started ({LABEL})."); + } + } + DaemonAction::Stop => { + let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?; + println!("heph daemon stopped (still installed; `uninstall` to remove)."); + } + DaemonAction::Restart => { + write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; + let _ = run_cmd("launchctl", &["bootout", &target])?; + let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; + if !ok { + bail!("launchctl bootstrap failed: {}", err.trim()); + } + println!("heph daemon restarted ({LABEL})."); + } + DaemonAction::Status => { + let installed = plist.exists(); + let running = launchd_loaded(&target); + print_status(installed, running, p, &plist); + } + DaemonAction::Uninstall => { + let _ = run_cmd("launchctl", &["bootout", &target])?; + if plist.exists() { + std::fs::remove_file(&plist)?; + } + println!("heph daemon uninstalled ({LABEL})."); + } + } + Ok(()) +} + +fn plist_str(p: &Path) -> Result { + Ok(p.to_str() + .context("plist path is not valid UTF-8")? + .to_string()) +} + +// -------------------------------------------------------------------------- +// systemd (Linux, user service) +// -------------------------------------------------------------------------- + +const UNIT: &str = "heph.service"; + +fn systemd_unit_path() -> Result { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .context("HOME/XDG_CONFIG_HOME unset")?; + Ok(base.join("systemd/user").join(UNIT)) +} + +fn sc(args: &[&str]) -> Result<(bool, String)> { + let mut full = vec!["--user"]; + full.extend_from_slice(args); + run_cmd("systemctl", &full) +} + +fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { + let unit = systemd_unit_path()?; + match action { + DaemonAction::Start => { + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?; + sc(&["daemon-reload"])?; + let (ok, err) = sc(&["enable", "--now", UNIT])?; + if !ok { + bail!("systemctl enable --now failed: {}", err.trim()); + } + println!("heph daemon started ({UNIT})."); + } + DaemonAction::Stop => { + sc(&["stop", UNIT])?; + println!("heph daemon stopped (still enabled; `uninstall` to remove)."); + } + DaemonAction::Restart => { + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?; + sc(&["daemon-reload"])?; + let (ok, err) = sc(&["restart", UNIT])?; + if !ok { + bail!("systemctl restart failed: {}", err.trim()); + } + println!("heph daemon restarted ({UNIT})."); + } + DaemonAction::Status => { + let installed = unit.exists(); + let running = sc(&["is-active", UNIT]).map(|(ok, _)| ok).unwrap_or(false); + print_status(installed, running, p, &unit); + } + DaemonAction::Uninstall => { + sc(&["disable", "--now", UNIT])?; + if unit.exists() { + std::fs::remove_file(&unit)?; + } + sc(&["daemon-reload"])?; + println!("heph daemon uninstalled ({UNIT})."); + } + } + Ok(()) +} + +fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path) { + println!("installed : {}", if installed { "yes" } else { "no" }); + println!("running : {}", if running { "yes" } else { "no" }); + println!("service : {}", service_file.display()); + println!("hephd : {}", p.hephd.display()); + println!("db : {}", p.db.display()); + println!("socket : {}", p.socket.display()); + println!("log : {}", p.log.display()); + if !running { + println!("\n(start it with `heph daemon start`)"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launchd_plist_has_label_args_and_paths() { + let plist = launchd_plist( + Path::new("/usr/local/bin/hephd"), + Path::new("/home/e/.local/share/heph/heph.db"), + Path::new("/tmp/heph/hephd.sock"), + Path::new("/home/e/.local/share/heph/hephd.log"), + ); + assert!(plist.contains("org.hephaestus.hephd")); + assert!(plist.contains("/usr/local/bin/hephd")); + assert!(plist.contains("--mode")); + assert!(plist.contains("/home/e/.local/share/heph/heph.db")); + assert!(plist.contains("/tmp/heph/hephd.sock")); + assert!(plist.contains("RunAtLoad")); + assert!(plist.contains("KeepAlive")); + assert!(plist.contains("hephd.log")); + } + + #[test] + fn systemd_unit_has_execstart_and_install() { + let unit = systemd_unit( + Path::new("/usr/local/bin/hephd"), + Path::new("/home/e/.local/share/heph/heph.db"), + Path::new("/run/user/1000/heph/hephd.sock"), + ); + assert!(unit.contains( + "ExecStart=/usr/local/bin/hephd --mode local \ + --db /home/e/.local/share/heph/heph.db \ + --socket /run/user/1000/heph/hephd.sock" + )); + assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("WantedBy=default.target")); + } + + #[test] + fn xml_escape_escapes_markup() { + assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d"); + } + + #[test] + fn write_if_changed_is_idempotent() { + let dir = std::env::temp_dir().join(format!("heph-svc-test-{}", std::process::id())); + let f = dir.join("unit"); + let _ = std::fs::remove_dir_all(&dir); + assert!(write_if_changed(&f, "one").unwrap(), "first write changes"); + assert!(!write_if_changed(&f, "one").unwrap(), "same content no-op"); + assert!(write_if_changed(&f, "two").unwrap(), "new content changes"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 60f7de3..ef80eb1 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -42,12 +42,22 @@ pub(crate) fn blocking_agent() -> ureq::Agent { } /// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to -/// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3). +/// a **stable** path next to the data store (`/heph/hephd.sock`) when +/// `XDG_RUNTIME_DIR` is unset — notably on macOS (tech-spec §3). +/// +/// The fallback is deliberately *not* the system temp dir: the daemon is a +/// long-lived OS service ([[design]] §4), and a temp-dir socket is fragile (it +/// varies per login session and macOS periodically prunes `/var/folders`). A +/// path beside the DB is deterministic across every process and reboot, so the +/// service and its clients always agree. pub fn default_socket_path() -> PathBuf { - let base = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - base.join("heph").join("hephd.sock") + if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(rt).join("heph").join("hephd.sock"); + } + default_db_path() + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("hephd.sock") } /// Default store path: `$XDG_DATA_HOME/heph/heph.db`, falling back to diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 2ebf50d..21c9aa2 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -358,7 +358,9 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. - ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). - ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. -- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. +- ✅ **CLI (§1) — the complete daemon API + task driver:** `heph` covers every RPC (next/list/task/done/drop/skip/attention/**edit**/promote/show/log/health/doc/node/get/resolve/search/journal/links/backlinks/link/project[+list]/sync/conflicts/export/auth) with **human dates** (`--do-date tomorrow|+3d|fri|ISO`) and **recurrence** (presets + NL + raw `--rrule`; `datespec`). Reschedule is the new **`task.set_schedule`** RPC. Process-tested over a real socket. +- ✅ **Daemon as an OS service (§3, [[design]] §4):** **`heph daemon start/stop/restart/status/uninstall`** idempotently manages a launchd agent (macOS) / systemd user service (Linux) running `hephd` on the default store; render fns unit-tested, verified live on macOS. The default socket is now a **stable** `/heph/hephd.sock` (was `$TMPDIR`-based) so a persistent service and its clients always agree. Surfaces are **connect-only** (no auto-spawn). +- ✅ **Todoist importer (tooling):** `mise run import-todoist` seeds a store from Todoist (dry-run default, `-- --commit` to write) — see [[import-todoist]]. - ✅ **CI (§9):** Forgejo `build.yaml` runs **entirely through Dagger** (the k8s job image is a thin Alpine + Dagger orchestrator with a DinD sidecar — no native Rust/nvim toolchain): `dagger call check` (cargo fmt/clippy/test on `rust:1-bookworm`) + `dagger call test-nvim` (build hephd + headless e2e). Cargo parallelism capped (`CARGO_BUILD_JOBS`) to avoid OOMing the build engine; cargo caches shared across runs. `prek` runs locally via git hooks, not in CI. - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal `, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). - ✅ **`heph.nvim` slice 11b (§8) — task views:** **`list` enriched** to titled [`RankedTask`] rows (title + `canonical_context_id`, shared `ranked_from_row` with `next`) so the Organizational view needs no N+1 `node.get`. Plugin: **Tactical `next`** + **Organizational `list`** views (rendered scratch buffers, `` opens a row's canonical-context doc — the node autocmd narrowed to `heph://node/*` so view buffers don't trip it); **task capture**, **set-attention**, **done/drop**, **skip**, **per-task `log` append** — all resolving "the current task" from the buffer (a `task` node, or a context doc via its `canonical-context` backlink); **`vim.ui.select` picker** (`picker.lua`) with Telescope auto-upgrade; `:Heph next/list/capture/attention/done/drop/skip/log/search` subcommands. e2e specs: **capture→next→open context→add/check checklist→done**, and **recurring fresh-checklist** (complete rolls forward in place; the next occurrence is all-unchecked — the §4.4 hard requirement).