Improve CLI and daemon lifecycle usability

This commit is contained in:
Eric Wendland 2026-07-18 15:23:20 +02:00
commit f61cb44dad
14 changed files with 1028 additions and 204 deletions

1
Cargo.lock generated
View file

@ -1551,6 +1551,7 @@ dependencies = [
"geth-node", "geth-node",
"geth-pipe", "geth-pipe",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
] ]

View file

@ -8,28 +8,25 @@ still named `geth`.
## First 10 Minutes ## First 10 Minutes
Build the single binary, initialize an isolated home, start the daemon, and Build the single binary and start a disposable daemon:
verify the local control path:
```sh ```sh
cargo build -p geth cargo build -p geth
export GETH_HOME="$(mktemp -d)" ./target/debug/geth daemon run --ephemeral
printf 'GETH_HOME=%s\n' "$GETH_HOME"
./target/debug/geth init
./target/debug/geth daemon run
``` ```
Keep the daemon running. In a second shell, reuse the printed `GETH_HOME` value: Keep the daemon running. In a second shell, reuse the home it printed:
```sh ```sh
export GETH_HOME="<same directory>" ./target/debug/geth --home <printed-path> wait daemon
./target/debug/geth wait daemon --timeout-ms 30000 ./target/debug/geth --home <printed-path> status --json
./target/debug/geth status --json ./target/debug/geth --home <printed-path> doctor --json
./target/debug/geth doctor --json
``` ```
This creates a local-only identity for evaluation. Before enrolling other Ctrl-C stops the daemon and removes its temporary state. For a persistent
machines, use the owner setup in `geth guide owner-setup`; it records an background node, run `geth daemon install`; this initializes local state,
installs a service for the current user, and starts it immediately. Before
enrolling other machines, use `geth guide owner-setup`; it records an
OpenSSH admin public key as the trust anchor and signs the initial keychain OpenSSH admin public key as the trust anchor and signs the initial keychain
statements without copying the private key into geth state. statements without copying the private key into geth state.
@ -43,6 +40,8 @@ Start with these documents when moving beyond the local smoke test:
and signed-operation compatibility rules. and signed-operation compatibility rules.
- [`docs/automation-examples.md`](docs/automation-examples.md): shell, Python, - [`docs/automation-examples.md`](docs/automation-examples.md): shell, Python,
and user-service examples. and user-service examples.
- [`docs/user-workflows.md`](docs/user-workflows.md): operator stories from
first run through enrollment, sync, automation, and recovery.
- [`docs/production-readiness-roadmap.md`](docs/production-readiness-roadmap.md): - [`docs/production-readiness-roadmap.md`](docs/production-readiness-roadmap.md):
the pre-deployment gate and its current status. the pre-deployment gate and its current status.
- [`docs/dogfood-checklist.md`](docs/dogfood-checklist.md): required - [`docs/dogfood-checklist.md`](docs/dogfood-checklist.md): required
@ -57,7 +56,7 @@ It has daemon mode and control mode:
```sh ```sh
geth init geth init
geth daemon run geth daemon run
geth daemon service install geth daemon install
geth status geth status
geth node id geth node id
geth resource list geth resource list
@ -91,14 +90,15 @@ signed records.
The daemon can also install itself as a user service: The daemon can also install itself as a user service:
```sh ```sh
geth daemon service install geth daemon install
geth daemon service status geth daemon status
geth daemon service uninstall geth daemon uninstall
``` ```
The bootstrap service managers are systemd user units on Linux, launchd user The bootstrap service managers are systemd user units on Linux, launchd user
agents on macOS, and per-user scheduled tasks on Windows. These are user-level agents on macOS, and per-user scheduled tasks on Windows. These are user-level
services, not system services. services, not system services. The longer `geth daemon service ...` family is
retained for compatibility and advanced options.
## Transport And SSH ## Transport And SSH
@ -131,14 +131,15 @@ metadata from an authorized peer over Iroh.
The bootstrap implementation provides: The bootstrap implementation provides:
- `geth guide [init|owner-setup|enrollment|keys|overlay|service|completions|smoke-test]` for - `geth guide [quickstart|init|owner-setup|enrollment|keys|overlay|service|completions|smoke-test]` for
embedded workflow help, including `--admin-key` / `--signing-key` setup embedded workflow help, including `--admin-key` / `--signing-key` setup
examples examples
- `geth completions <bash|zsh|fish|powershell|elvish>` for shell completion - `geth completions <bash|zsh|fish|powershell|elvish>` for shell completion
scripts generated from the live CLI command tree scripts generated from the live CLI command tree
- `geth init` - `geth init`
- `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>` - `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>`
- `geth daemon run` - `geth daemon run [--ephemeral]`
- `geth daemon install|start|stop|status|uninstall`
- `geth daemon service install|uninstall|start|stop|status|print` - `geth daemon service install|uninstall|start|stop|status|print`
- `geth status` - `geth status`
- `geth wait daemon|peer|sync --timeout-ms <ms>` - `geth wait daemon|peer|sync --timeout-ms <ms>`
@ -444,25 +445,25 @@ $GETH_HOME/
## Quick Start ## Quick Start
In one shell: For a disposable evaluation, run this in one shell:
```sh ```sh
export GETH_HOME="$(mktemp -d)" cargo run -p geth -- daemon run --ephemeral
cargo run -p geth -- init
cargo run -p geth -- daemon run
``` ```
In another shell: In another shell, use the home it prints:
```sh ```sh
export GETH_HOME="<same dir>" cargo run -p geth -- --home <printed-path> status
cargo run -p geth -- status cargo run -p geth -- --home <printed-path> node id
cargo run -p geth -- node id
echo "hello geth" > /tmp/hello-geth.txt echo "hello geth" > /tmp/hello-geth.txt
cargo run -p geth -- cas add /tmp/hello-geth.txt cargo run -p geth -- --home <printed-path> cas add /tmp/hello-geth.txt
cargo run -p geth -- cas list cargo run -p geth -- --home <printed-path> cas list
``` ```
For normal persistent use, `geth daemon install` initializes and starts a
background user service. Run `geth guide quickstart` to compare startup modes.
## Backup And Restore ## Backup And Restore
`geth backup create --out <dir>` creates an offline directory backup with a `geth backup create --out <dir>` creates an offline directory backup with a

View file

@ -11,6 +11,7 @@ base64.workspace = true
clap.workspace = true clap.workspace = true
clap_complete.workspace = true clap_complete.workspace = true
serde_json.workspace = true serde_json.workspace = true
tempfile.workspace = true
tokio.workspace = true tokio.workspace = true
geth-config = { path = "../geth-config" } geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }

File diff suppressed because it is too large Load diff

View file

@ -92,7 +92,7 @@ async fn serve_local_control(node: LocalNode, listener: UnixListener) -> Result<
} }
}); });
} }
signal = tokio::signal::ctrl_c() => { signal = shutdown_signal() => {
signal?; signal?;
tracing::info!("shutdown signal received"); tracing::info!("shutdown signal received");
return Ok(()); return Ok(());
@ -101,6 +101,22 @@ async fn serve_local_control(node: LocalNode, listener: UnixListener) -> Result<
} }
} }
async fn shutdown_signal() -> Result<(), std::io::Error> {
#[cfg(unix)]
{
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
result = tokio::signal::ctrl_c() => result,
_ = terminate.recv() => Ok(()),
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await
}
}
pub(crate) fn spawn_iroh_control_accept_loop( pub(crate) fn spawn_iroh_control_accept_loop(
node: LocalNode, node: LocalNode,
endpoint: GethIrohEndpoint, endpoint: GethIrohEndpoint,

View file

@ -73,6 +73,7 @@ pub struct ServiceReport {
pub manager: ServiceManager, pub manager: ServiceManager,
pub action: ServiceAction, pub action: ServiceAction,
pub service_name: String, pub service_name: String,
pub state: Option<String>,
pub definition_path: Option<PathBuf>, pub definition_path: Option<PathBuf>,
pub definition: Option<String>, pub definition: Option<String>,
pub commands: Vec<Vec<String>>, pub commands: Vec<Vec<String>>,
@ -223,39 +224,37 @@ pub fn status_user_service(manager: ServiceManager) -> Result<ServiceReport, Ser
let manager = manager.resolve()?; let manager = manager.resolve()?;
match manager { match manager {
ServiceManager::SystemdUser => { ServiceManager::SystemdUser => {
let command = run_command( let (command, code, detail) = inspect_command(
"systemctl", "systemctl",
&["--user", "status", SYSTEMD_UNIT, "--no-pager"], &["--user", "status", SYSTEMD_UNIT, "--no-pager"],
)?; )?;
Ok(report( Ok(status_report(
manager, manager,
ServiceAction::Status,
Some(systemd_unit_path()?), Some(systemd_unit_path()?),
None, command,
vec![command], systemd_state(code),
"queried systemd user service", &detail,
)) ))
} }
ServiceManager::LaunchdUser => { ServiceManager::LaunchdUser => {
let command = run_command("launchctl", &["list", LAUNCHD_LABEL])?; let (command, code, detail) = inspect_command("launchctl", &["list", LAUNCHD_LABEL])?;
Ok(report( Ok(status_report(
manager, manager,
ServiceAction::Status,
Some(launchd_plist_path()?), Some(launchd_plist_path()?),
None, command,
vec![command], launchd_state(code, &detail),
"queried launchd user agent", &detail,
)) ))
} }
ServiceManager::WindowsTask => { ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?; let (command, code, detail) =
Ok(report( inspect_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?;
Ok(status_report(
manager, manager,
ServiceAction::Status,
None, None,
None, command,
vec![command], windows_task_state(code, &detail),
"queried Windows per-user scheduled task", &detail,
)) ))
} }
ServiceManager::Auto => unreachable!("auto is resolved above"), ServiceManager::Auto => unreachable!("auto is resolved above"),
@ -358,12 +357,12 @@ fn install_launchd_user(
} }
let definition = launchd_plist(paths, executable); let definition = launchd_plist(paths, executable);
std::fs::write(&plist_path, &definition)?; std::fs::write(&plist_path, &definition)?;
let mut commands = vec![run_command( let mut commands = Vec::new();
if start {
commands.push(run_command(
"launchctl", "launchctl",
&["load", "-w", &plist_path.display().to_string()], &["load", "-w", &plist_path.display().to_string()],
)?]; )?);
if start {
commands.push(run_command("launchctl", &["start", LAUNCHD_LABEL])?);
} }
Ok(report( Ok(report(
ServiceManager::LaunchdUser, ServiceManager::LaunchdUser,
@ -379,10 +378,9 @@ fn uninstall_launchd_user() -> Result<ServiceReport, ServiceError> {
let plist_path = launchd_plist_path()?; let plist_path = launchd_plist_path()?;
let mut commands = Vec::new(); let mut commands = Vec::new();
if plist_path.exists() { if plist_path.exists() {
commands.push(run_command( let (command, _, _) =
"launchctl", inspect_command("launchctl", &["unload", &plist_path.display().to_string()])?;
&["unload", &plist_path.display().to_string()], commands.push(command);
)?);
std::fs::remove_file(&plist_path)?; std::fs::remove_file(&plist_path)?;
} }
Ok(report( Ok(report(
@ -546,6 +544,83 @@ fn run_command(program: &str, args: &[&str]) -> Result<Vec<String>, ServiceError
} }
} }
fn inspect_command(
program: &str,
args: &[&str],
) -> Result<(Vec<String>, Option<i32>, String), ServiceError> {
let output = Command::new(program).args(args).output()?;
let command = std::iter::once(program.to_owned())
.chain(args.iter().map(|arg| (*arg).to_owned()))
.collect::<Vec<_>>();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let detail = if stdout.is_empty() { stderr } else { stdout }
.lines()
.next()
.unwrap_or_default()
.to_owned();
Ok((command, output.status.code(), detail))
}
fn systemd_state(code: Option<i32>) -> &'static str {
match code {
Some(0) => "running",
Some(3) => "not-running",
Some(4) => "not-installed",
_ => "unknown",
}
}
fn launchd_state(code: Option<i32>, detail: &str) -> &'static str {
if code == Some(0) {
"loaded"
} else if detail
.to_ascii_lowercase()
.contains("could not find service")
{
"not-loaded"
} else {
"unknown"
}
}
fn windows_task_state(code: Option<i32>, detail: &str) -> &'static str {
if code == Some(0) {
"installed"
} else {
let detail = detail.to_ascii_lowercase();
if detail.contains("cannot find") || detail.contains("does not exist") {
"not-installed"
} else {
"unknown"
}
}
}
fn status_report(
manager: ServiceManager,
definition_path: Option<PathBuf>,
command: Vec<String>,
state: &str,
detail: &str,
) -> ServiceReport {
let note = if detail.is_empty() {
format!("user service is {state}")
} else {
format!("user service is {state}: {detail}")
};
let mut report = report(
manager,
ServiceAction::Status,
definition_path,
None,
vec![command],
&note,
);
report.state = Some(state.to_owned());
report
}
fn report( fn report(
manager: ServiceManager, manager: ServiceManager,
action: ServiceAction, action: ServiceAction,
@ -563,6 +638,7 @@ fn report(
}, },
manager, manager,
action, action,
state: None,
definition_path, definition_path,
definition, definition,
commands, commands,
@ -619,4 +695,35 @@ mod tests {
assert!(command.contains("GETH_HOME=")); assert!(command.contains("GETH_HOME="));
assert!(command.contains("daemon run")); assert!(command.contains("daemon run"));
} }
#[test]
fn service_status_reports_non_running_state_without_an_action_error() {
let report = status_report(
ServiceManager::SystemdUser,
Some(PathBuf::from("/tmp/geth.service")),
vec!["systemctl".to_owned(), "status".to_owned()],
"not-running",
"Unit geth.service could not be found.",
);
assert_eq!(report.action, ServiceAction::Status);
assert_eq!(report.state.as_deref(), Some("not-running"));
assert!(report.note.contains("could not be found"));
}
#[test]
fn service_status_classification_preserves_manager_errors_as_unknown() {
assert_eq!(systemd_state(Some(0)), "running");
assert_eq!(systemd_state(Some(3)), "not-running");
assert_eq!(systemd_state(Some(4)), "not-installed");
assert_eq!(systemd_state(Some(1)), "unknown");
assert_eq!(
launchd_state(Some(1), "Could not find service local.geth.daemon"),
"not-loaded"
);
assert_eq!(launchd_state(Some(1), "operation not permitted"), "unknown");
assert_eq!(
windows_task_state(Some(1), "ERROR: The system cannot find the file specified."),
"not-installed"
);
}
} }

View file

@ -325,6 +325,41 @@ fn cli_help_documents_owner_init_keys() {
assert!(stdout.contains("geth guide owner-setup")); assert!(stdout.contains("geth guide owner-setup"));
} }
#[test]
fn base_help_describes_lifecycle_and_resource_commands() {
let home = tempfile::tempdir().expect("tempdir");
let output = run_geth(home.path(), &["--help"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("daemon Run, install, and manage the daemon"));
assert!(stdout.contains("status Show daemon, storage, Iroh, and backend health"));
assert!(stdout.contains("cas Store, fetch, pin, and synchronize"));
assert!(stdout.contains("geth daemon install"));
assert!(stdout.contains("geth daemon run --ephemeral"));
}
#[test]
fn home_flag_overrides_geth_home_for_initialization() {
let environment_home = tempfile::tempdir().expect("environment home");
let selected_parent = tempfile::tempdir().expect("selected parent");
let selected_home = selected_parent.path().join("selected-home");
let output = run_geth(
environment_home.path(),
&[
"--home",
selected_home.to_str().expect("selected home"),
"init",
],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(selected_home.join("geth.sqlite").exists());
assert!(!environment_home.path().join("geth.sqlite").exists());
}
#[test] #[test]
fn guide_command_explains_key_roles() { fn guide_command_explains_key_roles() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");
@ -535,6 +570,12 @@ fn json_errors_include_stable_code_for_common_failures() {
.expect("error message") .expect("error message")
.contains("connect to daemon") .contains("connect to daemon")
); );
assert!(
error["hint"]
.as_str()
.expect("daemon recovery hint")
.contains("geth daemon install")
);
} }
#[test] #[test]

View file

@ -6,8 +6,15 @@ Accepted.
## Decision ## Decision
Geth provides `geth daemon service ...` commands to install, uninstall, start, Geth provides direct `geth daemon install|start|stop|status|uninstall` commands
stop, inspect, and print daemon service definitions. for the common lifecycle. `daemon install` initializes the selected geth home,
installs and enables the user service, and starts it immediately. The existing
`geth daemon service ...` commands remain available for compatibility, explicit
manager selection, definition previews, and install-without-start behavior.
Status probes return a normalized state. An inactive or missing service is a
successful inspection result, while a service-manager access problem is
reported as `unknown` with the manager's diagnostic rather than mislabeled as a
stopped daemon.
The service is always installed as a user service: The service is always installed as a user service:

View file

@ -26,6 +26,8 @@ accept and live-sync tasks, releases native docs/gossip/blob handles, calls
`Endpoint::close().await`, and removes the local control socket. The same `Endpoint::close().await`, and removes the local control socket. The same
cleanup path runs when the serving loop returns an error, preventing stale cleanup path runs when the serving loop returns an error, preventing stale
socket files and unclosed endpoint clones from becoming restart behavior. socket files and unclosed endpoint clones from becoming restart behavior.
Foreground daemons handle Ctrl-C, and Unix daemons also handle the SIGTERM used
by user service managers, through this same graceful shutdown path.
The local metadata store is SQLite product state. `geth-store` tracks a numeric The local metadata store is SQLite product state. `geth-store` tracks a numeric
`schema_version` in the `meta` table and applies ordered migrations up to the `schema_version` in the `meta` table and applies ordered migrations up to the
@ -44,6 +46,14 @@ daemon. The initial backends are systemd user units on Linux, launchd user agent
on macOS, and per-user scheduled tasks on Windows. Geth does not install itself on macOS, and per-user scheduled tasks on Windows. Geth does not install itself
as a privileged system service. as a privileged system service.
The common service lifecycle is available directly as `geth daemon
install|start|stop|status|uninstall`; the nested service commands remain the
advanced and compatibility surface. `geth daemon run --ephemeral` creates a
temporary home, reports how another CLI process can select it with `--home`, and
removes it after a normal foreground shutdown. Ephemeral mode still starts the
same daemon-owned Iroh endpoint and local control stack; it is not a second
runtime or transport path.
## Iroh-Only Remote Communication ## Iroh-Only Remote Communication
Remote geth node-to-node communication is Iroh-only. The daemon will own one Remote geth node-to-node communication is Iroh-only. The daemon will own one

View file

@ -18,12 +18,11 @@ export GETH_HOME="${GETH_HOME:-$HOME/.local/share/geth/geth}"
geth init geth init
geth daemon run >"$GETH_HOME/daemon.log" 2>&1 & geth daemon run >"$GETH_HOME/daemon.log" 2>&1 &
daemon_pid=$! daemon_pid=$!
trap 'kill "$daemon_pid" 2>/dev/null || true' EXIT
geth wait daemon --timeout-ms 30000 geth wait daemon --timeout-ms 30000
geth doctor --json geth doctor --json
geth status --json geth status --json
trap 'kill "$daemon_pid" 2>/dev/null || true' EXIT
``` ```
Create a backup into a separate directory and validate that it can restore to a Create a backup into a separate directory and validate that it can restore to a
@ -106,13 +105,17 @@ manager mutation:
```sh ```sh
geth daemon service print geth daemon service print
geth daemon service install --start geth daemon install
geth wait daemon --timeout-ms 30000 geth wait daemon --timeout-ms 30000
geth daemon service status geth daemon status
geth daemon service stop geth daemon stop
geth daemon service uninstall geth daemon uninstall
``` ```
For a disposable interactive test, `geth daemon run --ephemeral` creates and
prints a temporary home. Other commands can target it explicitly with `geth
--home <printed-path> ...`; normal Ctrl-C shutdown removes the state.
Admin SSH keys remain outside geth state. Automation should pass public admin Admin SSH keys remain outside geth state. Automation should pass public admin
keys with `--admin-key` and use the matching private key only as an argument to keys with `--admin-key` and use the matching private key only as an argument to
the explicit signing command when an admin operation is intended: the explicit signing command when an admin operation is intended:

View file

@ -20,7 +20,9 @@ to stable commands after the first deployment tag.
The following command families are intended to be stable automation surfaces: The following command families are intended to be stable automation surfaces:
- `geth init` - `geth init`
- `geth daemon run` - `geth --home <dir> ...`
- `geth daemon run [--ephemeral]`
- `geth daemon install|start|stop|status|uninstall`
- `geth daemon service install|uninstall|start|stop|status|print` - `geth daemon service install|uninstall|start|stop|status|print`
- `geth status` - `geth status`
- `geth doctor` - `geth doctor`

View file

@ -49,8 +49,9 @@ On Machine A:
```sh ```sh
geth init --admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519 --node-name owner geth init --admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519 --node-name owner
geth daemon service install --start geth daemon install
geth wait daemon --timeout-ms 30000 geth wait daemon --timeout-ms 30000
geth daemon status
geth status --json geth status --json
geth doctor --json geth doctor --json
geth peer export --out /tmp/owner.peer.json geth peer export --out /tmp/owner.peer.json
@ -191,8 +192,8 @@ On Machine A:
```sh ```sh
geth backup create --out /tmp/geth-backup geth backup create --out /tmp/geth-backup
geth daemon service stop geth daemon stop
geth daemon service start geth daemon start
geth wait daemon --timeout-ms 30000 geth wait daemon --timeout-ms 30000
geth doctor --json geth doctor --json
``` ```

View file

@ -15,6 +15,53 @@ Status markers:
For deployment-readiness work that cuts across feature areas, see For deployment-readiness work that cuts across feature areas, see
[`docs/production-readiness-roadmap.md`](production-readiness-roadmap.md). [`docs/production-readiness-roadmap.md`](production-readiness-roadmap.md).
## Operator Usability
- `[x]` Make startup modes and the daemon lifecycle discoverable.
Acceptance criteria:
- `[x]` Base and nested CLI help explain every command family instead of
showing unlabeled command names.
- `[x]` `geth daemon install` initializes, installs, enables, and starts a
background service for the current user.
- `[x]` Common start, stop, status, and uninstall operations do not require
the nested compatibility command path.
- `[x]` Service status reports running, inactive/not-installed, and unknown
manager states without treating every nonzero status probe as an action
failure.
- `[x]` `geth daemon run --ephemeral` creates disposable state and prints the
exact `--home` selector needed by another terminal.
- `[x]` Unix user-service shutdown handles SIGTERM through the daemon's
graceful Iroh/task/socket cleanup path.
- `[x]` Tests cover lifecycle parsing, help discoverability, and explicit
home selection without mutating a real user service manager.
- `[x]` Document task-oriented user stories.
Acceptance criteria:
- `[x]` Workflows cover disposable evaluation, persistent background use,
owner setup, enrollment, CAS transfer, synchronized application state,
automation, diagnosis, backup, and recovery.
- `[x]` Each workflow identifies its success signal and relevant trust or
durability boundary.
- `[x]` Documentation distinguishes automated coverage from real-machine,
hardware-key, relay, and privileged-interface dogfooding.
- `[ ]` Add unified service-log inspection.
Acceptance criteria:
- `[ ]` One CLI command gives the platform-appropriate user-service log view
or an exact recovery command on Linux, macOS, and Windows.
- `[ ]` Log access remains user-scoped and does not require a system service.
- `[ ]` Human and JSON output distinguish unavailable logs, an uninstalled
service, and an installed service with no log entries.
- `[ ]` Publish copy-paste installation entrypoints for release artifacts.
Acceptance criteria:
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact
checksums and put the single `geth` executable on `PATH`.
- `[ ]` Installation stays separate from explicit `geth daemon install` so
downloading a binary never silently creates trust state or starts a service.
- `[ ]` Upgrade and uninstall instructions preserve or explicitly remove the
selected geth home.
## Long-Term Goal: Distributed Homelab Overlay ## Long-Term Goal: Distributed Homelab Overlay
Goal: evolve geth into a distributed, fault-tolerant homelab overlay runtime in Goal: evolve geth into a distributed, fault-tolerant homelab overlay runtime in

233
docs/user-workflows.md Normal file
View file

@ -0,0 +1,233 @@
# User Workflows
This document describes geth from the operator's point of view. Each workflow
states the intended outcome, the shortest supported command path, the success
signal, and important trust or durability boundaries.
## Choose A Startup Mode
### Try geth without keeping state
User story: as a curious user or test author, I want an isolated daemon without
choosing a directory or cleaning it up afterward.
```sh
geth daemon run --ephemeral
```
The command initializes a temporary home, prints its path and a ready-to-copy
control command, and runs in the foreground. In another terminal:
```sh
geth --home <printed-path> status
geth --home <printed-path> node id
```
Success means `geth status` reports `geth daemon: running`. Ctrl-C performs a
graceful shutdown and removes the temporary home. An abrupt process kill may
leave temporary files for the operating system's normal temp cleanup.
### Keep a persistent background node
User story: as a workstation user, I want geth to start now and at future
logins without learning my platform's service-manager syntax.
```sh
geth daemon install
geth wait daemon
geth status
```
`daemon install` initializes the selected home if needed, installs and enables
a service for the current user, and starts it. It never installs a system
service. Common lifecycle operations are direct:
```sh
geth daemon status
geth daemon stop
geth daemon start
geth daemon uninstall
```
The older `geth daemon service ...` family remains supported for scripts and
advanced options. In particular, `geth daemon service install` installs without
starting unless `--start` is supplied.
### Keep state but run in the foreground
User story: as a developer, I want persistent state and logs attached to my
terminal.
```sh
geth init
geth daemon run
```
Set `RUST_LOG=geth_node=debug` when more daemon diagnostics are useful. Use
`geth --home <dir> ...` to operate an isolated home without exporting an
environment variable.
## Establish An Owner Trust Root
User story: as the mesh owner, I want my first node rooted in an existing SSH
or hardware-backed OpenSSH admin key without copying that private key into
geth.
```sh
geth init \
--admin-key ~/.ssh/id_ed25519_sk.pub \
--signing-key ~/.ssh/id_ed25519_sk \
--owner eric \
--node-name owner-laptop
geth daemon install
geth keychain status
geth node list
```
The public key becomes an admin trust anchor. The private key or FIDO/YubiKey
stub is passed to `ssh-keygen -Y sign`; geth does not copy it into local state.
Success means `keychain status` reports accepted signed operations and `node
list` includes the named owner node.
## Enroll A Second Node
User story: as the owner, I want to approve a new device without treating
discovery or a peer card as proof of trust.
1. Export and transfer the owner's signed peer card:
```sh
geth peer export --out owner.peer.json
```
2. On the new node, initialize, import the card, and submit a request:
```sh
geth init
geth daemon install
geth peer import owner.peer.json
geth node enroll request --node-name workstation --out workstation.enroll.json
geth node enroll submit owner-laptop --path workstation.enroll.json
```
3. On the owner node, review and approve with the admin key:
```sh
geth node enroll list --status pending
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
```
4. On the new node, pull and inspect the approved state:
```sh
geth sync now owner-laptop
geth wait sync owner-laptop
geth node list
```
Peer-card import only supplies signed endpoint metadata. The owner-signed
keychain and authorization operations are what create trust and capabilities.
## Move A Blob Between Nodes
User story: as a mesh user, I want to address content by hash and let an
authorized node fetch it over Iroh.
On the provider:
```sh
geth cas add ./archive.tar
geth node grant workstation resource:cas:local cas.fetch \
--signing-key ~/.ssh/id_ed25519_sk
```
On the consumer, after peer cards and grants have synchronized:
```sh
geth cas fetch owner-laptop <blob-hash>
geth cas providers <blob-hash>
geth cas get <blob-hash> --out ./archive.tar
```
Success means the fetch reports the provider, `cas providers` records it, and
the output hashes to the requested CAS hash. Remote fetch is Iroh-only and is
checked against `cas.fetch` on `resource:cas:local`.
## Synchronize Application State
User story: as a script author, I want small durable state primitives without
building transport, peer authentication, and retry handling myself.
Start locally with one of:
```sh
geth kv create preferences
geth kv set preferences theme dark
geth document create settings
geth document set settings '{"theme":"dark"}'
geth db add inventory ./inventory.sqlite
geth cas root add notes ./notes
geth cas root scan notes
```
Grant the matching resource capability to a node, then use the module's `sync`
command or `geth sync now <node>`. Check `geth sync status --json` for
per-peer/per-stream cursors and retry state. File-root application is
conservative: it does not overwrite local edits, and ambiguous changes become
durable conflicts for `geth cas conflict list`.
## Automate Reliably
User story: as an automation author, I want explicit homes, readiness checks,
machine-readable output, and stable errors.
```sh
geth --home "$job_home" init
geth --home "$job_home" daemon run >"$job_home/daemon.log" 2>&1 &
daemon_pid=$!
geth --home "$job_home" wait daemon --timeout-ms 30000 --json
geth --home "$job_home" status --json
```
Use `--json` for single responses and `--jsonl` for streaming responses. A
missing daemon returns the stable error code `daemon_unavailable` plus a startup
hint. See `automation-examples.md` and `command-stability.md` before depending
on experimental command families.
## Diagnose, Back Up, And Recover
User story: as an operator, I want actionable local diagnostics and a backup
that does not accidentally collect private trust anchors.
```sh
geth doctor
geth status
geth sync status
geth backup create --out ./geth-backup
```
`doctor` works even when the daemon is unavailable. Backups exclude daemon
runtime files, private geth identity keys, and external private SSH admin keys.
Restore always targets a separate empty home:
```sh
geth backup restore ./geth-backup --target-home ./restored-geth
geth --home ./restored-geth daemon run
```
## Workflow Verification Coverage
- CLI tests cover help discoverability, `--home` selection, initialization,
daemon control, stable JSON errors, wait behavior, service-definition
generation, and the owner/enrollment/sync path.
- Two-daemon integration tests cover peer-card exchange, authorization denials,
signed log import, CAS/KV/document/DB/file-root sync, pipe/pubsub, and restart
behavior where practical.
- Real user-service managers, hardware keys, real relays, TUN/Wintun privileges,
and the full two-machine experience remain dogfood checks because automated
tests must not mutate host services or require privileged hardware.
The real-machine acceptance checklist is in `dogfood-checklist.md`.