From f61cb44dad60109e2a3d865cc16b48a2753c586c Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 18 Jul 2026 15:23:20 +0200 Subject: [PATCH] Improve CLI and daemon lifecycle usability --- Cargo.lock | 1 + README.md | 61 +- crates/geth-cli/Cargo.toml | 1 + crates/geth-cli/src/lib.rs | 622 ++++++++++++++++----- crates/geth-node/src/daemon.rs | 18 +- crates/geth-node/src/service.rs | 161 +++++- crates/geth/tests/bootstrap.rs | 41 ++ docs/adr/0015-user-service-installation.md | 11 +- docs/architecture.md | 10 + docs/automation-examples.md | 15 +- docs/command-stability.md | 4 +- docs/dogfood-checklist.md | 7 +- docs/roadmap.md | 47 ++ docs/user-workflows.md | 233 ++++++++ 14 files changed, 1028 insertions(+), 204 deletions(-) create mode 100644 docs/user-workflows.md diff --git a/Cargo.lock b/Cargo.lock index fc8032f..81a699d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,6 +1551,7 @@ dependencies = [ "geth-node", "geth-pipe", "serde_json", + "tempfile", "tokio", ] diff --git a/README.md b/README.md index 000a2ee..751ebc2 100644 --- a/README.md +++ b/README.md @@ -8,28 +8,25 @@ still named `geth`. ## First 10 Minutes -Build the single binary, initialize an isolated home, start the daemon, and -verify the local control path: +Build the single binary and start a disposable daemon: ```sh cargo build -p geth -export GETH_HOME="$(mktemp -d)" -printf 'GETH_HOME=%s\n' "$GETH_HOME" -./target/debug/geth init -./target/debug/geth daemon run +./target/debug/geth daemon run --ephemeral ``` -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 -export GETH_HOME="" -./target/debug/geth wait daemon --timeout-ms 30000 -./target/debug/geth status --json -./target/debug/geth doctor --json +./target/debug/geth --home wait daemon +./target/debug/geth --home status --json +./target/debug/geth --home doctor --json ``` -This creates a local-only identity for evaluation. Before enrolling other -machines, use the owner setup in `geth guide owner-setup`; it records an +Ctrl-C stops the daemon and removes its temporary state. For a persistent +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 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. - [`docs/automation-examples.md`](docs/automation-examples.md): shell, Python, 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): the pre-deployment gate and its current status. - [`docs/dogfood-checklist.md`](docs/dogfood-checklist.md): required @@ -57,7 +56,7 @@ It has daemon mode and control mode: ```sh geth init geth daemon run -geth daemon service install +geth daemon install geth status geth node id geth resource list @@ -91,14 +90,15 @@ signed records. The daemon can also install itself as a user service: ```sh -geth daemon service install -geth daemon service status -geth daemon service uninstall +geth daemon install +geth daemon status +geth daemon uninstall ``` 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 -services, not system services. +services, not system services. The longer `geth daemon service ...` family is +retained for compatibility and advanced options. ## Transport And SSH @@ -131,14 +131,15 @@ metadata from an authorized peer over Iroh. 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 examples - `geth completions ` for shell completion scripts generated from the live CLI command tree - `geth init` - `geth init --admin-key --signing-key --node-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 status` - `geth wait daemon|peer|sync --timeout-ms ` @@ -444,25 +445,25 @@ $GETH_HOME/ ## Quick Start -In one shell: +For a disposable evaluation, run this in one shell: ```sh -export GETH_HOME="$(mktemp -d)" -cargo run -p geth -- init -cargo run -p geth -- daemon run +cargo run -p geth -- daemon run --ephemeral ``` -In another shell: +In another shell, use the home it prints: ```sh -export GETH_HOME="" -cargo run -p geth -- status -cargo run -p geth -- node id +cargo run -p geth -- --home status +cargo run -p geth -- --home node id echo "hello geth" > /tmp/hello-geth.txt -cargo run -p geth -- cas add /tmp/hello-geth.txt -cargo run -p geth -- cas list +cargo run -p geth -- --home cas add /tmp/hello-geth.txt +cargo run -p geth -- --home 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 `geth backup create --out ` creates an offline directory backup with a diff --git a/crates/geth-cli/Cargo.toml b/crates/geth-cli/Cargo.toml index a0f3c78..0b7d26a 100644 --- a/crates/geth-cli/Cargo.toml +++ b/crates/geth-cli/Cargo.toml @@ -11,6 +11,7 @@ base64.workspace = true clap.workspace = true clap_complete.workspace = true serde_json.workspace = true +tempfile.workspace = true tokio.workspace = true geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index b77f664..61bb985 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -5,21 +5,35 @@ use clap_complete::{Shell, generate}; use geth_config::GethPaths; use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus}; use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport}; -use std::io::{Read, stdout}; +use std::io::{Read, Write, stdout}; use std::path::PathBuf; use std::time::{Duration, Instant}; const TOP_LEVEL_AFTER_HELP: &str = r#"Common starts: - geth guide init + geth daemon install # initialize, install, and start in background + geth daemon run --ephemeral # disposable foreground daemon + geth guide quickstart geth guide owner-setup - geth guide overlay - geth guide completions geth init geth init --admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk --node-name laptop geth daemon run geth status -Use `geth --help` for command-specific examples."#; +Use `geth --help` for details and `geth guide` for complete workflows."#; + +const DAEMON_AFTER_HELP: &str = r#"Examples: + geth daemon install # persistent user service; starts immediately + geth daemon status + geth daemon stop + geth daemon start + geth daemon uninstall + geth daemon run # foreground, persistent state + geth daemon run --ephemeral # foreground, temporary state + +The service commands only use the current user's service manager. They never +install a privileged system service."#; + +const SERVICE_MANAGER_HELP: &str = "Backend: auto, systemd-user, launchd-user, or windows-task"; const INIT_LONG_ABOUT: &str = r#"Initialize local geth state. @@ -70,6 +84,7 @@ Related: const GUIDE_INDEX: &str = r#"Usage: geth guide Topics: + quickstart Choose disposable, foreground, or background startup. init Local-only init versus owner/admin init. owner-setup First owner node with SSH/YubiKey admin trust. enrollment Add another node/device to the owner mesh. @@ -79,6 +94,36 @@ Topics: completions Shell completion installation examples. smoke-test Minimal commands to verify a node and daemon."#; +const GUIDE_QUICKSTART: &str = r#"Choose the startup that matches what you are doing. + +Try geth without keeping state: + geth daemon run --ephemeral + +The command prints its temporary home. In another terminal, use that path: + geth --home status + geth --home node id + +The state is removed after a normal daemon shutdown (Ctrl-C). + +Install and start a persistent background daemon: + geth daemon install + geth status + +`daemon install` creates the local home if necessary, installs a user-level +service, enables it for future logins, and starts it immediately. Manage it with: + geth daemon status + geth daemon stop + geth daemon start + geth daemon uninstall + +Run a persistent daemon in the foreground instead: + geth init + geth daemon run + +Use `--home ` on any command to select an isolated home without exporting +GETH_HOME. See `geth guide owner-setup` before adding other machines. +"#; + const GUIDE_INIT: &str = r#"geth init has two modes. Local-only: @@ -194,11 +239,11 @@ key options because those fields create signed owner/device/node statements. const GUIDE_SERVICE: &str = r#"Install geth as a user service: - geth daemon service install --start - geth daemon service status - geth daemon service stop - geth daemon service start - geth daemon service uninstall + geth daemon install + geth daemon status + geth daemon stop + geth daemon start + geth daemon uninstall Service installation targets user service managers, not system services: Linux: systemd --user @@ -207,6 +252,10 @@ Service installation targets user service managers, not system services: Preview definitions without installing: geth daemon service print + +The longer `geth daemon service ...` command family remains available for +automation compatibility and for installing without immediately starting via +`geth daemon service install`. "#; const GUIDE_COMPLETIONS: &str = r#"Shell completions: @@ -272,19 +321,16 @@ Bearer invite flow: const GUIDE_SMOKE_TEST: &str = r#"Minimal smoke test: -Terminal 1: - export GETH_HOME="$(mktemp -d)" - geth init - geth daemon run +Terminal 1 (prints a temporary home): + geth daemon run --ephemeral Terminal 2: - export GETH_HOME="" - geth status - geth node id + geth --home status + geth --home node id echo "hello geth" > /tmp/hello-geth.txt - geth cas add /tmp/hello-geth.txt - geth cas list - geth keychain status + geth --home cas add /tmp/hello-geth.txt + geth --home cas list + geth --home keychain status For two-node owner/enrollment testing, use: geth guide owner-setup @@ -294,10 +340,20 @@ For two-node owner/enrollment testing, use: #[derive(Debug, Parser)] #[command( name = "geth", + version, about = "Personal local-first Iroh mesh runtime", - after_long_help = TOP_LEVEL_AFTER_HELP + long_about = "Personal local-first Iroh mesh runtime for nodes, resources, and secure peer workflows.\n\nThis is the local-first geth project, not the Ethereum client. One executable provides both daemon and control commands.", + after_long_help = TOP_LEVEL_AFTER_HELP, + arg_required_else_help = true )] pub struct Cli { + #[arg( + long, + global = true, + value_name = "DIR", + help = "Use DIR as geth home instead of GETH_HOME or the OS data directory" + )] + pub home: Option, #[arg(long, global = true, help = "Print machine-readable JSON output")] pub json: bool, #[arg( @@ -312,15 +368,21 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum Command { + /// Show task-oriented setup and workflow guides Guide { #[arg(value_enum)] topic: Option, }, + /// Generate shell completion scripts Completions { #[arg(value_enum)] shell: Shell, }, - #[command(long_about = INIT_LONG_ABOUT, after_long_help = INIT_AFTER_HELP)] + #[command( + about = "Initialize local state and identity", + long_about = INIT_LONG_ABOUT, + after_long_help = INIT_AFTER_HELP + )] Init { #[arg( long, @@ -355,76 +417,96 @@ pub enum Command { )] capabilities: Vec, }, + /// Run, install, and manage the daemon Daemon { #[command(subcommand)] command: DaemonCommand, }, + /// Show daemon, storage, Iroh, and backend health Status, + /// Inspect or trigger peer synchronization Sync { #[command(subcommand)] command: SyncCommand, }, + /// Wait for daemon, peer, or sync readiness Wait { #[command(subcommand)] command: WaitCommand, }, + /// Create or restore an offline local-state backup Backup { #[command(subcommand)] command: BackupCommand, }, + /// Diagnose local configuration, daemon, trust, and dependency problems Doctor, + /// Inspect and manage trusted nodes and enrollment Node { #[command(subcommand)] command: NodeCommand, }, + /// Exchange peer cards and test peer connectivity Peer { #[command(subcommand)] command: PeerCommand, }, + /// Plan and operate the experimental Iroh overlay network Overlay { #[command(subcommand)] command: OverlayCommand, }, + /// List and create resource registrations Resource { #[command(subcommand)] command: ResourceCommand, }, + /// Manage the SSH-admin-rooted identity keychain Keychain { #[command(subcommand)] command: KeychainCommand, }, + /// Explain and manage resource-scoped authorization Auth { #[command(subcommand)] command: AuthCommand, }, + /// Manage resource secret epochs and bearer access Secret { #[command(subcommand)] command: SecretCommand, }, + /// Store, fetch, pin, and synchronize content-addressed data Cas { #[command(subcommand)] command: CasCommand, }, + /// Operate synchronized key-value stores Kv { #[command(subcommand)] command: KvCommand, }, + /// Publish or read daemon-lifetime topic messages Pubsub { #[command(subcommand)] command: PubsubCommand, }, + /// Open authorized message and byte-stream pipes Pipe { #[command(subcommand)] command: PipeCommand, }, + /// Register and synchronize SQLite/cr-sqlite databases Db { #[command(subcommand)] command: DbCommand, }, + /// Create and synchronize document resources Document { #[command(subcommand)] command: DocumentCommand, }, + /// Use geth-managed SSH proxy, certificate, and revocation workflows Ssh { #[command(subcommand)] command: SshCommand, @@ -433,6 +515,7 @@ pub enum Command { #[derive(Clone, Debug, ValueEnum)] pub enum GuideTopic { + Quickstart, Init, OwnerSetup, Enrollment, @@ -444,8 +527,44 @@ pub enum GuideTopic { } #[derive(Debug, Subcommand)] +#[command(after_long_help = DAEMON_AFTER_HELP)] pub enum DaemonCommand { - Run, + /// Run the daemon in the foreground + Run { + #[arg( + long, + help = "Use a temporary home that is removed after normal shutdown" + )] + ephemeral: bool, + }, + /// Initialize, install, enable, and start a persistent user service + Install { + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] + manager: String, + #[arg(long, help = "Executable path stored in the service definition")] + bin: Option, + }, + /// Start the installed user service + Start { + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] + manager: String, + }, + /// Stop the installed user service + Stop { + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] + manager: String, + }, + /// Show the installed user service status + Status { + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] + manager: String, + }, + /// Stop and remove the installed user service + Uninstall { + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] + manager: String, + }, + /// Advanced and compatibility-preserving service controls Service { #[command(subcommand)] command: ServiceCommand, @@ -454,52 +573,62 @@ pub enum DaemonCommand { #[derive(Debug, Subcommand)] pub enum ServiceCommand { + /// Install and enable the user service Install { - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, - #[arg(long)] + #[arg(long, help = "Executable path stored in the service definition")] bin: Option, - #[arg(long)] + #[arg(long, help = "Start the service immediately after installation")] start: bool, }, + /// Stop and remove the user service Uninstall { - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, + /// Start the installed user service Start { - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, + /// Stop the installed user service Stop { - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, + /// Query the user service manager Status { - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, + /// Preview the service definition without installing it Print { - #[arg(long, default_value = "auto")] + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, - #[arg(long)] + #[arg(long, help = "Executable path used in the preview")] bin: Option, }, } #[derive(Debug, Subcommand)] pub enum SyncCommand { + /// Show per-peer stream health, cursors, and retry state Status, + /// Synchronize all known peers or one selected node immediately Now { node: Option }, } #[derive(Debug, Subcommand)] pub enum WaitCommand { + /// Wait until the local control socket answers Daemon { #[arg(long, default_value_t = 30_000)] timeout_ms: u64, #[arg(long, default_value_t = 250)] interval_ms: u64, }, + /// Wait until an imported peer answers over Iroh Peer { node: String, #[arg(long, default_value_t = 30_000)] @@ -507,6 +636,7 @@ pub enum WaitCommand { #[arg(long, default_value_t = 500)] interval_ms: u64, }, + /// Wait until a peer's sync streams are healthy or marked stale Sync { node: String, #[arg(long, default_value_t = 30_000)] @@ -518,10 +648,12 @@ pub enum WaitCommand { #[derive(Debug, Subcommand)] pub enum BackupCommand { + /// Create an offline backup directory without private identity keys Create { #[arg(long, value_name = "DIR")] out: PathBuf, }, + /// Restore a backup into a new, empty geth home Restore { backup_dir: PathBuf, #[arg(long, value_name = "DIR")] @@ -531,24 +663,31 @@ pub enum BackupCommand { #[derive(Debug, Subcommand)] pub enum NodeCommand { + /// Print the local stable node, agent, and Iroh endpoint identifiers Id, + /// Show the same local runtime health as `geth status` Status, + /// List the active trusted-node view List, + /// Request, review, approve, or synchronize node enrollment Enroll { #[command(subcommand)] command: NodeEnrollCommand, }, + /// Rename a trusted node with an admin-signed keychain operation Rename { node: String, name: String, #[arg(long)] signing_key: Option, }, + /// Revoke a trusted node with an admin-signed keychain operation Revoke { node: String, #[arg(long)] signing_key: Option, }, + /// Grant a trusted node one resource capability Grant { node: String, resource: String, @@ -560,6 +699,7 @@ pub enum NodeCommand { #[arg(long)] admin_key: Option, }, + /// Revoke a previously issued node grant RevokeGrant { resource: String, grant_id: String, @@ -568,12 +708,14 @@ pub enum NodeCommand { #[arg(long)] admin_key: Option, }, + /// Bind an Iroh endpoint to a trusted node EndpointAdd { node: String, endpoint: String, #[arg(long)] signing_key: PathBuf, }, + /// Revoke an Iroh endpoint binding EndpointRevoke { node: String, endpoint: String, @@ -584,6 +726,7 @@ pub enum NodeCommand { #[derive(Debug, Subcommand)] pub enum NodeEnrollCommand { + /// Create a signed enrollment request on the new node Request { #[arg(long)] node_name: String, @@ -594,6 +737,7 @@ pub enum NodeEnrollCommand { #[arg(long)] out: Option, }, + /// Send an enrollment request to an imported owner peer Submit { owner_node: String, #[arg(long)] @@ -601,13 +745,14 @@ pub enum NodeEnrollCommand { #[arg(long)] path: Option, }, - Import { - path: PathBuf, - }, + /// Import an enrollment request from a file + Import { path: PathBuf }, + /// List received enrollment requests List { #[arg(long)] status: Option, }, + /// Approve a request with the owner's SSH admin key Approve { request_id: String, #[arg(long)] @@ -619,24 +764,24 @@ pub enum NodeEnrollCommand { #[arg(long = "capability")] capabilities: Vec, }, - Sync { - owner_node: String, - }, + /// Pull approved enrollment state from the owner node + Sync { owner_node: String }, } #[derive(Debug, Subcommand)] pub enum PeerCommand { + /// Export this daemon's signed peer card Export { #[arg(long)] out: Option, }, - Import { - path: PathBuf, - }, + /// Import and verify a signed peer card + Import { path: PathBuf }, + /// List imported and discovered peer candidates List, - Ping { - node: String, - }, + /// Test protected Iroh connectivity to a peer + Ping { node: String }, + /// Ask a peer whether this node has a capability AuthCheck { node: String, resource: String, @@ -646,12 +791,15 @@ pub enum PeerCommand { #[derive(Debug, Subcommand)] pub enum OverlayCommand { + /// Show local overlay memberships and active interfaces Status, + /// Preview deterministic overlay addressing without changing state Plan { name: String, #[arg(long)] cidr: Option, }, + /// Join an overlay using a resource or bearer secret Join { name: String, #[arg(long)] @@ -659,14 +807,15 @@ pub enum OverlayCommand { #[arg(long)] cidr: Option, }, - Leave { - name: String, - }, + /// Remove local overlay membership + Leave { name: String }, + /// Preview platform-specific interface setup InterfacePlan { name: String, #[arg(long)] platform: Option, }, + /// Create the local TUN/Wintun-style overlay interface Up { name: String, #[arg(long)] @@ -674,12 +823,11 @@ pub enum OverlayCommand { #[arg(long)] mtu: Option, }, - Down { - name: String, - }, - Peers { - name: String, - }, + /// Stop the local overlay interface + Down { name: String }, + /// List peers visible to an overlay + Peers { name: String }, + /// Send one validated IPv4 packet over Iroh Send { name: String, node: String, @@ -688,6 +836,7 @@ pub enum OverlayCommand { #[arg(long)] bearer_secret: Option, }, + /// Read locally received overlay packets Recv { name: String, #[arg(long)] @@ -697,19 +846,24 @@ pub enum OverlayCommand { #[derive(Debug, Subcommand)] pub enum ResourceCommand { + /// List registered resources List, + /// Register a named resource Create { kind: String, name: String }, } #[derive(Debug, Subcommand)] pub enum KeychainCommand { + /// Initialize local keychain metadata and optional admin trust Init { #[arg(long)] admin_key: Option, #[arg(long)] signing_key: Option, }, + /// Show the reduced identity view and signature verification state Status, + /// Add an SSH public key as an admin trust anchor AdminAdd { #[arg(long)] admin_key: PathBuf, @@ -722,6 +876,7 @@ pub enum KeychainCommand { #[arg(long)] valid_before_ms: Option, }, + /// Revoke an admin trust anchor AdminRevoke { key: String, #[arg(long)] @@ -729,10 +884,12 @@ pub enum KeychainCommand { #[arg(long)] admin_key: Option, }, + /// Write the active OpenSSH allowed_signers projection AllowedSigners { #[arg(long)] out: Option, }, + /// Sign an arbitrary file through an active SSH admin key SignFile { #[arg(long = "in")] input: PathBuf, @@ -745,6 +902,7 @@ pub enum KeychainCommand { #[arg(long)] admin_key: Option, }, + /// Verify a file signature against current keychain trust VerifyFile { #[arg(long = "in")] input: PathBuf, @@ -757,10 +915,12 @@ pub enum KeychainCommand { #[arg(long)] principal: Option, }, + /// Export the canonical SSH signature chain Sigchain { #[arg(long)] out: Option, }, + /// Build a signed static-publication bundle PublishBundle { #[arg(long)] out: PathBuf, @@ -773,14 +933,17 @@ pub enum KeychainCommand { #[arg(long = "snapshot")] snapshots: Vec, }, + /// Verify a signature chain without importing it VerifySigchain { #[arg(long = "in")] input: PathBuf, }, + /// Verify and import a signature chain ImportSigchain { #[arg(long = "in")] input: PathBuf, }, + /// Verify a published checkpoint and its discovery metadata VerifyCheckpoint { #[arg(long)] checkpoint: PathBuf, @@ -795,6 +958,7 @@ pub enum KeychainCommand { #[arg(long)] principal: Option, }, + /// Fetch a published signature chain over HTTPS Fetch { #[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)] url: String, @@ -803,28 +967,27 @@ pub enum KeychainCommand { #[arg(long)] import: bool, }, - Explain { - op_id: String, - }, - ExplainSigner { - key: String, - }, + /// Explain why one keychain operation was accepted or rejected + Explain { op_id: String }, + /// Explain the current trust state of one signer + ExplainSigner { key: String }, + /// Verify all locally stored keychain operations Verify, - Sync { - node: String, - }, + /// Pull verified keychain operations from a trusted peer + Sync { node: String }, } #[derive(Debug, Subcommand)] pub enum AuthCommand { + /// Explain an allow or deny decision for one subject and capability Explain { subject: String, resource: String, capability: String, }, - Sync { - node: String, - }, + /// Pull verified authorization operations from a trusted peer + Sync { node: String }, + /// Add an admin-signed resource capability grant Grant { subject: String, resource: String, @@ -836,6 +999,7 @@ pub enum AuthCommand { #[arg(long)] admin_key: Option, }, + /// Revoke an admin-signed grant by id Revoke { resource: String, grant_id: String, @@ -848,13 +1012,13 @@ pub enum AuthCommand { #[derive(Debug, Subcommand)] pub enum SecretCommand { + /// List resource secret epochs Status, - Create { - resource: String, - }, - Rotate { - resource: String, - }, + /// Create the first local secret epoch for a resource + Create { resource: String }, + /// Rotate a resource to a new local secret epoch + Rotate { resource: String }, + /// Create, prove, verify, list, or revoke bearer access Bearer { #[command(subcommand)] command: SecretBearerCommand, @@ -863,6 +1027,7 @@ pub enum SecretCommand { #[derive(Debug, Subcommand)] pub enum SecretBearerCommand { + /// Create resource-scoped bearer access and print its token once Create { resource: String, #[arg(long = "capability", required = true)] @@ -870,12 +1035,15 @@ pub enum SecretBearerCommand { #[arg(long)] expires_at_ms: Option, }, + /// List public bearer metadata without private tokens List, + /// Issue a possession challenge for requested capabilities Challenge { resource: String, #[arg(long = "capability", required = true)] capabilities: Vec, }, + /// Produce a challenge response from a bearer token Prove { token: String, resource: String, @@ -884,6 +1052,7 @@ pub enum SecretBearerCommand { #[arg(long = "capability", required = true)] capabilities: Vec, }, + /// Verify a bearer challenge response locally Verify { token: String, resource: String, @@ -894,62 +1063,59 @@ pub enum SecretBearerCommand { #[arg(long = "capability", required = true)] capabilities: Vec, }, - Revoke { - resource: String, - bearer_id: String, - }, + /// Revoke bearer access by its public id + Revoke { resource: String, bearer_id: String }, } #[derive(Debug, Subcommand)] pub enum CasCommand { - Add { - path: PathBuf, - }, - AddPrivate { - resource: String, - path: PathBuf, - }, + /// Add a file to the local content-addressed store + Add { path: PathBuf }, + /// Add a prototype resource-encrypted file envelope + AddPrivate { resource: String, path: PathBuf }, + /// Copy a local blob to a path Get { hash: String, #[arg(long)] out: PathBuf, }, + /// Decrypt a prototype private blob to a path GetPrivate { resource: String, hash: String, #[arg(long)] out: PathBuf, }, + /// Fetch an authorized blob from an imported peer over Iroh Fetch { node: String, hash: String, #[arg(long)] bearer_secret: Option, }, - Hash { - path: PathBuf, - }, - Has { - hash: String, - }, - Pin { - hash: String, - }, - Unpin { - hash: String, - }, + /// Hash a file without adding it + Hash { path: PathBuf }, + /// Check whether a blob exists locally + Has { hash: String }, + /// Protect a blob from cleanup + Pin { hash: String }, + /// Allow a blob to be removed by cleanup + Unpin { hash: String }, + /// Remove unpinned local blobs Cleanup { #[arg(long)] dry_run: bool, }, - Providers { - hash: String, - }, + /// List known local and peer providers for a blob + Providers { hash: String }, + /// List local blobs and pin state List, + /// Register, scan, synchronize, and safely apply file roots Root { #[command(subcommand)] command: CasRootCommand, }, + /// Inspect and resolve durable file-root conflicts Conflict { #[command(subcommand)] command: CasConflictCommand, @@ -958,20 +1124,20 @@ pub enum CasCommand { #[derive(Debug, Subcommand)] pub enum CasRootCommand { - Add { - name: String, - path: PathBuf, - }, + /// Register a local directory as a named file root + Add { name: String, path: PathBuf }, + /// List local and peer-qualified file roots List, - Scan { - name: String, - }, + /// Scan a local root and store its deterministic CAS tree + Scan { name: String }, + /// Pull authorized tree metadata and bytes from a peer Sync { node: String, name: String, #[arg(long)] bearer_secret: Option, }, + /// Materialize a tree without overwriting local edits Apply { source: String, #[arg(long)] @@ -983,6 +1149,7 @@ pub enum CasRootCommand { #[derive(Debug, Subcommand)] pub enum CasConflictCommand { + /// Record a file-root conflict explicitly Record { root: String, path: String, @@ -996,10 +1163,12 @@ pub enum CasConflictCommand { #[arg(long)] remote_tree: Option, }, + /// List unresolved and resolved conflicts List { #[arg(long)] root: Option, }, + /// Record an operator-selected conflict resolution Resolve { conflict_id: String, resolution: String, @@ -1010,9 +1179,9 @@ pub enum CasConflictCommand { #[derive(Debug, Subcommand)] pub enum KvCommand { - Create { - name: String, - }, + /// Create a named local key-value store + Create { name: String }, + /// Set a local key, optionally checking a non-owner subject Set { name: String, key: String, @@ -1020,10 +1189,9 @@ pub enum KvCommand { #[arg(long)] subject: Option, }, - Get { - name: String, - key: String, - }, + /// Read a local value + Get { name: String, key: String }, + /// Pull authorized values from a peer over Iroh Sync { node: String, name: String, @@ -1034,6 +1202,7 @@ pub enum KvCommand { #[derive(Debug, Subcommand)] pub enum PubsubCommand { + /// Publish a daemon-lifetime message locally or to a peer Pub { topic: String, message: String, @@ -1042,6 +1211,7 @@ pub enum PubsubCommand { #[arg(long)] bearer_secret: Option, }, + /// Read the current daemon-lifetime topic snapshot Sub { topic: String, #[arg(long)] @@ -1053,6 +1223,7 @@ pub enum PubsubCommand { #[derive(Debug, Subcommand)] pub enum PipeCommand { + /// Register a daemon-lifetime listener locally or on a peer Listen { name: String, #[arg(long)] @@ -1060,6 +1231,7 @@ pub enum PipeCommand { #[arg(long)] bearer_secret: Option, }, + /// Request an authorized pipe connection Connect { target: String, #[arg(long)] @@ -1067,6 +1239,7 @@ pub enum PipeCommand { #[arg(long)] bearer_secret: Option, }, + /// Forward a local loopback TCP listener to a peer's loopback target ForwardTcp { #[arg(long)] listen: String, @@ -1077,6 +1250,7 @@ pub enum PipeCommand { #[arg(long)] bearer_secret: Option, }, + /// Forward a local Unix socket to an absolute socket path on a peer ForwardUnix { #[arg(long)] listen: PathBuf, @@ -1087,6 +1261,7 @@ pub enum PipeCommand { #[arg(long)] bearer_secret: Option, }, + /// Send text, a file, or stdin through a dedicated Iroh pipe Send { target: String, message: Option, @@ -1097,6 +1272,7 @@ pub enum PipeCommand { #[arg(long)] bearer_secret: Option, }, + /// Drain messages from a local daemon-lifetime listener Recv { name: String, #[arg(long)] @@ -1106,13 +1282,11 @@ pub enum PipeCommand { #[derive(Debug, Subcommand)] pub enum DbCommand { - Add { - name: String, - path: PathBuf, - }, - Status { - name: String, - }, + /// Register a local SQLite database without mutating its schema + Add { name: String, path: PathBuf }, + /// Show schema compatibility and cr-sqlite metadata + Status { name: String }, + /// Read a typed batch from crsql_changes Changes { name: String, #[arg(long)] @@ -1120,6 +1294,7 @@ pub enum DbCommand { #[arg(long, default_value_t = 100)] limit: u32, }, + /// Pull and apply an authorized compatible change batch Sync { node: String, name: String, @@ -1132,19 +1307,15 @@ pub enum DbCommand { #[derive(Debug, Subcommand)] pub enum DocumentCommand { - Create { - name: String, - }, - Status { - name: String, - }, - Set { - name: String, - state_json: String, - }, - Get { - name: String, - }, + /// Create a named local document resource + Create { name: String }, + /// Show local document metadata + Status { name: String }, + /// Replace local document state from validated JSON + Set { name: String, state_json: String }, + /// Read local document state + Get { name: String }, + /// Pull authorized document changes from a peer Sync { node: String, name: String, @@ -1155,21 +1326,25 @@ pub enum DocumentCommand { #[derive(Debug, Subcommand)] pub enum SshCommand { + /// Act as an OpenSSH ProxyCommand over an authorized Iroh stream Proxy { node: String, #[arg(long)] bearer_secret: Option, }, + /// Run a restricted geth admin command on a peer AdminShell { node: String, command: String, #[arg(long)] bearer_secret: Option, }, + /// Request, approve, import, list, or sync SSH certificates Cert { #[command(subcommand)] command: SshCertCommand, }, + /// Add, export, import, list, or sync SSH revocations Revocation { #[command(subcommand)] command: SshRevocationCommand, @@ -1178,6 +1353,7 @@ pub enum SshCommand { #[derive(Debug, Subcommand)] pub enum SshCertCommand { + /// Create a signed SSH certificate request Request { #[arg(long)] public_key: PathBuf, @@ -1194,10 +1370,12 @@ pub enum SshCertCommand { #[arg(long)] subject: Option, }, + /// List local certificate requests Requests { #[arg(long)] subject: Option, }, + /// Build or execute the OpenSSH certificate signing command Approve { request_id: String, #[arg(long)] @@ -1213,6 +1391,7 @@ pub enum SshCertCommand { #[arg(long)] subject: Option, }, + /// Attach a signed certificate to its request Import { request_id: String, #[arg(long)] @@ -1220,10 +1399,12 @@ pub enum SshCertCommand { #[arg(long)] subject: Option, }, + /// List stored SSH certificates List { #[arg(long)] subject: Option, }, + /// Pull authorized certificate metadata from a peer Sync { node: String, #[arg(long)] @@ -1233,6 +1414,7 @@ pub enum SshCertCommand { #[derive(Debug, Subcommand)] pub enum SshRevocationCommand { + /// Record a key, certificate, serial, or key-id revocation Add { kind: String, target: String, @@ -1241,10 +1423,12 @@ pub enum SshRevocationCommand { #[arg(long)] subject: Option, }, + /// List stored SSH revocations List { #[arg(long)] subject: Option, }, + /// Export JSONL, OpenSSH KRL specification, or binary KRL data Export { #[arg(long)] out: PathBuf, @@ -1255,6 +1439,7 @@ pub enum SshRevocationCommand { #[arg(long)] subject: Option, }, + /// Import JSONL or an enumerable OpenSSH KRL specification Import { path: PathBuf, #[arg(long, default_value = "jsonl")] @@ -1262,6 +1447,7 @@ pub enum SshRevocationCommand { #[arg(long)] subject: Option, }, + /// Pull authorized revocation metadata from a peer Sync { node: String, #[arg(long)] @@ -1290,7 +1476,24 @@ async fn run_inner(cli: Cli) -> Result<()> { print_completions(*shell); return Ok(()); } - let paths = GethPaths::resolve().context("resolve geth paths")?; + if cli.home.is_some() + && matches!( + &cli.command, + Command::Daemon { + command: DaemonCommand::Run { ephemeral: true } + } + ) + { + bail!( + "--home cannot be combined with --ephemeral; omit --ephemeral for persistent state or omit --home for an automatically managed temporary home" + ); + } + let paths = cli + .home + .clone() + .map(GethPaths::from_home) + .map_or_else(GethPaths::resolve, Ok) + .context("resolve geth paths")?; match cli.command { Command::Guide { topic } => { print_guide(topic, cli.json || cli.jsonl)?; @@ -1318,12 +1521,59 @@ async fn run_inner(cli: Cli) -> Result<()> { println!("node: {}", node.node_id); } Command::Daemon { - command: DaemonCommand::Run, + command: DaemonCommand::Run { ephemeral: true }, + } => { + run_ephemeral_daemon(cli.json || cli.jsonl).await?; + } + Command::Daemon { + command: DaemonCommand::Run { ephemeral: false }, } => { geth_node::run_daemon(paths) .await .context("run geth daemon")?; } + Command::Daemon { + command: DaemonCommand::Install { manager, bin }, + } => { + let report = run_service_command( + &paths, + ServiceCommand::Install { + manager, + bin, + start: true, + }, + ) + .context("install and start geth user service")?; + print_service_report(report, cli.json || cli.jsonl)?; + } + Command::Daemon { + command: DaemonCommand::Start { manager }, + } => { + let report = run_service_command(&paths, ServiceCommand::Start { manager }) + .context("start geth user service; install it first with `geth daemon install`")?; + print_service_report(report, cli.json || cli.jsonl)?; + } + Command::Daemon { + command: DaemonCommand::Stop { manager }, + } => { + let report = run_service_command(&paths, ServiceCommand::Stop { manager }) + .context("stop geth user service")?; + print_service_report(report, cli.json || cli.jsonl)?; + } + Command::Daemon { + command: DaemonCommand::Status { manager }, + } => { + let report = run_service_command(&paths, ServiceCommand::Status { manager }) + .context("query geth user service; install it with `geth daemon install`")?; + print_service_report(report, cli.json || cli.jsonl)?; + } + Command::Daemon { + command: DaemonCommand::Uninstall { manager }, + } => { + let report = run_service_command(&paths, ServiceCommand::Uninstall { manager }) + .context("uninstall geth user service")?; + print_service_report(report, cli.json || cli.jsonl)?; + } Command::Daemon { command: DaemonCommand::Service { command }, } => { @@ -1399,7 +1649,10 @@ async fn run_inner(cli: Cli) -> Result<()> { let response = geth_node::send_control(&paths, request) .await .with_context(|| { - format!("connect to daemon at {}", paths.socket_path().display()) + format!( + "connect to daemon at {}\nnext: start it with `geth daemon install` (background) or `geth daemon run` (foreground)", + paths.socket_path().display() + ) })?; print_response(response, cli.json || cli.jsonl)?; } @@ -1407,6 +1660,46 @@ async fn run_inner(cli: Cli) -> Result<()> { Ok(()) } +async fn run_ephemeral_daemon(json: bool) -> Result<()> { + let (home, paths, node) = create_ephemeral_node()?; + if json { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "type": "ephemeral-daemon-starting", + "home": paths.home(), + "node_id": node.node_id, + "control_command": format!("geth --home {} status", paths.home().display()), + "cleanup": "state is removed after normal daemon shutdown", + }))? + ); + } else { + println!("starting ephemeral geth daemon"); + println!("home: {}", paths.home().display()); + println!("node: {}", node.node_id); + println!("control: geth --home {} status", paths.home().display()); + println!("cleanup: state is removed after normal shutdown (Ctrl-C)"); + } + stdout() + .flush() + .context("flush ephemeral startup details")?; + geth_node::run_daemon(paths) + .await + .context("run ephemeral geth daemon")?; + drop(home); + Ok(()) +} + +fn create_ephemeral_node() -> Result<(tempfile::TempDir, GethPaths, geth_node::LocalNode)> { + let home = tempfile::Builder::new() + .prefix("geth-ephemeral-") + .tempdir() + .context("create ephemeral geth home")?; + let paths = GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).context("initialize ephemeral geth node")?; + Ok((home, paths, node)) +} + fn print_json_error(error: &anyhow::Error) -> Result<()> { let message = error.to_string(); let detail = format!("{error:#}"); @@ -1451,6 +1744,7 @@ fn json_error_hint(detail: &str) -> Option { fn print_guide(topic: Option, json: bool) -> Result<()> { let (name, body) = match topic { None => ("index", GUIDE_INDEX), + Some(GuideTopic::Quickstart) => ("quickstart", GUIDE_QUICKSTART), Some(GuideTopic::Init) => ("init", GUIDE_INIT), Some(GuideTopic::OwnerSetup) => ("owner-setup", GUIDE_OWNER_SETUP), Some(GuideTopic::Enrollment) => ("enrollment", GUIDE_ENROLLMENT), @@ -4210,6 +4504,7 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> { "manager": report.manager.to_string(), "action": format!("{:?}", report.action), "service_name": report.service_name, + "state": report.state, "definition_path": report.definition_path, "definition": report.definition, "commands": report.commands, @@ -4222,6 +4517,9 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> { println!("service: {}", report.service_name); println!("manager: {}", report.manager); println!("action: {:?}", report.action); + if let Some(state) = &report.state { + println!("state: {state}"); + } if let Some(path) = report.definition_path { println!("definition: {}", path.display()); } @@ -4317,6 +4615,62 @@ fn shell_quote_command(command: &[String]) -> String { mod tests { use super::*; + #[test] + fn help_describes_common_workflows_and_command_families() { + let help = Cli::command().render_long_help().to_string(); + assert!(help.contains("geth daemon install")); + assert!(help.contains("geth daemon run --ephemeral")); + assert!(help.contains("Show daemon, storage, Iroh, and backend health")); + assert!(help.contains("Run, install, and manage the daemon")); + assert!(help.contains("--home ")); + } + + #[test] + fn common_daemon_lifecycle_commands_parse_directly() { + for command in ["install", "start", "stop", "status", "uninstall"] { + let parsed = Cli::try_parse_from(["geth", "daemon", command]); + assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}"); + } + let parsed = Cli::try_parse_from(["geth", "daemon", "run", "--ephemeral"]) + .expect("parse ephemeral daemon"); + assert!(matches!( + parsed.command, + Command::Daemon { + command: DaemonCommand::Run { ephemeral: true } + } + )); + } + + #[tokio::test] + async fn ephemeral_daemon_rejects_an_explicit_persistent_home() { + let parsed = Cli::try_parse_from([ + "geth", + "--home", + "/tmp/persistent-geth", + "daemon", + "run", + "--ephemeral", + ]) + .expect("parse command before semantic validation"); + let error = run_inner(parsed) + .await + .expect_err("--home and --ephemeral must conflict"); + let message = error.to_string(); + assert!(message.contains("--home")); + assert!(message.contains("--ephemeral")); + } + + #[test] + fn ephemeral_node_initializes_and_cleans_up_its_temporary_home() { + let (home, paths, node) = create_ephemeral_node().expect("create ephemeral node"); + let path = paths.home().to_path_buf(); + assert!(paths.metadata_db().exists()); + assert!(paths.config_file().exists()); + assert!(node.node_id.starts_with("node:")); + drop(home); + assert!(!path.exists()); + } + #[test] fn json_error_classification_is_stable_for_common_failures() { assert_eq!( diff --git a/crates/geth-node/src/daemon.rs b/crates/geth-node/src/daemon.rs index f3e0580..c812fed 100644 --- a/crates/geth-node/src/daemon.rs +++ b/crates/geth-node/src/daemon.rs @@ -92,7 +92,7 @@ async fn serve_local_control(node: LocalNode, listener: UnixListener) -> Result< } }); } - signal = tokio::signal::ctrl_c() => { + signal = shutdown_signal() => { signal?; tracing::info!("shutdown signal received"); 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( node: LocalNode, endpoint: GethIrohEndpoint, diff --git a/crates/geth-node/src/service.rs b/crates/geth-node/src/service.rs index 6343c37..d30dd70 100644 --- a/crates/geth-node/src/service.rs +++ b/crates/geth-node/src/service.rs @@ -73,6 +73,7 @@ pub struct ServiceReport { pub manager: ServiceManager, pub action: ServiceAction, pub service_name: String, + pub state: Option, pub definition_path: Option, pub definition: Option, pub commands: Vec>, @@ -223,39 +224,37 @@ pub fn status_user_service(manager: ServiceManager) -> Result { - let command = run_command( + let (command, code, detail) = inspect_command( "systemctl", &["--user", "status", SYSTEMD_UNIT, "--no-pager"], )?; - Ok(report( + Ok(status_report( manager, - ServiceAction::Status, Some(systemd_unit_path()?), - None, - vec![command], - "queried systemd user service", + command, + systemd_state(code), + &detail, )) } ServiceManager::LaunchdUser => { - let command = run_command("launchctl", &["list", LAUNCHD_LABEL])?; - Ok(report( + let (command, code, detail) = inspect_command("launchctl", &["list", LAUNCHD_LABEL])?; + Ok(status_report( manager, - ServiceAction::Status, Some(launchd_plist_path()?), - None, - vec![command], - "queried launchd user agent", + command, + launchd_state(code, &detail), + &detail, )) } ServiceManager::WindowsTask => { - let command = run_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?; - Ok(report( + let (command, code, detail) = + inspect_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?; + Ok(status_report( manager, - ServiceAction::Status, None, - None, - vec![command], - "queried Windows per-user scheduled task", + command, + windows_task_state(code, &detail), + &detail, )) } ServiceManager::Auto => unreachable!("auto is resolved above"), @@ -358,12 +357,12 @@ fn install_launchd_user( } let definition = launchd_plist(paths, executable); std::fs::write(&plist_path, &definition)?; - let mut commands = vec![run_command( - "launchctl", - &["load", "-w", &plist_path.display().to_string()], - )?]; + let mut commands = Vec::new(); if start { - commands.push(run_command("launchctl", &["start", LAUNCHD_LABEL])?); + commands.push(run_command( + "launchctl", + &["load", "-w", &plist_path.display().to_string()], + )?); } Ok(report( ServiceManager::LaunchdUser, @@ -379,10 +378,9 @@ fn uninstall_launchd_user() -> Result { let plist_path = launchd_plist_path()?; let mut commands = Vec::new(); if plist_path.exists() { - commands.push(run_command( - "launchctl", - &["unload", &plist_path.display().to_string()], - )?); + let (command, _, _) = + inspect_command("launchctl", &["unload", &plist_path.display().to_string()])?; + commands.push(command); std::fs::remove_file(&plist_path)?; } Ok(report( @@ -546,6 +544,83 @@ fn run_command(program: &str, args: &[&str]) -> Result, ServiceError } } +fn inspect_command( + program: &str, + args: &[&str], +) -> Result<(Vec, Option, 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::>(); + 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) -> &'static str { + match code { + Some(0) => "running", + Some(3) => "not-running", + Some(4) => "not-installed", + _ => "unknown", + } +} + +fn launchd_state(code: Option, 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, 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, + command: Vec, + 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], + ¬e, + ); + report.state = Some(state.to_owned()); + report +} + fn report( manager: ServiceManager, action: ServiceAction, @@ -563,6 +638,7 @@ fn report( }, manager, action, + state: None, definition_path, definition, commands, @@ -619,4 +695,35 @@ mod tests { assert!(command.contains("GETH_HOME=")); 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" + ); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index cc965c3..7897404 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -325,6 +325,41 @@ fn cli_help_documents_owner_init_keys() { 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] fn guide_command_explains_key_roles() { let home = tempfile::tempdir().expect("tempdir"); @@ -535,6 +570,12 @@ fn json_errors_include_stable_code_for_common_failures() { .expect("error message") .contains("connect to daemon") ); + assert!( + error["hint"] + .as_str() + .expect("daemon recovery hint") + .contains("geth daemon install") + ); } #[test] diff --git a/docs/adr/0015-user-service-installation.md b/docs/adr/0015-user-service-installation.md index 67e8c81..edf274c 100644 --- a/docs/adr/0015-user-service-installation.md +++ b/docs/adr/0015-user-service-installation.md @@ -6,8 +6,15 @@ Accepted. ## Decision -Geth provides `geth daemon service ...` commands to install, uninstall, start, -stop, inspect, and print daemon service definitions. +Geth provides direct `geth daemon install|start|stop|status|uninstall` commands +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: diff --git a/docs/architecture.md b/docs/architecture.md index 11ef05f..40ad1af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 cleanup path runs when the serving loop returns an error, preventing stale 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 `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 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 Remote geth node-to-node communication is Iroh-only. The daemon will own one diff --git a/docs/automation-examples.md b/docs/automation-examples.md index c17fe90..2bd1b7f 100644 --- a/docs/automation-examples.md +++ b/docs/automation-examples.md @@ -18,12 +18,11 @@ export GETH_HOME="${GETH_HOME:-$HOME/.local/share/geth/geth}" geth init geth daemon run >"$GETH_HOME/daemon.log" 2>&1 & daemon_pid=$! +trap 'kill "$daemon_pid" 2>/dev/null || true' EXIT geth wait daemon --timeout-ms 30000 geth doctor --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 @@ -106,13 +105,17 @@ manager mutation: ```sh geth daemon service print -geth daemon service install --start +geth daemon install geth wait daemon --timeout-ms 30000 -geth daemon service status -geth daemon service stop -geth daemon service uninstall +geth daemon status +geth daemon stop +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 ...`; normal Ctrl-C shutdown removes the state. + 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 the explicit signing command when an admin operation is intended: diff --git a/docs/command-stability.md b/docs/command-stability.md index 6e0095b..f4f9f78 100644 --- a/docs/command-stability.md +++ b/docs/command-stability.md @@ -20,7 +20,9 @@ to stable commands after the first deployment tag. The following command families are intended to be stable automation surfaces: - `geth init` -- `geth daemon run` +- `geth --home ...` +- `geth daemon run [--ephemeral]` +- `geth daemon install|start|stop|status|uninstall` - `geth daemon service install|uninstall|start|stop|status|print` - `geth status` - `geth doctor` diff --git a/docs/dogfood-checklist.md b/docs/dogfood-checklist.md index df3bc90..ecf26b8 100644 --- a/docs/dogfood-checklist.md +++ b/docs/dogfood-checklist.md @@ -49,8 +49,9 @@ On Machine A: ```sh 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 daemon status geth status --json geth doctor --json geth peer export --out /tmp/owner.peer.json @@ -191,8 +192,8 @@ On Machine A: ```sh geth backup create --out /tmp/geth-backup -geth daemon service stop -geth daemon service start +geth daemon stop +geth daemon start geth wait daemon --timeout-ms 30000 geth doctor --json ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 952c76b..2531c4a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,6 +15,53 @@ Status markers: For deployment-readiness work that cuts across feature areas, see [`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 Goal: evolve geth into a distributed, fault-tolerant homelab overlay runtime in diff --git a/docs/user-workflows.md b/docs/user-workflows.md new file mode 100644 index 0000000..9874168 --- /dev/null +++ b/docs/user-workflows.md @@ -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 status +geth --home 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 ...` 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 --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 +geth cas providers +geth cas get --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 `. 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`.