diff --git a/Cargo.lock b/Cargo.lock
index 0a2c89f..be8f974 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2274,6 +2274,7 @@ dependencies = [
"rand 0.8.6",
"reqwest",
"rsa",
+ "semver",
"serde",
"serde_json",
"tempfile",
diff --git a/Cargo.toml b/Cargo.toml
index 7d34a27..e24c881 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -59,6 +59,7 @@ reqwest = { version = "0.13", default-features = false, features = [
"json",
"query",
] }
+semver = "1"
[profile.release]
lto = "thin"
diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs
index 6015a3d..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,15 +188,46 @@ 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\
- Restart=on-failure\n\
+ ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\
+ {cargo_env}\
+ Restart=always\n\
+ RestartSec=1\n\
\n\
[Install]\n\
WantedBy=default.target\n",
@@ -244,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 {
@@ -260,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 {
@@ -314,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 {
@@ -327,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 {
@@ -377,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"));
@@ -386,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]
@@ -394,14 +483,36 @@ 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 \
--db /home/e/.local/share/heph/heph.db \
--socket /run/user/1000/heph/hephd.sock"
));
- assert!(unit.contains("Restart=on-failure"));
+ // Restart=always (not on-failure) so a clean exit (code 0) — what
+ // self-update does to hand off to the new binary — is respawned too.
+ assert!(unit.contains("Restart=always"));
+ 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/Cargo.toml b/crates/hephd/Cargo.toml
index 9bb7b9e..fb30b17 100644
--- a/crates/hephd/Cargo.toml
+++ b/crates/hephd/Cargo.toml
@@ -32,6 +32,7 @@ jsonwebtoken.workspace = true
keyring-core.workspace = true
reqwest.workspace = true
ureq.workspace = true
+semver.workspace = true
# The OS credential backend that `oauth.rs` registers as the keyring-core
# default store — exactly one per platform, not the whole keyring meta-crate.
diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs
index 09f8714..5d68bad 100644
--- a/crates/hephd/src/lib.rs
+++ b/crates/hephd/src/lib.rs
@@ -17,6 +17,7 @@ pub mod oauth;
pub mod quickadd;
pub mod remote;
pub mod rpc;
+pub mod selfupdate;
pub mod server;
pub mod sync;
diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs
index 62df200..ad3517c 100644
--- a/crates/hephd/src/main.rs
+++ b/crates/hephd/src/main.rs
@@ -17,8 +17,8 @@ use tokio::net::{TcpListener, UnixListener};
use heph_core::LocalStore;
use hephd::{
- default_db_path, default_socket_path, sync, Daemon, KeyringTokenStore, LockGuard, RemoteStore,
- SystemClock, TokenStore,
+ default_db_path, default_socket_path, selfupdate::SelfUpdateConfig, sync, Daemon,
+ KeyringTokenStore, LockGuard, RemoteStore, SystemClock, TokenStore,
};
/// How often a spoke background-syncs with its hub.
@@ -77,6 +77,16 @@ struct Cli {
/// --oidc-issuer, the device attaches a cached bearer token to hub requests.
#[arg(long)]
oidc_client_id: Option,
+
+ /// Opt-in (default off): periodically poll the forge for a newer release and
+ /// auto-update this daemon. Off unless this flag is given.
+ #[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,
}
/// Build the spoke/client token source: a keyring store keyed by `account` (the
@@ -112,6 +122,11 @@ async fn main() -> Result<()> {
.with_context(|| format!("creating socket dir {}", parent.display()))?;
}
+ // Opt-in self-update (default off): `Some` only when `--self-update` is set.
+ let self_update = cli
+ .self_update
+ .then(|| SelfUpdateConfig::new(cli.self_update_interval_secs.map(Duration::from_secs)));
+
// Build the daemon for the chosen mode. `local`/`server` own the file (and
// hold its lock for the process's life); `client` keeps no replica.
let (_lock, daemon) = match cli.mode {
@@ -131,7 +146,10 @@ async fn main() -> Result<()> {
}
None => RemoteStore::new(&server_url),
};
- (None, Daemon::new(store))
+ (
+ None,
+ Daemon::new(store).with_self_update(self_update.clone()),
+ )
}
Mode::Local | Mode::Server => {
let db = cli.db.clone().unwrap_or_else(default_db_path);
@@ -147,7 +165,8 @@ async fn main() -> Result<()> {
});
let daemon = Daemon::new(store)
.with_hub(cli.hub_url.clone())
- .with_spoke_auth(spoke);
+ .with_spoke_auth(spoke)
+ .with_self_update(self_update.clone());
// server mode: expose the hub HTTP endpoint over the same store.
if cli.mode == Mode::Server {
@@ -190,6 +209,9 @@ async fn main() -> Result<()> {
}
};
+ // Opt-in self-update poller (no-op unless --self-update); mode-agnostic.
+ daemon.spawn_self_update_loop();
+
// Replace any stale socket from a previous run, then bind.
if socket.exists() {
std::fs::remove_file(&socket)
diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs
new file mode 100644
index 0000000..8c7d470
--- /dev/null
+++ b/crates/hephd/src/selfupdate.rs
@@ -0,0 +1,417 @@
+//! Opt-in self-update (cards: `docs/how-to/self-update/`). When enabled, hephd
+//! polls the forge for a newer tagged release and rebuilds + restarts onto it.
+//!
+//! The moving parts are dependency-injected behind traits — [`ReleaseSource`]
+//! (where the latest tag comes from) and [`Installer`] (how the upgrade is
+//! applied) — so the poll/apply logic is unit-tested without a live forge or a
+//! real `cargo install`. The production wiring (`ForgeReleaseSource`,
+//! `CargoInstaller`) is exercised only at runtime.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use anyhow::{Context, Result};
+
+/// Default poll cadence when `--self-update` is on and no interval is given.
+pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
+
+/// Configuration for the opt-in self-update mode. Its mere presence (the daemon
+/// holds an `Option`) means the mode is enabled; absent ⇒ off.
+#[derive(Clone, Debug)]
+pub struct SelfUpdateConfig {
+ /// How often to poll the forge for a newer release.
+ pub interval: Duration,
+}
+
+impl SelfUpdateConfig {
+ /// Build a config, falling back to [`DEFAULT_INTERVAL`] when no override.
+ pub fn new(interval: Option) -> Self {
+ Self {
+ interval: interval.unwrap_or(DEFAULT_INTERVAL),
+ }
+ }
+}
+
+/// 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.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`
+/// (release tags are `v1.0.4`).
+fn parse_version(s: &str) -> Result {
+ let head = s
+ .trim()
+ .trim_start_matches('v')
+ .split_whitespace()
+ .next()
+ .unwrap_or("");
+ semver::Version::parse(head).with_context(|| format!("parsing version {s:?}"))
+}
+
+/// Whether `latest_tag` names a strictly newer release than `current` (the
+/// running `heph_core::VERSION`). A malformed version on either side is an
+/// error — never a silent "no update".
+pub fn update_available(current: &str, latest_tag: &str) -> Result {
+ Ok(parse_version(latest_tag)? > parse_version(current)?)
+}
+
+/// Pull the `tag_name` out of a Forgejo/Gitea `releases/latest` response body.
+/// Split out from the HTTP fetch so it can be tested against a sample payload.
+pub fn parse_latest_tag(body: &str) -> Result {
+ #[derive(serde::Deserialize)]
+ struct Release {
+ tag_name: String,
+ }
+ let rel: Release =
+ serde_json::from_str(body).context("parsing forge releases/latest response")?;
+ Ok(rel.tag_name)
+}
+
+/// Fetch the latest release tag from the forge over HTTP (reusing the daemon's
+/// shared `reqwest::Client`). Network/HTTP/JSON failures surface as `Err` for
+/// the caller to log-and-continue.
+pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result {
+ let body = http
+ .get(url)
+ .send()
+ .await
+ .context("requesting forge releases/latest")?
+ .error_for_status()
+ .context("forge releases/latest returned an error status")?
+ .text()
+ .await
+ .context("reading forge releases/latest body")?;
+ parse_latest_tag(&body)
+}
+
+/// Where "the latest release tag" comes from. Injectable so the poll loop can
+/// be exercised without hitting the network (real impl: [`ForgeReleaseSource`]).
+pub trait ReleaseSource: Send + Sync + 'static {
+ fn latest_tag(&self) -> impl std::future::Future