C2: hephd self-update (Mikado plan — cards for review) #7

Merged
eblume merged 19 commits from mikado/hephd-self-update into main 2026-06-04 15:03:23 -07:00
3 changed files with 156 additions and 25 deletions
Showing only changes of commit 59822d7257 - Show all commits

C2(hephd-self-update): impl service-env-forge-access (public HTTPS, cargo on PATH)

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) <noreply@anthropic.com>
Erich Blume 2026-06-04 14:46:34 -07:00

View file

@ -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('>', "&gt;")
}
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 <string>--self-update</string>".to_string()
} else {
String::new()
};
let cargo_env = if self_update {
let (path, home) = cargo_env();
format!(
"\n <key>PATH</key>\n <string>{}</string>\n <key>HOME</key>\n <string>{}</string>",
xml_escape(&path),
xml_escape(&home),
)
} else {
String::new()
};
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@ -131,7 +159,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String {
<string>--db</string>
<string>{db}</string>
<string>--socket</string>
<string>{socket}</string>
<string>{socket}</string>{self_update_arg}
</array>
<key>RunAtLoad</key>
<true/>
@ -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). -->
<key>HEPH_QUICKADD</key>
<string>1</string>
<string>1</string>{cargo_env}
</dict>
<key>StandardOutPath</key>
<string>{log}</string>
@ -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("<string>org.hephaestus.hephd</string>"));
assert!(plist.contains("<string>/usr/local/bin/hephd</string>"));
@ -387,6 +457,24 @@ mod tests {
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.
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("<string>--self-update</string>"));
assert!(plist.contains("<key>PATH</key>"));
assert!(plist.contains(".cargo/bin"));
assert!(plist.contains("<key>HOME</key>"));
}
#[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]

View file

@ -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<S: ReleaseSource>(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());

View file

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