diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 233052a..780d8d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,12 @@ name: Release dig-node # still published (a copy of the same bytes) so apt.dig.net + the installer fallback # keep resolving byte-exact across the rename without editing the consumer repos. # +# `dign` is a FIRST-CLASS alias binary for `dig-node` (issue #548, mirroring how `digs` +# aliases `digstore` #434): a SEPARATE `[[bin]]` target sharing the SAME entrypoint, so +# `dign ` behaves byte-for-byte like `dig-node `. It is built + published +# ALONGSIDE `dig-node` under its own stem `dign---[.exe]` (identical shape +# to `dig-node-*`), which the dig-installer resolves via `Repo::dign()` (a follow-up). +# # Per OS/arch (each emitted under both stems above): # * windows-x64 (x86_64-pc-windows-msvc, windows-latest) # * linux-x64 (x86_64-unknown-linux-gnu, ubuntu-latest) @@ -166,8 +172,10 @@ jobs: target key: ${{ runner.os }}-companion-build-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-companion-build-${{ matrix.target }}- - - name: Build release binary - run: cargo build --release --locked --target ${{ matrix.target }} --bin dig-node + - name: Build release binaries (dig-node + dign alias) + # Build BOTH bins — `dign` is the first-class alias (issue #548, mirroring digs + # #434), published alongside `dig-node` under its own asset stem below. + run: cargo build --release --locked --target ${{ matrix.target }} --bin dig-node --bin dign - name: Stage artifact (dual-named) id: stage shell: bash @@ -183,6 +191,15 @@ jobs: # template + the installer's pre-rename fallback). See the header comment. cp "$SRC" "dist/dig-node-${VER}-${{ matrix.out_name }}" cp "$SRC" "dist/dig-companion-${VER}-${{ matrix.out_name }}" + # The `dign` first-class alias (issue #548): a SEPARATE binary that behaves + # byte-for-byte like `dig-node`, published under its own `dign-*` stem (same + # shape as `dig-node-*`) so the dig-installer resolves it via Repo::dign(). Its + # filename differs from `dig-node` only by the `dig-node`->`dign` substring + # (incl. the `.exe` suffix on Windows). + DIGN_BIN="${{ matrix.bin }}"; DIGN_BIN="${DIGN_BIN/dig-node/dign}" + DIGN_SRC="target/${{ matrix.target }}/release/${DIGN_BIN}" + test -f "$DIGN_SRC" || { echo "dign binary not produced: $DIGN_SRC"; exit 1; } + cp "$DIGN_SRC" "dist/dign-${VER}-${{ matrix.out_name }}" echo "ver=$VER" >> "$GITHUB_OUTPUT" ls -la dist - name: Upload build artifact diff --git a/Cargo.lock b/Cargo.lock index 9e73e9e..f5f5ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1966,7 +1966,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.30.0" +version = "0.31.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 3d10877..9e88298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.30.0" +version = "0.31.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/README.md b/README.md index 2c4fc66..1a184c9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ shipped as a **self-contained, cross-platform Rust binary** that installs as an > configuration contract (see the Environment section). For the legacy Linux package + the > installer's pre-rename fallback, the GitHub release also publishes each binary under the old > `dig-companion-*` filename (identical bytes). See [`USER_JOURNEY.md`](USER_JOURNEY.md). +> +> **`dign` alias.** A shorter first-class alias binary, `dign`, ships alongside `dig-node` (mirroring +> `digs`↔`digstore`): `dign ` behaves identically to `dig-node ` — same subcommands, +> flags, `--json`, and exit codes — and each binary reports its own name in `--help`/`--version`. The +> release publishes it as `dign---[.exe]` beside the `dig-node-*` asset. The extension resolves `chia://` (DIG) URLs by fetching encrypted, Merkle-proven content over a DIG RPC and then **verifying + decrypting it in the extension**. By default it talks to `rpc.dig.net`; @@ -133,6 +138,8 @@ ways at once: dig-node run # serve on 127.0.0.1:9778 until Ctrl-C # or simply: dig-node # bare invocation == run +# the `dign` alias is interchangeable everywhere: +dign run # identical to `dig-node run` ``` ## Configuration diff --git a/SPEC.md b/SPEC.md index cfd60d2..c5f3332 100644 --- a/SPEC.md +++ b/SPEC.md @@ -105,7 +105,18 @@ the OS service, and every machine-readable service-identity surface are named `d ENGINE library crate is `dig-node-core` (lib `dig_node_core`) — a distinct name from the `dig-node` binary so the two are never confused (#216). Every machine-readable surface (`/health.service`, `/version.service`, the CLI `--json` envelopes' `service` field) MUST report the service identity -string `"dig-node"` (`meta::SERVICE_NAME`). +string `"dig-node"` (`meta::SERVICE_NAME`) — the alias below renames only the invoked binary, never +the service identity. + +2.1a. **`dign` first-class alias (issue #548).** The service shell also produces a second binary, +`dign`, a FIRST-CLASS alias for `dig-node` (mirroring how `digs` aliases `digstore`, #434). It is a +real installed binary — not a shell alias — defined as a second `[[bin]]` target +(`crates/dig-node-service/src/bin/dign.rs`) that shares the SINGLE entrypoint +`dig_node_service::run()` with `dig-node`, so there is NO duplicated logic. `dign ` MUST behave +IDENTICALLY to `dig-node `: the same subcommands, flags, `--json` envelopes, and exit codes. +The displayed program name is derived from arg0, so `dign --help`/`--version` report `dign` while +`dig-node --help`/`--version` report `dig-node`. A release publishes `dign` alongside the primary +under the stem `dign---[.exe]` (byte-identical shape to `dig-node---`). 2.2. **Node library version.** The node is the first-party `dig-node-core` engine library crate in this workspace. The constant `meta::DIG_NODE_VERSION` MUST equal the node library's crate version @@ -1152,6 +1163,10 @@ passes through unchanged, with no shape to keep in sync on this side. `run-service` (hidden; the Windows SCM entrypoint, §9.4; behaves as `run` off Windows) · `install` · `uninstall` · `start` · `stop` · `status` · `pair` (§7.11) · `open` (§8.5). +The `dign` alias binary (§2.1a) exposes this SAME subcommand set with the SAME semantics — `dign +` is equivalent to `dig-node ` in every respect except the reported program +name. + ### 8.5. `open` — the OS scheme handler (#389) `dig-node open ` is the target the installer registers for the OS `chia://` and diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 6a30f36..80d984d 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -18,6 +18,15 @@ default-run = "dig-node" name = "dig-node" path = "src/main.rs" +# `dign` — a FIRST-CLASS alias binary for `dig-node` (issue #548), mirroring how `digs` +# aliases `digstore` (#434). It shares the SINGLE entrypoint `dig_node_service::run()` +# (see src/bin/dign.rs) so there is no duplicated logic; a build produces BOTH `dig-node` +# and `dign`, and each reflects its own invoked name in --help/--version. The release +# workflow publishes it alongside the primary as `dign--[.exe]`. +[[bin]] +name = "dign" +path = "src/bin/dign.rs" + # A tiny stand-in for the REAL `dig-updater` beacon CLI (a separate repo/release, not # vendored here), so the `updater.*` control-plane tests (#515) exercise a genuine child- # process spawn — argv, stdout/exit-code parsing — without depending on that binary being diff --git a/crates/dig-node-service/src/bin/dign.rs b/crates/dig-node-service/src/bin/dign.rs new file mode 100644 index 0000000..5e9e720 --- /dev/null +++ b/crates/dig-node-service/src/bin/dign.rs @@ -0,0 +1,11 @@ +//! `dign` — a FIRST-CLASS alias binary for the `dig-node` service CLI (issue #548). +//! +//! `dign ` behaves IDENTICALLY to `dig-node `: same subcommands, flags, +//! `--json`, exit codes, and help. It is a real installed binary (not a shell alias) +//! that shares the SINGLE entrypoint [`dig_node_service::run`] with `dig-node` — there is +//! no duplicated logic. clap derives the displayed program name from arg0, so +//! `dign --help`/`--version` all read `dign`. + +fn main() -> std::process::ExitCode { + dig_node_service::run() +} diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs new file mode 100644 index 0000000..626efd1 --- /dev/null +++ b/crates/dig-node-service/src/entrypoint.rs @@ -0,0 +1,337 @@ +//! The shared CLI entrypoint for BOTH the `dig-node` and `dign` binaries (issue #548). +//! +//! `dign` is a FIRST-CLASS alias for `dig-node`: `dign ` behaves identically to +//! `dig-node ` (same subcommands, flags, `--json`, exit codes). Both binaries are +//! thin shims (`src/main.rs`, `src/bin/dign.rs`) over the ONE [`run`] entrypoint here, +//! so there is NO duplicated logic — and each reflects its OWN invoked name (arg0) in +//! `--help`/`--version`, making the alias a real installed binary, not a shell alias. +//! +//! Subcommands: +//! run Run the node in the foreground (the service entrypoint too). +//! install Register the node as an auto-starting OS service. +//! uninstall Remove the OS service. +//! start Start the installed service. +//! stop Stop the running service. +//! status Report whether the node is serving (probes /health). +//! +//! With no subcommand, the binary runs in the foreground (equivalent to `run`), so a +//! bare invocation just serves — the least-surprise default for a localhost endpoint. +//! +//! ## Machine-readable output (`--json`) +//! +//! Every subcommand accepts the global `--json` flag: on success it emits ONE structured +//! object to **stdout** (`{ ok:true, action, ... }`) and routes human prose to +//! **stderr**; on failure it emits `{ ok:false, error:{ code, exit_code, message, hint } }` +//! to stdout and still exits with the differentiated code. The exit-code table is +//! documented in [`crate::cli`] and the README. + +use std::ffi::OsStr; +use std::path::Path; + +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; + +use crate::cli::{error_envelope, success_envelope, ExitCode, Outcome}; +use crate::config::Config; +use crate::open; +use crate::pair::{self, PairAction}; +use crate::{serve, service, VERSION}; + +#[derive(Parser)] +#[command( + // A default only: [`run`] overrides both the displayed name and the usage `bin_name` + // with the ACTUAL invoked binary (arg0), so `dign` reports `dign` and `dig-node` + // reports `dig-node`. This literal is the fallback when arg0 is somehow absent. + name = "dig-node", + version = VERSION, + about = "Local DIG node for the DIG Chrome extension (installable as an OS service)", + long_about = None, +)] +struct Cli { + /// Emit a single machine-readable JSON object to stdout (human prose → stderr). + /// Errors are emitted as `{ok:false,error:{code,exit_code,message,hint}}`. + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Run the node in the foreground (also the unix-service entrypoint). + Run, + /// Internal: the Windows-service entrypoint (speaks the SCM service protocol). + /// Installed by `install` on Windows; not meant to be run by hand. On non-Windows + /// it behaves like `run`. + #[command(hide = true)] + RunService, + /// Register the node as an auto-starting OS service. + Install, + /// Remove the OS service. + Uninstall, + /// Start the installed service. + Start, + /// Stop the running service. + Stop, + /// Report whether the node is serving (probes /health). + Status, + /// Pair a browser controller (the DIG Chrome extension) with this node (#280): + /// grant it a scoped, revocable control token after local confirmation. + Pair { + #[command(subcommand)] + action: Option, + }, + /// Open a DIG link in the default browser (#389). The OS scheme-handler target the + /// installer registers for `chia://` + `urn:dig:chia:`. Accepts ONLY those two schemes, + /// resolves via the local node's serve URL, and never invokes a shell. + Open { + /// The DIG link (`chia://[:]/` or `urn:dig:chia:<…>`). + link: String, + }, + /// Internal: idempotently register the `dig.local` → `127.0.0.2` OS hosts entry (#91/#503), + /// so `http://dig.local` resolves to the node. Invoked by the native install packages; + /// requires write access to the hosts file (run elevated). Not meant to be run by hand. + #[command(hide = true)] + EnsureHosts, +} + +/// `dig-node pair` sub-actions. With none, lists pending requests + issued tokens. +#[derive(Subcommand)] +enum PairCommand { + /// List pending pairing requests (with codes) + issued controller tokens. + List, + /// Approve a pending pairing by id (mints a scoped controller token). + Approve { + /// The pairing_id from `dig-node pair` / the extension. + pairing_id: String, + }, + /// Revoke an issued controller token by id. + Revoke { + /// The token id from `dig-node pair`. + token_id: String, + }, +} + +impl Command { + /// The action name used in the `--json` envelope. + fn action(&self) -> &'static str { + match self { + Command::Run | Command::RunService => "run", + Command::Install => "install", + Command::Uninstall => "uninstall", + Command::Start => "start", + Command::Stop => "stop", + Command::Status => "status", + Command::Pair { .. } => "pair", + Command::Open { .. } => "open", + Command::EnsureHosts => "ensure-hosts", + } + } +} + +/// The file-stem of the binary as it was invoked (arg0), e.g. `dig-node` or `dign` (the +/// issue-#548 alias). This is the program name the CLI reports in `--help`/`--version`, +/// so each binary shows its OWN name rather than a hardcoded `"dig-node"`. Falls back to +/// `"dig-node"` when arg0 is absent/empty. +fn invoked_bin_name() -> String { + bin_name_from_arg0(std::env::args_os().next().as_deref()) +} + +/// Pure core of [`invoked_bin_name`]: the file-stem of an arg0 path, with the extension +/// (`.exe`) and directory prefix stripped, falling back to `"dig-node"` for an +/// absent/empty arg0. Extracted so the naming rule is unit-testable without touching the +/// process-global argv. +fn bin_name_from_arg0(arg0: Option<&OsStr>) -> String { + arg0.map(Path::new) + .and_then(Path::file_stem) + .map(|s| s.to_string_lossy().into_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "dig-node".to_string()) +} + +/// The shared CLI entrypoint for BOTH the `dig-node` and `dign` binaries (issue #548). +/// Kept here in the library — not duplicated in each `src/bin` shim — so the two binaries +/// are the same command surface with ONE codepath. +/// +/// Parses argv with the ACTUAL invoked binary name ([`invoked_bin_name`]) as both the +/// displayed program name and the usage `bin_name`, so `dign --help` shows `dign` and +/// `dig-node --help` shows `dig-node`. +pub fn run() -> std::process::ExitCode { + // Parse with the invoked binary's name as the program + bin name, so the alias + // (`dign`) is first-class: its help/usage/version/errors all read `dign`, never a + // hardcoded `dig-node`, and never the raw arg0 (which may be an absolute path). + // + // `Command::name` requires `Into`, which this clap only satisfies for a + // `&'static str`; the invoked name is computed at runtime, so we leak the tiny stem + // to obtain a `'static` reference. This is a single, process-lifetime allocation on + // the entrypoint of a short-lived CLI — never in a loop — so it is not a meaningful + // leak. (`bin_name` takes `Into`, so it takes the owned value directly.) + let bin = invoked_bin_name(); + let bin_static: &'static str = Box::leak(bin.clone().into_boxed_str()); + let matches = Cli::command().name(bin_static).bin_name(bin).get_matches(); + let cli = match Cli::from_arg_matches(&matches) { + Ok(c) => c, + Err(e) => e.exit(), + }; + + let json = cli.json; + let config = Config::from_env(); + let command = cli.command.unwrap_or(Command::Run); + let action = command.action(); + + // `run` / `run-service` serve indefinitely — they have no terminal Outcome. + // Everything else returns an Outcome we render as JSON or prose. + let exit = match command { + Command::Run => render_serve(block_on_serve(config), action, json), + Command::RunService => render_serve(run_service(config), action, json), + Command::Install => render(service::install(&config), action, json), + Command::Uninstall => render(service::uninstall(), action, json), + Command::Start => render(service::start(), action, json), + Command::Stop => render(service::stop(), action, json), + Command::Status => render_status(service::status(&config), action, json), + Command::Pair { action: pair_cmd } => { + let pair_action = match pair_cmd { + None | Some(PairCommand::List) => PairAction::List, + Some(PairCommand::Approve { pairing_id }) => PairAction::Approve { pairing_id }, + Some(PairCommand::Revoke { token_id }) => PairAction::Revoke { token_id }, + }; + render(pair::run(&config, pair_action), action, json) + } + Command::Open { link } => render(open::run(&config, &link), action, json), + Command::EnsureHosts => render(crate::hosts::run(), action, json), + }; + std::process::ExitCode::from(exit.code()) +} + +/// Render a one-shot subcommand outcome: under `--json` emit the success/error envelope +/// to stdout; otherwise print the human summary (success → stdout, errors → stderr). +/// Returns the exit code. +fn render(result: std::io::Result, action: &str, json: bool) -> ExitCode { + match result { + Ok(outcome) => { + if json { + println!("{}", success_envelope(action, outcome.result)); + } else { + println!("{}", outcome.summary); + } + ExitCode::Ok + } + Err(e) => emit_error(&e, action, json), + } +} + +/// Render `status`: success either way, but `serving:false` maps to exit 1 +/// (`NOT_SERVING`) so scripts can gate on liveness. +fn render_status(result: std::io::Result, action: &str, json: bool) -> ExitCode { + match result { + Ok(outcome) => { + let serving = outcome.result["serving"].as_bool().unwrap_or(false); + if json { + println!("{}", success_envelope(action, outcome.result)); + } else { + println!("{}", outcome.summary); + } + if serving { + ExitCode::Ok + } else { + ExitCode::NotServing + } + } + Err(e) => emit_error(&e, action, json), + } +} + +/// Render the `run`/`run-service` path. These block until shutdown; a clean exit is +/// success, a bind/IO error is the typed failure. (No success object is printed — the +/// process simply runs; the startup log goes to stderr from `serve`.) +fn render_serve(result: std::io::Result<()>, action: &str, json: bool) -> ExitCode { + match result { + Ok(()) => ExitCode::Ok, + Err(e) => emit_error(&e, action, json), + } +} + +/// Emit a failure: under `--json` the structured error envelope to stdout, else the +/// `error: …` line to stderr. Maps the io::Error to the differentiated exit code. +fn emit_error(e: &std::io::Error, action: &str, json: bool) -> ExitCode { + let exit = ExitCode::from_io_error(e); + let message = e.to_string(); + let hint = hint_for(exit); + if json { + println!("{}", error_envelope(action, exit, &message, hint)); + } else { + eprintln!("error: {message}"); + if let Some(h) = hint { + eprintln!("hint: {h}"); + } + } + exit +} + +/// A remediation hint for an exit class (shown to humans, carried in the JSON error +/// envelope's `hint`). +fn hint_for(exit: ExitCode) -> Option<&'static str> { + match exit { + ExitCode::PermissionDenied => { + Some("Re-run in a terminal opened with \"Run as administrator\" (Windows).") + } + ExitCode::BindFailed => { + Some("The port is in use or unavailable; set DIG_NODE_PORT to a free port.") + } + _ => None, + } +} + +/// Build the multi-threaded tokio runtime and serve. Kept here (not in [`crate::server`]) +/// so the lib's `serve` stays a plain async fn callers can drive on their own runtime. +fn block_on_serve(config: Config) -> std::io::Result<()> { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(serve(config)) +} + +/// The Windows-service entrypoint: hand control to the SCM dispatcher (it builds its own +/// runtime around the serve loop and reports Running/Stopped). On non-Windows there is no +/// SCM, so this just runs in the foreground like `run`. +#[cfg(windows)] +fn run_service(_config: Config) -> std::io::Result<()> { + crate::win_service::run() +} +#[cfg(not(windows))] +fn run_service(config: Config) -> std::io::Result<()> { + block_on_serve(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bin_name_prefers_arg0_file_stem() { + // A full path resolves to the bare stem; the `.exe` suffix is stripped. + assert_eq!( + bin_name_from_arg0(Some(OsStr::new("/usr/bin/dign"))), + "dign" + ); + assert_eq!(bin_name_from_arg0(Some(OsStr::new("dign.exe"))), "dign"); + assert_eq!( + bin_name_from_arg0(Some(OsStr::new("/opt/dig/dig-node"))), + "dig-node" + ); + // A bare name with no extension is returned as-is. + assert_eq!(bin_name_from_arg0(Some(OsStr::new("dig-node"))), "dig-node"); + } + + #[test] + fn bin_name_falls_back_to_dig_node_when_absent_or_empty() { + assert_eq!(bin_name_from_arg0(None), "dig-node"); + assert_eq!(bin_name_from_arg0(Some(OsStr::new(""))), "dig-node"); + } + + #[test] + fn cli_definition_is_valid() { + // clap's derived command builds without a malformed-definition panic. + Cli::command().debug_assert(); + } +} diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 9b6cac1..2e276dd 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -37,6 +37,10 @@ pub mod config; /// served-store CSP. The wiring lives in [`server`]. pub mod content; pub mod control; +/// The shared CLI entrypoint ([`run`]) for BOTH the `dig-node` binary and its first-class +/// `dign` alias (issue #548). Both `src/main.rs` and `src/bin/dign.rs` are thin shims over +/// it, so the two binaries share ONE codepath and each reports its own invoked name. +pub mod entrypoint; /// `dig-node ensure-hosts` (#91/#503): idempotently register the `dig.local` → `127.0.0.2` OS /// hosts entry so `http://dig.local` resolves to the node. Invoked by the native install packages. pub mod hosts; @@ -67,5 +71,6 @@ pub mod win_service; pub use cli::{ExitCode, Outcome}; pub use config::Config; +pub use entrypoint::run; pub use meta::ErrorCode; pub use server::{serve, VERSION}; diff --git a/crates/dig-node-service/src/main.rs b/crates/dig-node-service/src/main.rs index 6207c43..cd56d39 100644 --- a/crates/dig-node-service/src/main.rs +++ b/crates/dig-node-service/src/main.rs @@ -1,251 +1,8 @@ -//! dig-node CLI — the entrypoint for both manual runs and the OS service. -//! (The binary, crate, and service all carry the canonical `dig-node` name; this is -//! the local **dig-node** service the DIG Chrome extension points `server.host` at.) -//! -//! Subcommands: -//! run Run the node in the foreground (the service entrypoint too). -//! install Register the node as an auto-starting OS service. -//! uninstall Remove the OS service. -//! start Start the installed service. -//! stop Stop the running service. -//! status Report whether the node is serving (probes /health). -//! -//! With no subcommand, the binary runs in the foreground (equivalent to `run`), so -//! a bare invocation just serves — the least-surprise default for a localhost -//! endpoint. -//! -//! ## Machine-readable output (`--json`) -//! -//! Every subcommand accepts the global `--json` flag: on success it emits ONE -//! structured object to **stdout** (`{ ok:true, action, ... }`) and routes human -//! prose to **stderr**; on failure it emits `{ ok:false, error:{ code, exit_code, -//! message, hint } }` to stdout and still exits with the differentiated code. The -//! exit-code table is documented in [`dig_node_service::cli`] and the README. - -use clap::{Parser, Subcommand}; -use dig_node_service::cli::{error_envelope, success_envelope, ExitCode, Outcome}; -use dig_node_service::config::Config; -use dig_node_service::open; -use dig_node_service::pair::{self, PairAction}; -use dig_node_service::{serve, service, VERSION}; - -#[derive(Parser)] -#[command( - name = "dig-node", - version = VERSION, - about = "Local DIG node for the DIG Chrome extension (installable as an OS service)", - long_about = None, -)] -struct Cli { - /// Emit a single machine-readable JSON object to stdout (human prose → stderr). - /// Errors are emitted as `{ok:false,error:{code,exit_code,message,hint}}`. - #[arg(long, global = true)] - json: bool, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Command { - /// Run the node in the foreground (also the unix-service entrypoint). - Run, - /// Internal: the Windows-service entrypoint (speaks the SCM service protocol). - /// Installed by `install` on Windows; not meant to be run by hand. On non-Windows - /// it behaves like `run`. - #[command(hide = true)] - RunService, - /// Register the node as an auto-starting OS service. - Install, - /// Remove the OS service. - Uninstall, - /// Start the installed service. - Start, - /// Stop the running service. - Stop, - /// Report whether the node is serving (probes /health). - Status, - /// Pair a browser controller (the DIG Chrome extension) with this node (#280): - /// grant it a scoped, revocable control token after local confirmation. - Pair { - #[command(subcommand)] - action: Option, - }, - /// Open a DIG link in the default browser (#389). The OS scheme-handler target the - /// installer registers for `chia://` + `urn:dig:chia:`. Accepts ONLY those two schemes, - /// resolves via the local node's serve URL, and never invokes a shell. - Open { - /// The DIG link (`chia://[:]/` or `urn:dig:chia:<…>`). - link: String, - }, - /// Internal: idempotently register the `dig.local` → `127.0.0.2` OS hosts entry (#91/#503), - /// so `http://dig.local` resolves to the node. Invoked by the native install packages; - /// requires write access to the hosts file (run elevated). Not meant to be run by hand. - #[command(hide = true)] - EnsureHosts, -} - -/// `dig-node pair` sub-actions. With none, lists pending requests + issued tokens. -#[derive(Subcommand)] -enum PairCommand { - /// List pending pairing requests (with codes) + issued controller tokens. - List, - /// Approve a pending pairing by id (mints a scoped controller token). - Approve { - /// The pairing_id from `dig-node pair` / the extension. - pairing_id: String, - }, - /// Revoke an issued controller token by id. - Revoke { - /// The token id from `dig-node pair`. - token_id: String, - }, -} - -impl Command { - /// The action name used in the `--json` envelope. - fn action(&self) -> &'static str { - match self { - Command::Run | Command::RunService => "run", - Command::Install => "install", - Command::Uninstall => "uninstall", - Command::Start => "start", - Command::Stop => "stop", - Command::Status => "status", - Command::Pair { .. } => "pair", - Command::Open { .. } => "open", - Command::EnsureHosts => "ensure-hosts", - } - } -} +//! The `dig-node` binary — a thin shim over the shared entrypoint +//! [`dig_node_service::run`]. The `dign` alias binary (`src/bin/dign.rs`, issue #548) +//! shares this exact codepath, so the two binaries are identical modulo the invoked +//! program name (which clap derives from arg0). fn main() -> std::process::ExitCode { - let cli = Cli::parse(); - let json = cli.json; - let config = Config::from_env(); - let command = cli.command.unwrap_or(Command::Run); - let action = command.action(); - - // `run` / `run-service` serve indefinitely — they have no terminal Outcome. - // Everything else returns an Outcome we render as JSON or prose. - let exit = match command { - Command::Run => render_serve(run(config), action, json), - Command::RunService => render_serve(run_service(config), action, json), - Command::Install => render(service::install(&config), action, json), - Command::Uninstall => render(service::uninstall(), action, json), - Command::Start => render(service::start(), action, json), - Command::Stop => render(service::stop(), action, json), - Command::Status => render_status(service::status(&config), action, json), - Command::Pair { action: pair_cmd } => { - let pair_action = match pair_cmd { - None | Some(PairCommand::List) => PairAction::List, - Some(PairCommand::Approve { pairing_id }) => PairAction::Approve { pairing_id }, - Some(PairCommand::Revoke { token_id }) => PairAction::Revoke { token_id }, - }; - render(pair::run(&config, pair_action), action, json) - } - Command::Open { link } => render(open::run(&config, &link), action, json), - Command::EnsureHosts => render(dig_node_service::hosts::run(), action, json), - }; - std::process::ExitCode::from(exit.code()) -} - -/// Render a one-shot subcommand outcome: under `--json` emit the success/error -/// envelope to stdout; otherwise print the human summary (success → stdout, errors -/// → stderr). Returns the exit code. -fn render(result: std::io::Result, action: &str, json: bool) -> ExitCode { - match result { - Ok(outcome) => { - if json { - println!("{}", success_envelope(action, outcome.result)); - } else { - println!("{}", outcome.summary); - } - ExitCode::Ok - } - Err(e) => emit_error(&e, action, json), - } -} - -/// Render `status`: success either way, but `serving:false` maps to exit 1 -/// (`NOT_SERVING`) so scripts can gate on liveness. -fn render_status(result: std::io::Result, action: &str, json: bool) -> ExitCode { - match result { - Ok(outcome) => { - let serving = outcome.result["serving"].as_bool().unwrap_or(false); - if json { - println!("{}", success_envelope(action, outcome.result)); - } else { - println!("{}", outcome.summary); - } - if serving { - ExitCode::Ok - } else { - ExitCode::NotServing - } - } - Err(e) => emit_error(&e, action, json), - } -} - -/// Render the `run`/`run-service` path. These block until shutdown; a clean exit -/// is success, a bind/IO error is the typed failure. (No success object is printed -/// — the process simply runs; the startup log goes to stderr from `serve`.) -fn render_serve(result: std::io::Result<()>, action: &str, json: bool) -> ExitCode { - match result { - Ok(()) => ExitCode::Ok, - Err(e) => emit_error(&e, action, json), - } -} - -/// Emit a failure: under `--json` the structured error envelope to stdout, else the -/// `error: …` line to stderr. Maps the io::Error to the differentiated exit code. -fn emit_error(e: &std::io::Error, action: &str, json: bool) -> ExitCode { - let exit = ExitCode::from_io_error(e); - let message = e.to_string(); - let hint = hint_for(exit); - if json { - println!("{}", error_envelope(action, exit, &message, hint)); - } else { - eprintln!("error: {message}"); - if let Some(h) = hint { - eprintln!("hint: {h}"); - } - } - exit -} - -/// A remediation hint for an exit class (shown to humans, carried in the JSON -/// error envelope's `hint`). -fn hint_for(exit: ExitCode) -> Option<&'static str> { - match exit { - ExitCode::PermissionDenied => { - Some("Re-run in a terminal opened with \"Run as administrator\" (Windows).") - } - ExitCode::BindFailed => { - Some("The port is in use or unavailable; set DIG_NODE_PORT to a free port.") - } - _ => None, - } -} - -/// Build the multi-threaded tokio runtime and serve. Kept here (not in the lib) so -/// the lib's `serve` stays a plain async fn callers can drive on their own runtime. -fn run(config: Config) -> std::io::Result<()> { - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - rt.block_on(serve(config)) -} - -/// The Windows-service entrypoint: hand control to the SCM dispatcher (it builds -/// its own runtime around the serve loop and reports Running/Stopped). On -/// non-Windows there is no SCM, so this just runs in the foreground like `run`. -#[cfg(windows)] -fn run_service(_config: Config) -> std::io::Result<()> { - dig_node_service::win_service::run() -} -#[cfg(not(windows))] -fn run_service(config: Config) -> std::io::Result<()> { - run(config) + dig_node_service::run() } diff --git a/crates/dig-node-service/tests/cli_dign_alias.rs b/crates/dig-node-service/tests/cli_dign_alias.rs new file mode 100644 index 0000000..36f02ce --- /dev/null +++ b/crates/dig-node-service/tests/cli_dign_alias.rs @@ -0,0 +1,107 @@ +//! `dign` is a FIRST-CLASS alias binary for `dig-node` (issue #548): the two bins share +//! ONE codepath (`dig_node_service::run()`), expose the SAME command surface + exit +//! codes, and each reflects its OWN invoked name (arg0) in `--help`/`--version` — so +//! `dign ` behaves identically to `dig-node `. +//! +//! These run against the REAL built binaries (Cargo hands each `[[bin]]` path to +//! integration tests via `CARGO_BIN_EXE_`), so they also prove the second +//! `[[bin]]` target actually builds — mirroring digstore's `cli_digs_alias.rs`. + +use std::process::Command; + +use serde_json::Value; + +fn dig_node() -> Command { + Command::new(env!("CARGO_BIN_EXE_dig-node")) +} + +fn dign() -> Command { + Command::new(env!("CARGO_BIN_EXE_dign")) +} + +/// Both binaries build/run, and each `--version` reports the SAME semver — with its +/// OWN program name (clap prints " "): `dign 0.x.y` vs `dig-node 0.x.y`. +#[test] +fn dign_and_dig_node_report_the_same_version() { + let dn = dig_node() + .arg("--version") + .output() + .expect("dig-node --version"); + let dg = dign().arg("--version").output().expect("dign --version"); + assert!(dn.status.success() && dg.status.success()); + + let dn_out = String::from_utf8_lossy(&dn.stdout); + let dg_out = String::from_utf8_lossy(&dg.stdout); + + // The trailing semver token must match; the leading program name differs. + let dn_ver = dn_out.split_whitespace().last().unwrap(); + let dg_ver = dg_out.split_whitespace().last().unwrap(); + assert_eq!(dn_ver, dg_ver, "same version: `{dn_out}` vs `{dg_out}`"); + assert!( + dn_out.starts_with("dig-node "), + "dig-node leads with its name: {dn_out}" + ); + assert!( + dg_out.starts_with("dign "), + "dign leads with its own name: {dg_out}" + ); +} + +/// `dign --help` renders its OWN name in the usage line, not a hardcoded "dig-node". +#[test] +fn dign_help_usage_shows_dign() { + let out = dign().arg("--help").output().expect("dign --help"); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("Usage: dign"), + "usage line must read `dign`, got:\n{stdout}" + ); + // Discriminating: "dig-node" must not leak into the alias's usage (note "dign" is + // NOT a substring of "dig-node", so this catches a hardcoded program name). + assert!( + !stdout.contains("Usage: dig-node"), + "the `dign` help must not report `dig-node`:\n{stdout}" + ); +} + +/// `dign` runs the SAME dispatch path: `dign status --json` against a dead port fails +/// with the identical envelope `dig-node status --json` produces (exit 1 NOT_SERVING, +/// `serving:false`, and the stable `service:"dig-node"` identity — the alias does NOT +/// rename the SERVICE, only the invoked binary). +#[test] +fn dign_dispatches_commands_like_dig_node() { + let dg = dign() + .args(["status", "--json"]) + .env("DIG_NODE_PORT", "1") + .output() + .expect("dign status --json"); + assert_eq!( + dg.status.code(), + Some(1), + "NOT_SERVING exits 1 under dign too" + ); + + let stdout = String::from_utf8_lossy(&dg.stdout); + let v: Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be one JSON object: {e}\n---\n{stdout}")); + assert_eq!(v["ok"], Value::Bool(true)); + assert_eq!(v["action"], Value::String("status".into())); + // The alias renames the BINARY, never the service identity string. + assert_eq!(v["service"], Value::String("dig-node".into())); + assert_eq!(v["serving"], Value::Bool(false)); +} + +/// The two bins expose the IDENTICAL command surface: an unknown subcommand is a usage +/// error (clap exit 2) under BOTH, proving they share one parser/dispatch path. +#[test] +fn dign_rejects_unknown_subcommand_like_dig_node() { + let dg = dign().arg("definitely-not-a-command").output().unwrap(); + let dn = dig_node().arg("definitely-not-a-command").output().unwrap(); + assert_eq!( + dg.status.code(), + dn.status.code(), + "both fail an unknown subcommand with the same code" + ); + assert!(!dg.status.success(), "bad arg must fail under dign"); +} diff --git a/crates/dig-node-service/tests/release_workflow_dign_guard.rs b/crates/dig-node-service/tests/release_workflow_dign_guard.rs new file mode 100644 index 0000000..2a4ce29 --- /dev/null +++ b/crates/dig-node-service/tests/release_workflow_dign_guard.rs @@ -0,0 +1,32 @@ +//! Guard: the release workflow MUST build + publish the `dign` first-class alias +//! (issue #548) alongside `dig-node`, so every dig-node GitHub Release carries a +//! `dign--[.exe]` asset with the SAME shape as `dig-node--`. +//! +//! This is the producer-side counterpart to the dig-installer's `Repo::dign()` asset +//! matcher (a separate follow-up, #548 step 3): here we assert the workflow actually +//! EMITS the asset the installer will later resolve. `release.yml` is embedded at +//! compile time so the check runs hermetically with no filesystem access. + +/// The release workflow, embedded from the repo root (`crates/dig-node-service/tests` +/// is three levels below it). +const RELEASE_YML: &str = include_str!("../../../.github/workflows/release.yml"); + +/// The build step must compile the `dign` bin target beside `dig-node`; dropping +/// `--bin dign` would silently stop shipping the alias. +#[test] +fn release_workflow_builds_the_dign_bin() { + assert!( + RELEASE_YML.contains("--bin dig-node --bin dign"), + "release.yml must `cargo build … --bin dig-node --bin dign`" + ); +} + +/// The stage step must publish the alias under the `dign--` stem — the +/// exact shape the dig-installer resolves (matching `dig-node--`). +#[test] +fn release_workflow_stages_the_dign_asset() { + assert!( + RELEASE_YML.contains("dist/dign-${VER}-${{ matrix.out_name }}"), + "release.yml must stage a `dign--` release asset" + ); +}