generated from eblume/project-template
feat(cli): heph daemon — manage hephd as a launchd/systemd service
Some checks failed
Build / validate (pull_request) Has been cancelled
Some checks failed
Build / validate (pull_request) Has been cancelled
Surfaces are connect-only; the daemon now runs as an explicit OS service so it can be shared without any surface owning its lifecycle. - service.rs: heph daemon start/stop/restart/status/uninstall, idempotent; launchd LaunchAgent (macOS) / systemd user service (Linux); resolves hephd next to heph else on PATH; pure plist/unit render fns unit-tested - main.rs: Command::Daemon handled before connecting (like auth) - hephd: default socket is now a STABLE <data-dir>/heph/hephd.sock when XDG_RUNTIME_DIR is unset (was $TMPDIR — fragile for a persistent service; macOS prunes /var/folders and the path varied per session) - tech-spec §14: CLI + daemon-service done entries Verified live on macOS: start/restart/stop/uninstall + CLI reaches the store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1315b9ce18
commit
0cfe627055
4 changed files with 443 additions and 6 deletions
|
|
@ -16,6 +16,7 @@ use heph_core::{Node, RankedTask, Task};
|
|||
use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
|
||||
|
||||
mod datespec;
|
||||
mod service;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "heph", version, about)]
|
||||
|
|
@ -220,6 +221,11 @@ enum Command {
|
|||
/// Destination directory (created if needed).
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Manage the hephd daemon as an OS service (launchd / systemd).
|
||||
Daemon {
|
||||
#[command(subcommand)]
|
||||
action: service::DaemonAction,
|
||||
},
|
||||
/// Authenticate this device with a sync hub (OAuth 2.0 device-code flow).
|
||||
Auth {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -344,6 +350,10 @@ fn run_auth(action: AuthAction) -> Result<()> {
|
|||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// `daemon` manages the OS service; it must not connect to a daemon.
|
||||
if let Command::Daemon { action } = &cli.command {
|
||||
return service::run(action);
|
||||
}
|
||||
// `auth` runs locally (device-code flow + keyring); it needs no daemon.
|
||||
if let Command::Auth { action } = cli.command {
|
||||
return run_auth(action);
|
||||
|
|
@ -626,6 +636,7 @@ fn main() -> Result<()> {
|
|||
println!("Exported {count} nodes to {}", dir.display());
|
||||
}
|
||||
Command::Auth { .. } => unreachable!("auth is handled before connecting"),
|
||||
Command::Daemon { .. } => unreachable!("daemon is handled before connecting"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
414
crates/heph/src/service.rs
Normal file
414
crates/heph/src/service.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
//! `heph daemon` — manage `hephd` as an OS service (tech-spec §3, [[design]] §4).
|
||||
//!
|
||||
//! Surfaces are connect-only; the daemon runs as an explicit service so it can
|
||||
//! 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.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Subcommand;
|
||||
|
||||
use hephd::{default_db_path, default_socket_path};
|
||||
|
||||
/// launchd label / systemd-independent identifier.
|
||||
const LABEL: &str = "org.hephaestus.hephd";
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum DaemonAction {
|
||||
/// Install (if needed) and start the daemon service.
|
||||
Start,
|
||||
/// 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,
|
||||
/// Show whether the service is installed and running.
|
||||
Status,
|
||||
/// Stop and remove the service entirely.
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
/// Resolved locations the service definition needs.
|
||||
struct Paths {
|
||||
hephd: PathBuf,
|
||||
db: PathBuf,
|
||||
socket: PathBuf,
|
||||
log: PathBuf,
|
||||
}
|
||||
|
||||
fn paths() -> Result<Paths> {
|
||||
let db = default_db_path();
|
||||
let log = db
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("hephd.log");
|
||||
Ok(Paths {
|
||||
hephd: resolve_hephd()?,
|
||||
db,
|
||||
socket: default_socket_path(),
|
||||
log,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find `hephd`: next to the running `heph` binary first, else on `PATH`.
|
||||
fn resolve_hephd() -> Result<PathBuf> {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(sib) = exe.parent().map(|d| d.join("hephd")) {
|
||||
if sib.is_file() {
|
||||
return Ok(sib);
|
||||
}
|
||||
}
|
||||
}
|
||||
which("hephd").context("`hephd` not found next to `heph` or on PATH")
|
||||
}
|
||||
|
||||
/// Minimal `which`: first PATH entry containing an executable `name`.
|
||||
fn which(name: &str) -> Result<PathBuf> {
|
||||
let path = std::env::var_os("PATH").context("PATH is unset")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let cand = dir.join(name);
|
||||
if cand.is_file() {
|
||||
return Ok(cand);
|
||||
}
|
||||
}
|
||||
bail!("{name} not found on PATH")
|
||||
}
|
||||
|
||||
enum Manager {
|
||||
Launchd,
|
||||
Systemd,
|
||||
}
|
||||
|
||||
fn manager() -> Result<Manager> {
|
||||
if cfg!(target_os = "macos") {
|
||||
Ok(Manager::Launchd)
|
||||
} else if cfg!(target_os = "linux") {
|
||||
if Path::new("/run/systemd/system").exists() {
|
||||
Ok(Manager::Systemd)
|
||||
} else {
|
||||
bail!("no systemd detected — run `hephd` yourself (e.g. via your init system)")
|
||||
}
|
||||
} else {
|
||||
bail!("`heph daemon` supports macOS (launchd) and systemd Linux; run `hephd` yourself")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(action: &DaemonAction) -> Result<()> {
|
||||
let p = paths()?;
|
||||
match manager()? {
|
||||
Manager::Launchd => launchd(action, &p),
|
||||
Manager::Systemd => systemd(action, &p),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering (pure — unit-tested)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String {
|
||||
let arg = |p: &Path| xml_escape(&p.to_string_lossy());
|
||||
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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<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>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{log}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{log}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#,
|
||||
label = LABEL,
|
||||
hephd = arg(hephd),
|
||||
db = arg(db),
|
||||
socket = arg(socket),
|
||||
log = arg(log),
|
||||
)
|
||||
}
|
||||
|
||||
fn systemd_unit(hephd: &Path, db: &Path, socket: &Path) -> String {
|
||||
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\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n",
|
||||
hephd = hephd.display(),
|
||||
db = db.display(),
|
||||
socket = socket.display(),
|
||||
)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/// Write `contents` to `path` (creating parents) only if it differs — keeps the
|
||||
/// service file stable so reloads aren't forced needlessly. Returns whether it
|
||||
/// changed.
|
||||
fn write_if_changed(path: &Path, contents: &str) -> Result<bool> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
if std::fs::read_to_string(path).ok().as_deref() == Some(contents) {
|
||||
return Ok(false);
|
||||
}
|
||||
std::fs::write(path, contents)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Run a command, returning success and captured stderr (for benign-failure
|
||||
/// tolerance like "service not loaded").
|
||||
fn run_cmd(prog: &str, args: &[&str]) -> Result<(bool, String)> {
|
||||
let out = Command::new(prog)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run `{prog}`"))?;
|
||||
Ok((
|
||||
out.status.success(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// launchd (macOS)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
fn launchd_plist_path() -> Result<PathBuf> {
|
||||
let home = std::env::var_os("HOME").context("HOME is unset")?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join("Library/LaunchAgents")
|
||||
.join(format!("{LABEL}.plist")))
|
||||
}
|
||||
|
||||
fn uid() -> Result<String> {
|
||||
let out = Command::new("id")
|
||||
.arg("-u")
|
||||
.output()
|
||||
.context("failed to run `id -u`")?;
|
||||
if !out.status.success() {
|
||||
bail!("could not determine uid");
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn launchd_loaded(domain_target: &str) -> bool {
|
||||
run_cmd("launchctl", &["print", domain_target])
|
||||
.map(|(ok, _)| ok)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||
let plist = launchd_plist_path()?;
|
||||
let uid = uid()?;
|
||||
let domain = format!("gui/{uid}");
|
||||
let target = format!("gui/{uid}/{LABEL}");
|
||||
|
||||
match action {
|
||||
DaemonAction::Start => {
|
||||
write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?;
|
||||
if launchd_loaded(&target) {
|
||||
println!("heph daemon already running ({LABEL}).");
|
||||
} else {
|
||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
||||
if !ok {
|
||||
bail!("launchctl bootstrap failed: {}", err.trim());
|
||||
}
|
||||
println!("heph daemon started ({LABEL}).");
|
||||
}
|
||||
}
|
||||
DaemonAction::Stop => {
|
||||
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))?;
|
||||
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
||||
if !ok {
|
||||
bail!("launchctl bootstrap failed: {}", err.trim());
|
||||
}
|
||||
println!("heph daemon restarted ({LABEL}).");
|
||||
}
|
||||
DaemonAction::Status => {
|
||||
let installed = plist.exists();
|
||||
let running = launchd_loaded(&target);
|
||||
print_status(installed, running, p, &plist);
|
||||
}
|
||||
DaemonAction::Uninstall => {
|
||||
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
||||
if plist.exists() {
|
||||
std::fs::remove_file(&plist)?;
|
||||
}
|
||||
println!("heph daemon uninstalled ({LABEL}).");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plist_str(p: &Path) -> Result<String> {
|
||||
Ok(p.to_str()
|
||||
.context("plist path is not valid UTF-8")?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// systemd (Linux, user service)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const UNIT: &str = "heph.service";
|
||||
|
||||
fn systemd_unit_path() -> Result<PathBuf> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.context("HOME/XDG_CONFIG_HOME unset")?;
|
||||
Ok(base.join("systemd/user").join(UNIT))
|
||||
}
|
||||
|
||||
fn sc(args: &[&str]) -> Result<(bool, String)> {
|
||||
let mut full = vec!["--user"];
|
||||
full.extend_from_slice(args);
|
||||
run_cmd("systemctl", &full)
|
||||
}
|
||||
|
||||
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))?;
|
||||
sc(&["daemon-reload"])?;
|
||||
let (ok, err) = sc(&["enable", "--now", UNIT])?;
|
||||
if !ok {
|
||||
bail!("systemctl enable --now failed: {}", err.trim());
|
||||
}
|
||||
println!("heph daemon started ({UNIT}).");
|
||||
}
|
||||
DaemonAction::Stop => {
|
||||
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))?;
|
||||
sc(&["daemon-reload"])?;
|
||||
let (ok, err) = sc(&["restart", UNIT])?;
|
||||
if !ok {
|
||||
bail!("systemctl restart failed: {}", err.trim());
|
||||
}
|
||||
println!("heph daemon restarted ({UNIT}).");
|
||||
}
|
||||
DaemonAction::Status => {
|
||||
let installed = unit.exists();
|
||||
let running = sc(&["is-active", UNIT]).map(|(ok, _)| ok).unwrap_or(false);
|
||||
print_status(installed, running, p, &unit);
|
||||
}
|
||||
DaemonAction::Uninstall => {
|
||||
sc(&["disable", "--now", UNIT])?;
|
||||
if unit.exists() {
|
||||
std::fs::remove_file(&unit)?;
|
||||
}
|
||||
sc(&["daemon-reload"])?;
|
||||
println!("heph daemon uninstalled ({UNIT}).");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path) {
|
||||
println!("installed : {}", if installed { "yes" } else { "no" });
|
||||
println!("running : {}", if running { "yes" } else { "no" });
|
||||
println!("service : {}", service_file.display());
|
||||
println!("hephd : {}", p.hephd.display());
|
||||
println!("db : {}", p.db.display());
|
||||
println!("socket : {}", p.socket.display());
|
||||
println!("log : {}", p.log.display());
|
||||
if !running {
|
||||
println!("\n(start it with `heph daemon start`)");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_has_label_args_and_paths() {
|
||||
let plist = launchd_plist(
|
||||
Path::new("/usr/local/bin/hephd"),
|
||||
Path::new("/home/e/.local/share/heph/heph.db"),
|
||||
Path::new("/tmp/heph/hephd.sock"),
|
||||
Path::new("/home/e/.local/share/heph/hephd.log"),
|
||||
);
|
||||
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>/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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemd_unit_has_execstart_and_install() {
|
||||
let unit = systemd_unit(
|
||||
Path::new("/usr/local/bin/hephd"),
|
||||
Path::new("/home/e/.local/share/heph/heph.db"),
|
||||
Path::new("/run/user/1000/heph/hephd.sock"),
|
||||
);
|
||||
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"));
|
||||
assert!(unit.contains("WantedBy=default.target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_escapes_markup() {
|
||||
assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_if_changed_is_idempotent() {
|
||||
let dir = std::env::temp_dir().join(format!("heph-svc-test-{}", std::process::id()));
|
||||
let f = dir.join("unit");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(write_if_changed(&f, "one").unwrap(), "first write changes");
|
||||
assert!(!write_if_changed(&f, "one").unwrap(), "same content no-op");
|
||||
assert!(write_if_changed(&f, "two").unwrap(), "new content changes");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,12 +42,22 @@ pub(crate) fn blocking_agent() -> ureq::Agent {
|
|||
}
|
||||
|
||||
/// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to
|
||||
/// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3).
|
||||
/// a **stable** path next to the data store (`<data-dir>/heph/hephd.sock`) when
|
||||
/// `XDG_RUNTIME_DIR` is unset — notably on macOS (tech-spec §3).
|
||||
///
|
||||
/// The fallback is deliberately *not* the system temp dir: the daemon is a
|
||||
/// long-lived OS service ([[design]] §4), and a temp-dir socket is fragile (it
|
||||
/// varies per login session and macOS periodically prunes `/var/folders`). A
|
||||
/// path beside the DB is deterministic across every process and reboot, so the
|
||||
/// service and its clients always agree.
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join("heph").join("hephd.sock")
|
||||
if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(rt).join("heph").join("hephd.sock");
|
||||
}
|
||||
default_db_path()
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("hephd.sock")
|
||||
}
|
||||
|
||||
/// Default store path: `$XDG_DATA_HOME/heph/heph.db`, falling back to
|
||||
|
|
|
|||
|
|
@ -358,7 +358,9 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi
|
|||
- ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`.
|
||||
- ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected).
|
||||
- ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop.
|
||||
- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**.
|
||||
- ✅ **CLI (§1) — the complete daemon API + task driver:** `heph` covers every RPC (next/list/task/done/drop/skip/attention/**edit**/promote/show/log/health/doc/node/get/resolve/search/journal/links/backlinks/link/project[+list]/sync/conflicts/export/auth) with **human dates** (`--do-date tomorrow|+3d|fri|ISO`) and **recurrence** (presets + NL + raw `--rrule`; `datespec`). Reschedule is the new **`task.set_schedule`** RPC. Process-tested over a real socket.
|
||||
- ✅ **Daemon as an OS service (§3, [[design]] §4):** **`heph daemon start/stop/restart/status/uninstall`** idempotently manages a launchd agent (macOS) / systemd user service (Linux) running `hephd` on the default store; render fns unit-tested, verified live on macOS. The default socket is now a **stable** `<data-dir>/heph/hephd.sock` (was `$TMPDIR`-based) so a persistent service and its clients always agree. Surfaces are **connect-only** (no auto-spawn).
|
||||
- ✅ **Todoist importer (tooling):** `mise run import-todoist` seeds a store from Todoist (dry-run default, `-- --commit` to write) — see [[import-todoist]].
|
||||
- ✅ **CI (§9):** Forgejo `build.yaml` runs **entirely through Dagger** (the k8s job image is a thin Alpine + Dagger orchestrator with a DinD sidecar — no native Rust/nvim toolchain): `dagger call check` (cargo fmt/clippy/test on `rust:1-bookworm`) + `dagger call test-nvim` (build hephd + headless e2e). Cargo parallelism capped (`CARGO_BUILD_JOBS`) to avoid OOMing the build engine; cargo caches shared across runs. `prek` runs locally via git hooks, not in CI.
|
||||
- ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c).
|
||||
- ✅ **`heph.nvim` slice 11b (§8) — task views:** **`list` enriched** to titled [`RankedTask`] rows (title + `canonical_context_id`, shared `ranked_from_row` with `next`) so the Organizational view needs no N+1 `node.get`. Plugin: **Tactical `next`** + **Organizational `list`** views (rendered scratch buffers, `<CR>` opens a row's canonical-context doc — the node autocmd narrowed to `heph://node/*` so view buffers don't trip it); **task capture**, **set-attention**, **done/drop**, **skip**, **per-task `log` append** — all resolving "the current task" from the buffer (a `task` node, or a context doc via its `canonical-context` backlink); **`vim.ui.select` picker** (`picker.lua`) with Telescope auto-upgrade; `:Heph next/list/capture/attention/done/drop/skip/log/search` subcommands. e2e specs: **capture→next→open context→add/check checklist→done**, and **recurring fresh-checklist** (complete rolls forward in place; the next occurrence is all-unchecked — the §4.4 hard requirement).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue