From 59822d72573eb9851ade67ec64d97a4180cc8f9a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 14:46:34 -0700 Subject: [PATCH] C2(hephd-self-update): impl service-env-forge-access (public HTTPS, cargo on PATH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo is public, so self-update needs no credentials: cargo install --git is a plain anonymous clone (NOT the access-restricted Forgejo cargo registry, which is what required forge.ops.eblu.me). Point INSTALL_GIT_URL and the releases poll at the canonical public host over HTTPS — verified end-to-end (cargo install --git https://forge.eblu.me/... --tag v1.0.3 builds a working hephd with zero auth). Make the headless service able to run the apply path: 'heph daemon start --self-update' (default off) generates a launchd/systemd service that passes --self-update and bakes a PATH (incl ~/.cargo/bin) + HOME so the minimal service env can find cargo. restart preserves the setting. Default (no flag) services are byte-identical to before. Template + URL behavior covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph/src/service.rs | 138 +++++++++++++++++++++++++++++---- crates/hephd/src/selfupdate.rs | 33 +++++--- docs/how-to/run-the-daemon.md | 10 +++ 3 files changed, 156 insertions(+), 25 deletions(-) diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs index fc642a7..7b15865 100644 --- a/crates/heph/src/service.rs +++ b/crates/heph/src/service.rs @@ -19,12 +19,22 @@ const LABEL: &str = "org.hephaestus.hephd"; #[derive(Subcommand, Debug)] pub enum DaemonAction { /// Install (if needed) and start the daemon service. - Start, + 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, + }, /// 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, + /// 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, + }, /// Show whether the service is installed and running. Status, /// Stop and remove the service entirely. @@ -114,8 +124,26 @@ fn xml_escape(s: &str) -> String { .replace('>', ">") } -fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { +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 { + let (path, home) = cargo_env(); + format!( + "\n PATH\n {}\n HOME\n {}", + xml_escape(&path), + xml_escape(&home), + ) + } else { + String::new() + }; format!( r#" @@ -131,7 +159,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { --db {db} --socket - {socket} + {socket}{self_update_arg} RunAtLoad @@ -143,7 +171,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { Aqua session as a LaunchAgent, so its child gets the GUI/hotkey it needs. Opt-in here (not in dev/test runs, which never set it). --> HEPH_QUICKADD - 1 + 1{cargo_env} StandardOutPath {log} @@ -160,14 +188,44 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { ) } -fn systemd_unit(hephd: &Path, db: &Path, socket: &Path) -> String { +/// A `PATH`/`HOME` pair for a service that must run `cargo install`. Service +/// managers start with a minimal environment, so we prepend `~/.cargo/bin` (which +/// holds cargo and the rustup toolchain shims) to the usual locations and pin +/// `HOME`, which cargo needs for its registry/cache. +fn cargo_env() -> (String, String) { + let home = std::env::var("HOME").unwrap_or_default(); + let path = + format!("{home}/.cargo/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + (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) +} + +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 { + let (path, home) = cargo_env(); + format!("Environment=PATH={path}\nEnvironment=HOME={home}\n") + } else { + String::new() + }; format!( "[Unit]\n\ Description=heph daemon (hephd)\n\ After=default.target\n\ \n\ [Service]\n\ - ExecStart={hephd} --mode local --db {db} --socket {socket}\n\ + ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\ + {cargo_env}\ Restart=always\n\ RestartSec=1\n\ \n\ @@ -245,8 +303,11 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let target = format!("gui/{uid}/{LABEL}"); match action { - DaemonAction::Start => { - write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; + DaemonAction::Start { self_update } => { + write_if_changed( + &plist, + &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, *self_update), + )?; if launchd_loaded(&target) { println!("heph daemon already running ({LABEL})."); } else { @@ -261,8 +322,12 @@ 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 => { - write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; + DaemonAction::Restart { self_update } => { + let su = *self_update || file_opts_into_self_update(&plist); + write_if_changed( + &plist, + &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, su), + )?; let _ = run_cmd("launchctl", &["bootout", &target])?; let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; if !ok { @@ -315,8 +380,11 @@ fn sc(args: &[&str]) -> Result<(bool, String)> { 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))?; + DaemonAction::Start { self_update } => { + write_if_changed( + &unit, + &systemd_unit(&p.hephd, &p.db, &p.socket, *self_update), + )?; sc(&["daemon-reload"])?; let (ok, err) = sc(&["enable", "--now", UNIT])?; if !ok { @@ -328,8 +396,9 @@ fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { 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))?; + 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))?; sc(&["daemon-reload"])?; let (ok, err) = sc(&["restart", UNIT])?; if !ok { @@ -378,6 +447,7 @@ 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, ); assert!(plist.contains("org.hephaestus.hephd")); assert!(plist.contains("/usr/local/bin/hephd")); @@ -387,6 +457,24 @@ mod tests { assert!(plist.contains("RunAtLoad")); assert!(plist.contains("KeepAlive")); assert!(plist.contains("hephd.log")); + // Default (no self-update): no flag, no cargo PATH baked in. + assert!(!plist.contains("--self-update")); + assert!(!plist.contains(".cargo/bin")); + } + + #[test] + fn launchd_plist_self_update_adds_flag_and_cargo_path() { + let plist = launchd_plist( + Path::new("/usr/local/bin/hephd"), + Path::new("/db"), + Path::new("/sock"), + Path::new("/log"), + true, + ); + assert!(plist.contains("--self-update")); + assert!(plist.contains("PATH")); + assert!(plist.contains(".cargo/bin")); + assert!(plist.contains("HOME")); } #[test] @@ -395,6 +483,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, ); assert!(unit.contains( "ExecStart=/usr/local/bin/hephd --mode local \ @@ -407,6 +496,23 @@ mod tests { assert!(!unit.contains("Restart=on-failure")); assert!(unit.contains("RestartSec=")); assert!(unit.contains("WantedBy=default.target")); + // Default (no self-update): no flag, no baked env. + assert!(!unit.contains("--self-update")); + assert!(!unit.contains("Environment=PATH=")); + } + + #[test] + fn systemd_unit_self_update_adds_flag_and_env() { + let unit = systemd_unit( + Path::new("/usr/local/bin/hephd"), + Path::new("/db"), + Path::new("/sock"), + true, + ); + assert!(unit.contains("--self-update")); + assert!(unit.contains("Environment=PATH=")); + assert!(unit.contains(".cargo/bin")); + assert!(unit.contains("Environment=HOME=")); } #[test] diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs index 6a3300b..8c7d470 100644 --- a/crates/hephd/src/selfupdate.rs +++ b/crates/hephd/src/selfupdate.rs @@ -32,11 +32,11 @@ impl SelfUpdateConfig { } } -/// The forge releases feed for this project — the latest tagged release. -/// Uses the SSH-canonical host (`forge.ops.eblu.me`); see the -/// `service-env-forge-access` card for the cargo/forge host caveat. +/// The forge releases feed for this project — the latest tagged release. The +/// repo is public, so this is an unauthenticated GET on the canonical public +/// host. pub const RELEASES_LATEST_URL: &str = - "https://forge.ops.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest"; + "https://forge.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest"; /// Extract the bare `X.Y.Z` semver from a version string that may carry a build /// suffix (`heph_core::VERSION` is e.g. `"1.0.3 (aa376b4)"`) or a leading `v` @@ -141,11 +141,12 @@ pub async fn check_release(source: &S, current: &str) -> Check } } -/// The git SSH URL self-update installs from. The SSH host on port 2222 is the -/// proven path: cargo rejects the HTTPS host over its canonical-name mismatch -/// (`forge.ops.eblu.me` vs the advertised `forge.eblu.me`). See the -/// `service-env-forge-access` card. -pub const INSTALL_GIT_URL: &str = "ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.git"; +/// The git URL self-update installs from. hephaestus is a **public** repo, and +/// `cargo install --git` is a plain anonymous git clone — *not* the Forgejo +/// cargo *registry* (that's access-restricted and needs `forge.ops.eblu.me`; +/// this is unrelated). So a credential-free HTTPS clone of the canonical public +/// host works from any device. +pub const INSTALL_GIT_URL: &str = "https://forge.eblu.me/eblume/hephaestus.git"; /// All workspace binaries, installed in lockstep so `heph`/`hephd`/`heph-tui` /// never skew after an update. @@ -284,6 +285,20 @@ mod tests { } } + #[test] + fn install_and_release_urls_are_public_https_no_ssh() { + // hephaestus is public; cargo install --git is a plain clone (not the + // access-restricted Forgejo cargo registry), so no SSH / credentials. + for url in [INSTALL_GIT_URL, RELEASES_LATEST_URL] { + assert!(url.starts_with("https://"), "{url} must be HTTPS"); + assert!(!url.contains("ssh://"), "{url} must not use SSH"); + assert!( + url.contains("forge.eblu.me"), + "{url} should use the canonical public host" + ); + } + } + #[tokio::test] async fn apply_update_installs_then_restarts_on_success() { let inst = Arc::new(FakeInstaller::default()); diff --git a/docs/how-to/run-the-daemon.md b/docs/how-to/run-the-daemon.md index 658a664..2b00dff 100644 --- a/docs/how-to/run-the-daemon.md +++ b/docs/how-to/run-the-daemon.md @@ -53,6 +53,16 @@ still the old binary until you restart it: heph daemon restart ``` +## Self-update (opt-in) + +`hephd` can keep itself current: `heph daemon start --self-update` generates a +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. + ## Development isolation `heph daemon` manages the **installed** daemon on the default paths. For in-repo