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