feat(heph): bake daemon mode/hub/oidc/self-update-interval into the service
All checks were successful
Build / validate (pull_request) Successful in 5m45s

`heph daemon start`/`restart` previously hardcoded `hephd --mode local` and
only wired the bare `--self-update` bool — the poll interval and all spoke/hub
sync config (`--hub-url`, `--http-addr`, `--oidc-*`) could not be set on the
managed service without hand-editing the plist/unit (which a later
start/restart would clobber).

Generate the hephd arg vector from a DaemonConfig and add the corresponding
`heph daemon start/restart` flags: --mode, --hub-url, --http-addr,
--oidc-issuer, --oidc-audience, --oidc-client-id, and
--self-update-interval-secs. Regenerating now reads the existing service file
and preserves any flags not passed (start as well as restart), so a bare
invocation never silently drops baked config.

Closes the "pass through --self-update-interval-secs" and "bake hub/spoke
config into the generated service" backlog tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-08 13:25:15 -07:00
commit 626c796e6c
4 changed files with 473 additions and 79 deletions

View file

@ -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<String>,
/// Hub to background-sync this replica's op-log with (makes it a spoke) —
/// bakes `--hub-url`.
#[arg(long)]
hub_url: Option<String>,
/// Hub HTTP listen address (server mode) — bakes `--http-addr`.
#[arg(long)]
http_addr: Option<String>,
/// OIDC issuer used to verify (server) or obtain (spoke) hub tokens — bakes
/// `--oidc-issuer`.
#[arg(long)]
oidc_issuer: Option<String>,
/// OIDC audience hub tokens must carry (server mode) — bakes
/// `--oidc-audience`.
#[arg(long)]
oidc_audience: Option<String>,
/// OIDC client id this device authenticates as (spoke) — bakes
/// `--oidc-client-id`.
#[arg(long)]
oidc_client_id: Option<String>,
/// 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<u64>,
}
/// The hephd flags the service generator bakes beyond the fixed `--db`/`--socket`.
#[derive(Default, Clone, PartialEq, Debug)]
struct DaemonConfig {
mode: Option<String>,
hub_url: Option<String>,
http_addr: Option<String>,
oidc_issuer: Option<String>,
oidc_audience: Option<String>,
oidc_client_id: Option<String>,
self_update: bool,
self_update_interval_secs: Option<u64>,
}
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<String> {
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<String>, flag: &str, val: &Option<String>) {
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('>', "&gt;")
}
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 <string>--self-update</string>".to_string()
} else {
String::new()
};
let cargo_env = if self_update {
fn xml_unescape(s: &str) -> String {
s.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
}
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!(" <string>{}</string>", xml_escape(a)))
.collect::<Vec<_>>()
.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 <key>PATH</key>\n <string>{}</string>\n <key>HOME</key>\n <string>{}</string>",
@ -153,13 +341,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{hephd}</string>
<string>--mode</string>
<string>local</string>
<string>--db</string>
<string>{db}</string>
<string>--socket</string>
<string>{socket}</string>{self_update_arg}
{args_xml}
</array>
<key>RunAtLoad</key>
<true/>
@ -181,10 +363,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update
</plist>
"#,
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<String> {
let Some(k) = plist.find("<key>ProgramArguments</key>") else {
return vec![];
};
let rest = &plist[k..];
let (Some(start), Some(end)) = (rest.find("<array>"), rest.find("</array>")) else {
return vec![];
};
let block = &rest[start..end];
let mut out = vec![];
let mut cur = block;
while let Some(o) = cur.find("<string>") {
let after = &cur[o + "<string>".len()..];
let Some(c) = after.find("</string>") else {
break;
};
out.push(xml_unescape(&after[..c]));
cur = &after[c + "</string>".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<String> {
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("<string>org.hephaestus.hephd</string>"));
assert!(plist.contains("<string>/usr/local/bin/hephd</string>"));
assert!(plist.contains("<string>--mode</string>"));
assert!(plist.contains("<string>local</string>"));
assert!(plist.contains("<string>/home/e/.local/share/heph/heph.db</string>"));
assert!(plist.contains("<string>/tmp/heph/hephd.sock</string>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
assert!(plist.contains("<key>KeepAlive</key>"));
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("<string>--self-update</string>"));
assert!(plist.contains("<key>PATH</key>"));
assert!(plist.contains(".cargo/bin"));
assert!(plist.contains("<key>HOME</key>"));
// 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("<string>--self-update-interval-secs</string>"));
assert!(with.contains("<string>3600</string>"));
// 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("<string>--hub-url</string>"));
assert!(plist.contains("<string>http://hub.example:8787</string>"));
assert!(plist.contains("<string>--oidc-issuer</string>"));
assert!(plist.contains("<string>https://idp.example/o/heph/</string>"));
assert!(plist.contains("<string>--oidc-client-id</string>"));
assert!(plist.contains("<string>heph</string>"));
}
#[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 &amp; b &lt; c &gt; d");
assert_eq!(xml_unescape("a &amp; b &lt; c &gt; d"), "a & b < c > d");
}
#[test]

View file

@ -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.

View file

@ -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 <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

View file

@ -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 <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