diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs index 7b15865..1c90924 100644 --- a/crates/heph/src/service.rs +++ b/crates/heph/src/service.rs @@ -4,12 +4,18 @@ //! 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. +//! +//! The service generator bakes the daemon's runtime config — mode, sync hub, +//! and OIDC — into the unit so a spoke/hub can run under the managed service +//! instead of a hand-written plist/unit. Regenerating (`start`/`restart`) +//! **preserves any config already baked into the on-disk file**, so a bare +//! invocation never silently drops flags a previous one set. use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{bail, Context, Result}; -use clap::Subcommand; +use clap::{Args, Subcommand}; use hephd::{default_db_path, default_socket_path}; @@ -19,28 +25,106 @@ const LABEL: &str = "org.hephaestus.hephd"; #[derive(Subcommand, Debug)] pub enum DaemonAction { /// Install (if needed) and start the daemon service. - Start { - /// Generate a service that runs with opt-in self-update enabled - /// (default off). The service gets a PATH that can find cargo. - #[arg(long)] - self_update: bool, - }, + Start(ServiceArgs), /// 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. Preserves the - /// existing self-update setting unless `--self-update` re-enables it. - Restart { - /// Force self-update on when regenerating the service definition. - #[arg(long)] - self_update: bool, - }, + /// config already baked into the service file (pass flags to add/override). + Restart(ServiceArgs), /// Show whether the service is installed and running. Status, /// Stop and remove the service entirely. Uninstall, } +/// Config flags baked into the generated service, shared by `start`/`restart`. +/// Anything omitted falls back to what the on-disk service file already has, so +/// regenerating is non-destructive. +#[derive(Args, Debug)] +pub struct ServiceArgs { + /// Runtime mode baked into the service (default `local`). Use `server` for a + /// sync hub, `client` for an online-only proxy. + #[arg(long, value_parser = ["local", "server", "client"])] + mode: Option, + /// Hub to background-sync this replica's op-log with (makes it a spoke) — + /// bakes `--hub-url`. + #[arg(long)] + hub_url: Option, + /// Hub HTTP listen address (server mode) — bakes `--http-addr`. + #[arg(long)] + http_addr: Option, + /// OIDC issuer used to verify (server) or obtain (spoke) hub tokens — bakes + /// `--oidc-issuer`. + #[arg(long)] + oidc_issuer: Option, + /// OIDC audience hub tokens must carry (server mode) — bakes + /// `--oidc-audience`. + #[arg(long)] + oidc_audience: Option, + /// OIDC client id this device authenticates as (spoke) — bakes + /// `--oidc-client-id`. + #[arg(long)] + oidc_client_id: Option, + /// Generate a service that runs with opt-in self-update enabled (default + /// off). The service gets a PATH that can find cargo. + #[arg(long)] + self_update: bool, + /// Override the self-update poll interval, in seconds (default: 6h). Only + /// meaningful with --self-update. + #[arg(long)] + self_update_interval_secs: Option, +} + +/// The hephd flags the service generator bakes beyond the fixed `--db`/`--socket`. +#[derive(Default, Clone, PartialEq, Debug)] +struct DaemonConfig { + mode: Option, + hub_url: Option, + http_addr: Option, + oidc_issuer: Option, + oidc_audience: Option, + oidc_client_id: Option, + self_update: bool, + self_update_interval_secs: Option, +} + +impl ServiceArgs { + fn to_config(&self) -> DaemonConfig { + DaemonConfig { + mode: self.mode.clone(), + hub_url: self.hub_url.clone(), + http_addr: self.http_addr.clone(), + oidc_issuer: self.oidc_issuer.clone(), + oidc_audience: self.oidc_audience.clone(), + oidc_client_id: self.oidc_client_id.clone(), + self_update: self.self_update, + self_update_interval_secs: self.self_update_interval_secs, + } + } +} + +impl DaemonConfig { + /// CLI-provided values win; anything omitted falls back to `base` (the flags + /// already baked into the on-disk service file), so regenerating the service + /// never drops config a previous invocation set. `self_update` is sticky — + /// it stays on if either the CLI or the existing file enabled it. + fn fill_from(self, base: DaemonConfig) -> DaemonConfig { + DaemonConfig { + mode: self.mode.or(base.mode), + hub_url: self.hub_url.or(base.hub_url), + http_addr: self.http_addr.or(base.http_addr), + oidc_issuer: self.oidc_issuer.or(base.oidc_issuer), + oidc_audience: self.oidc_audience.or(base.oidc_audience), + oidc_client_id: self.oidc_client_id.or(base.oidc_client_id), + self_update: self.self_update || base.self_update, + self_update_interval_secs: self + .self_update_interval_secs + .or(base.self_update_interval_secs), + } + } +} + /// Resolved locations the service definition needs. struct Paths { hephd: PathBuf, @@ -114,6 +198,105 @@ pub fn run(action: &DaemonAction) -> Result<()> { } } +// -------------------------------------------------------------------------- +// hephd argument vector (pure — shared by both renderers and unit-tested) +// -------------------------------------------------------------------------- + +/// The full `hephd …` argument vector the service runs, given the resolved paths +/// and baked config. `--mode` defaults to `local`; the optional flags appear in +/// a stable order so regenerating an unchanged config produces an identical file. +fn hephd_args(hephd: &Path, db: &Path, socket: &Path, cfg: &DaemonConfig) -> Vec { + let mut a = vec![ + hephd.to_string_lossy().into_owned(), + "--mode".into(), + cfg.mode.clone().unwrap_or_else(|| "local".into()), + "--db".into(), + db.to_string_lossy().into_owned(), + "--socket".into(), + socket.to_string_lossy().into_owned(), + ]; + push_opt(&mut a, "--hub-url", &cfg.hub_url); + push_opt(&mut a, "--http-addr", &cfg.http_addr); + push_opt(&mut a, "--oidc-issuer", &cfg.oidc_issuer); + push_opt(&mut a, "--oidc-audience", &cfg.oidc_audience); + push_opt(&mut a, "--oidc-client-id", &cfg.oidc_client_id); + // Interval is only meaningful with --self-update, so it's nested under it. + if cfg.self_update { + a.push("--self-update".into()); + if let Some(secs) = cfg.self_update_interval_secs { + a.push("--self-update-interval-secs".into()); + a.push(secs.to_string()); + } + } + a +} + +fn push_opt(args: &mut Vec, flag: &str, val: &Option) { + if let Some(v) = val { + args.push(flag.to_string()); + args.push(v.clone()); + } +} + +/// Parse a `hephd` argument vector back into a [`DaemonConfig`] — the inverse of +/// [`hephd_args`], used to recover config already baked into an on-disk service +/// file. Unrecognized args (the binary path, `--db`, `--socket`) are ignored. +fn parse_hephd_args(args: &[String]) -> DaemonConfig { + let mut c = DaemonConfig::default(); + let mut i = 0; + while i < args.len() { + let next = || args.get(i + 1).cloned(); + match args[i].as_str() { + "--mode" => { + c.mode = next(); + i += 2; + } + "--hub-url" => { + c.hub_url = next(); + i += 2; + } + "--http-addr" => { + c.http_addr = next(); + i += 2; + } + "--oidc-issuer" => { + c.oidc_issuer = next(); + i += 2; + } + "--oidc-audience" => { + c.oidc_audience = next(); + i += 2; + } + "--oidc-client-id" => { + c.oidc_client_id = next(); + i += 2; + } + "--self-update" => { + c.self_update = true; + i += 1; + } + "--self-update-interval-secs" => { + c.self_update_interval_secs = next().and_then(|s| s.parse().ok()); + i += 2; + } + _ => i += 1, + } + } + c +} + +/// Recover the config baked into an existing service file (empty if absent). +fn existing_config(path: &Path, mgr: &Manager) -> DaemonConfig { + let Ok(s) = std::fs::read_to_string(path) else { + return DaemonConfig::default(); + }; + let args = match mgr { + Manager::Launchd => launchd_program_args(&s), + Manager::Systemd => systemd_exec_args(&s), + }; + parse_hephd_args(&args) +} + // -------------------------------------------------------------------------- // Rendering (pure — unit-tested) // -------------------------------------------------------------------------- @@ -124,17 +307,22 @@ fn xml_escape(s: &str) -> String { .replace('>', ">") } -fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update: bool) -> String { - let arg = |p: &Path| xml_escape(&p.to_string_lossy()); - // Opt-in self-update: pass the flag, and give the service a PATH/HOME that - // can find cargo + the toolchain (a LaunchAgent's default env can't), since - // the apply path shells out to `cargo install`. - let self_update_arg = if self_update { - "\n --self-update".to_string() - } else { - String::new() - }; - let cargo_env = if self_update { +fn xml_unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, cfg: &DaemonConfig) -> String { + let args_xml = hephd_args(hephd, db, socket, cfg) + .iter() + .map(|a| format!(" {}", xml_escape(a))) + .collect::>() + .join("\n"); + // Opt-in self-update needs a PATH/HOME that can find cargo + the toolchain + // (a LaunchAgent's default env can't), since the apply path shells out to + // `cargo install`. + let cargo_env = if cfg.self_update { let (path, home) = cargo_env(); format!( "\n PATH\n {}\n HOME\n {}", @@ -153,13 +341,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update {label} ProgramArguments - {hephd} - --mode - local - --db - {db} - --socket - {socket}{self_update_arg} +{args_xml} RunAtLoad @@ -181,10 +363,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update "#, label = LABEL, - hephd = arg(hephd), - db = arg(db), - socket = arg(socket), - log = arg(log), + log = xml_escape(&log.to_string_lossy()), ) } @@ -199,20 +378,34 @@ fn cargo_env() -> (String, String) { (path, home) } -/// Whether an already-installed service file opted into self-update — so -/// `restart` (which regenerates the file) preserves the setting instead of -/// silently turning it off. -fn file_opts_into_self_update(path: &Path) -> bool { - std::fs::read_to_string(path) - .map(|s| s.contains("--self-update")) - .unwrap_or(false) +/// Extract the `ProgramArguments` strings from an existing launchd plist. +fn launchd_program_args(plist: &str) -> Vec { + let Some(k) = plist.find("ProgramArguments") else { + return vec![]; + }; + let rest = &plist[k..]; + let (Some(start), Some(end)) = (rest.find(""), rest.find("")) else { + return vec![]; + }; + let block = &rest[start..end]; + let mut out = vec![]; + let mut cur = block; + while let Some(o) = cur.find("") { + let after = &cur[o + "".len()..]; + let Some(c) = after.find("") else { + break; + }; + out.push(xml_unescape(&after[..c])); + cur = &after[c + "".len()..]; + } + out } -fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> String { - // Opt-in self-update: pass the flag and give the unit a PATH/HOME that can - // find cargo + the toolchain, since the apply path runs `cargo install`. - let su_arg = if self_update { " --self-update" } else { "" }; - let cargo_env = if self_update { +fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, cfg: &DaemonConfig) -> String { + let exec = hephd_args(hephd, db, socket, cfg).join(" "); + // Opt-in self-update needs a PATH/HOME that can find cargo + the toolchain, + // since the apply path runs `cargo install`. + let cargo_env = if cfg.self_update { let (path, home) = cargo_env(); format!("Environment=PATH={path}\nEnvironment=HOME={home}\n") } else { @@ -224,19 +417,24 @@ fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> St After=default.target\n\ \n\ [Service]\n\ - ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\ + ExecStart={exec}\n\ {cargo_env}\ Restart=always\n\ RestartSec=1\n\ \n\ [Install]\n\ WantedBy=default.target\n", - hephd = hephd.display(), - db = db.display(), - socket = socket.display(), ) } +/// Extract the `ExecStart=` argument vector from an existing systemd unit. +fn systemd_exec_args(unit: &str) -> Vec { + unit.lines() + .find_map(|l| l.strip_prefix("ExecStart=")) + .map(|rest| rest.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default() +} + // -------------------------------------------------------------------------- // Shared helpers // -------------------------------------------------------------------------- @@ -303,10 +501,13 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let target = format!("gui/{uid}/{LABEL}"); match action { - DaemonAction::Start { self_update } => { + DaemonAction::Start(args) => { + let cfg = args + .to_config() + .fill_from(existing_config(&plist, &Manager::Launchd)); write_if_changed( &plist, - &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, *self_update), + &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg), )?; if launchd_loaded(&target) { println!("heph daemon already running ({LABEL})."); @@ -322,11 +523,13 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?; println!("heph daemon stopped (still installed; `uninstall` to remove)."); } - DaemonAction::Restart { self_update } => { - let su = *self_update || file_opts_into_self_update(&plist); + DaemonAction::Restart(args) => { + let cfg = args + .to_config() + .fill_from(existing_config(&plist, &Manager::Launchd)); write_if_changed( &plist, - &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, su), + &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg), )?; let _ = run_cmd("launchctl", &["bootout", &target])?; let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; @@ -380,11 +583,11 @@ fn sc(args: &[&str]) -> Result<(bool, String)> { fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { let unit = systemd_unit_path()?; match action { - DaemonAction::Start { self_update } => { - write_if_changed( - &unit, - &systemd_unit(&p.hephd, &p.db, &p.socket, *self_update), - )?; + DaemonAction::Start(args) => { + let cfg = args + .to_config() + .fill_from(existing_config(&unit, &Manager::Systemd)); + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, &cfg))?; sc(&["daemon-reload"])?; let (ok, err) = sc(&["enable", "--now", UNIT])?; if !ok { @@ -396,9 +599,11 @@ fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { sc(&["stop", UNIT])?; println!("heph daemon stopped (still enabled; `uninstall` to remove)."); } - DaemonAction::Restart { self_update } => { - let su = *self_update || file_opts_into_self_update(&unit); - write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, su))?; + DaemonAction::Restart(args) => { + let cfg = args + .to_config() + .fill_from(existing_config(&unit, &Manager::Systemd)); + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, &cfg))?; sc(&["daemon-reload"])?; let (ok, err) = sc(&["restart", UNIT])?; if !ok { @@ -440,6 +645,18 @@ fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path) mod tests { use super::*; + fn spoke_cfg() -> DaemonConfig { + DaemonConfig { + mode: Some("local".into()), + hub_url: Some("http://hub.example:8787".into()), + oidc_issuer: Some("https://idp.example/o/heph/".into()), + oidc_client_id: Some("heph".into()), + self_update: true, + self_update_interval_secs: Some(600), + ..Default::default() + } + } + #[test] fn launchd_plist_has_label_args_and_paths() { let plist = launchd_plist( @@ -447,19 +664,21 @@ mod tests { Path::new("/home/e/.local/share/heph/heph.db"), Path::new("/tmp/heph/hephd.sock"), Path::new("/home/e/.local/share/heph/hephd.log"), - false, + &DaemonConfig::default(), ); assert!(plist.contains("org.hephaestus.hephd")); assert!(plist.contains("/usr/local/bin/hephd")); assert!(plist.contains("--mode")); + assert!(plist.contains("local")); 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")); - // Default (no self-update): no flag, no cargo PATH baked in. + // Default (no self-update, no spoke/hub config): none of those flags. assert!(!plist.contains("--self-update")); assert!(!plist.contains(".cargo/bin")); + assert!(!plist.contains("--hub-url")); } #[test] @@ -469,12 +688,64 @@ mod tests { Path::new("/db"), Path::new("/sock"), Path::new("/log"), - true, + &DaemonConfig { + self_update: true, + ..Default::default() + }, ); assert!(plist.contains("--self-update")); assert!(plist.contains("PATH")); assert!(plist.contains(".cargo/bin")); assert!(plist.contains("HOME")); + // No interval given → no interval flag. + assert!(!plist.contains("--self-update-interval-secs")); + } + + #[test] + fn launchd_plist_self_update_interval_is_baked_under_self_update() { + let with = launchd_plist( + Path::new("/hephd"), + Path::new("/db"), + Path::new("/sock"), + Path::new("/log"), + &DaemonConfig { + self_update: true, + self_update_interval_secs: Some(3600), + ..Default::default() + }, + ); + assert!(with.contains("--self-update-interval-secs")); + assert!(with.contains("3600")); + // Interval is meaningless without --self-update, so it's not emitted. + let without = launchd_plist( + Path::new("/hephd"), + Path::new("/db"), + Path::new("/sock"), + Path::new("/log"), + &DaemonConfig { + self_update: false, + self_update_interval_secs: Some(3600), + ..Default::default() + }, + ); + assert!(!without.contains("--self-update-interval-secs")); + } + + #[test] + fn launchd_plist_bakes_spoke_config() { + let plist = launchd_plist( + Path::new("/hephd"), + Path::new("/db"), + Path::new("/sock"), + Path::new("/log"), + &spoke_cfg(), + ); + assert!(plist.contains("--hub-url")); + assert!(plist.contains("http://hub.example:8787")); + assert!(plist.contains("--oidc-issuer")); + assert!(plist.contains("https://idp.example/o/heph/")); + assert!(plist.contains("--oidc-client-id")); + assert!(plist.contains("heph")); } #[test] @@ -483,7 +754,7 @@ mod tests { Path::new("/usr/local/bin/hephd"), Path::new("/home/e/.local/share/heph/heph.db"), Path::new("/run/user/1000/heph/hephd.sock"), - false, + &DaemonConfig::default(), ); assert!(unit.contains( "ExecStart=/usr/local/bin/hephd --mode local \ @@ -507,17 +778,96 @@ mod tests { Path::new("/usr/local/bin/hephd"), Path::new("/db"), Path::new("/sock"), - true, + &DaemonConfig { + self_update: true, + self_update_interval_secs: Some(3600), + ..Default::default() + }, ); - assert!(unit.contains("--self-update")); + assert!(unit.contains("--self-update --self-update-interval-secs 3600")); assert!(unit.contains("Environment=PATH=")); assert!(unit.contains(".cargo/bin")); assert!(unit.contains("Environment=HOME=")); } #[test] - fn xml_escape_escapes_markup() { + fn systemd_unit_bakes_hub_config() { + let unit = systemd_unit( + Path::new("/hephd"), + Path::new("/db"), + Path::new("/sock"), + &DaemonConfig { + mode: Some("server".into()), + http_addr: Some("0.0.0.0:8787".into()), + oidc_issuer: Some("https://idp.example/o/heph/".into()), + oidc_audience: Some("heph".into()), + ..Default::default() + }, + ); + assert!(unit.contains("--mode server")); + assert!(unit.contains("--http-addr 0.0.0.0:8787")); + assert!(unit.contains("--oidc-issuer https://idp.example/o/heph/")); + assert!(unit.contains("--oidc-audience heph")); + } + + #[test] + fn launchd_config_round_trips_through_the_plist() { + let cfg = spoke_cfg(); + let plist = launchd_plist( + Path::new("/hephd"), + Path::new("/db"), + Path::new("/sock"), + Path::new("/log"), + &cfg, + ); + let parsed = parse_hephd_args(&launchd_program_args(&plist)); + assert_eq!(parsed, cfg); + } + + #[test] + fn systemd_config_round_trips_through_the_unit() { + let cfg = DaemonConfig { + mode: Some("server".into()), + http_addr: Some("0.0.0.0:8787".into()), + oidc_issuer: Some("https://idp.example/o/heph/".into()), + oidc_audience: Some("heph".into()), + self_update: true, + self_update_interval_secs: Some(600), + ..Default::default() + }; + let unit = systemd_unit( + Path::new("/hephd"), + Path::new("/db"), + Path::new("/sock"), + &cfg, + ); + let parsed = parse_hephd_args(&systemd_exec_args(&unit)); + assert_eq!(parsed, cfg); + } + + #[test] + fn fill_from_preserves_existing_and_lets_cli_override() { + let existing = spoke_cfg(); + // A bare invocation (no flags) preserves everything baked in the file. + assert_eq!( + DaemonConfig::default().fill_from(existing.clone()), + existing + ); + // A CLI-provided value overrides; self_update stays sticky. + let overridden = DaemonConfig { + self_update_interval_secs: Some(60), + ..Default::default() + } + .fill_from(existing.clone()); + assert_eq!(overridden.self_update_interval_secs, Some(60)); + assert_eq!(overridden.hub_url, existing.hub_url); + assert!(overridden.self_update); + } + + #[test] + fn xml_escape_round_trips() { assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d"); + assert_eq!(xml_unescape("a & b < c > d"), "a & b < c > d"); } #[test] diff --git a/docs/changelog.d/daemon-self-update-interval.feature.md b/docs/changelog.d/daemon-self-update-interval.feature.md new file mode 100644 index 0000000..b5ec9b8 --- /dev/null +++ b/docs/changelog.d/daemon-self-update-interval.feature.md @@ -0,0 +1 @@ +`heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config. diff --git a/docs/how-to/run-the-daemon.md b/docs/how-to/run-the-daemon.md index 2b00dff..cb9e56d 100644 --- a/docs/how-to/run-the-daemon.md +++ b/docs/how-to/run-the-daemon.md @@ -36,14 +36,47 @@ when it's already stopped is fine. > exits cleanly to hand off to the new binary) wouldn't come back on Linux. Run > `heph daemon restart` once (it regenerates the unit) to pick up `Restart=always`. -Either way it runs `hephd --mode local` against the default store +By default it runs `hephd --mode local` against the default store (`~/.local/share/heph/heph.db`) and socket, with logs at -`~/.local/share/heph/hephd.log`. +`~/.local/share/heph/hephd.log`. Pass flags to `start`/`restart` to bake a +different runtime config into the service (see below). > **`stop` vs `uninstall`:** `stop` halts the daemon now, but the service is > still installed, so on macOS it starts again at next login. Use `uninstall` > to stop it persistently. +## Baking sync config (spoke / hub) + +By default the service runs a standalone `--mode local` daemon. To make the +managed service a **spoke** (background-syncs to a hub) or a **hub** (`--mode +server`), pass the corresponding `hephd` flags to `start` (or `restart`) — they +get baked into the generated plist/unit: + +```bash +# Spoke: sync to a hub, authenticating with OIDC +heph daemon start \ + --hub-url http://hub.example:8787 \ + --oidc-issuer https://idp.example/application/o/heph/ \ + --oidc-client-id heph + +# Hub: expose the authenticated sync endpoint +heph daemon start --mode server \ + --http-addr 0.0.0.0:8787 \ + --oidc-issuer https://idp.example/application/o/heph/ \ + --oidc-audience heph +``` + +Bakeable flags: `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`, +`--oidc-audience`, `--oidc-client-id`, `--self-update`, +`--self-update-interval-secs`. **Regenerating preserves what's already baked +in** — `start`/`restart` read the existing service file and carry over any flags +you don't pass, so a bare `heph daemon restart` never drops your spoke/hub or +self-update config. Pass a flag again to add or override it. + +> Spoke sync is HTTP-only today (`hephd`'s sync client doesn't speak HTTPS) — a +> `--hub-url` over the tailnet or behind a TLS-terminating proxy is the usual +> setup. + ## After upgrading When you rebuild/reinstall (`cargo install … --force`), the running daemon is @@ -59,9 +92,11 @@ heph daemon restart service that polls the forge for newer releases and, when one appears, rebuilds via `cargo install` (anonymous HTTPS clone of the public repo — no credentials) and restarts onto the new binary. It is **off by default**; the generated -service also gets a `PATH` that can find cargo. `heph daemon restart` preserves -the setting (pass `--self-update` again to turn it on later). Requires the Rust -toolchain (`cargo`) installed for the service user. +service also gets a `PATH` that can find cargo. Override the 6h poll cadence with +`--self-update-interval-secs `. Both `start` and `restart` preserve an +already-baked self-update setting (and its interval), so a bare invocation won't +silently disable it — pass `--self-update` again only to turn it on later. +Requires the Rust toolchain (`cargo`) installed for the service user. ## Development isolation diff --git a/docs/how-to/self-update.md b/docs/how-to/self-update.md index d4dda1f..e7d0627 100644 --- a/docs/how-to/self-update.md +++ b/docs/how-to/self-update.md @@ -20,9 +20,17 @@ heph daemon start --self-update ``` That generates a launchd/systemd service that runs `hephd --self-update` and -gives it a `PATH` that can find `cargo`. `heph daemon restart` preserves the -setting (pass `--self-update` again to turn it on later). To run the daemon -directly instead: +gives it a `PATH` that can find `cargo`. Override the 6h poll cadence with +`--self-update-interval-secs `: + +```bash +heph daemon start --self-update # default: poll every 6h +heph daemon start --self-update --self-update-interval-secs 3600 +``` + +Both `start` and `restart` preserve an already-baked setting (the flag and its +interval), so a bare invocation won't silently disable it — pass `--self-update` +again only to turn it on later. To run the daemon directly instead: ```bash hephd --self-update # default: poll every 6h