cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 6ba026c0f188b650567b5388611b279133d62cbe
parent f8f2c6c96506ce573a7003810495c3201089e23b
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 06:11:09 +0000

cli: enforce network posture globals

- reject external operations in offline mode
- require relay configuration for online network work
- preserve supported dry-run behavior while offline
- cover network posture in target cli tests

Diffstat:
Msrc/main.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/operation_adapter.rs | 20++++++++++++++++++++
Mtests/target_cli.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 146 insertions(+), 4 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -22,8 +22,9 @@ use clap::Parser; use crate::cli::{CliArgs, Command, ConfigArgs, ConfigCommand, OutputFormatArg}; use crate::operation_adapter::{ - MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationOutputFormat, - OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService, + MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationNetworkMode, + OperationOutputFormat, OperationRequest, OperationRequestPayload, OperationResultPayload, + OperationService, }; use crate::operation_basket::BasketOperationService; use crate::operation_core::CoreOperationService; @@ -54,7 +55,7 @@ fn run() -> Result<ExitCode, runtime::RuntimeError> { let config = RuntimeConfig::from_system(&config_args_from_target(&args)?)?; let logging = initialize_logging(&config.logging)?; let request = MvpOperationRequest::from_target_args(&args).map_err(operation_config_error)?; - let envelope = match validate_request_contract(&request) { + let envelope = match validate_request_contract(&request, &config) { Ok(()) => execute_request(request, &config, &logging), Err(error) => failure_envelope(&request, error), }; @@ -314,7 +315,10 @@ where } } -fn validate_request_contract(request: &MvpOperationRequest) -> Result<(), OperationAdapterError> { +fn validate_request_contract( + request: &MvpOperationRequest, + config: &RuntimeConfig, +) -> Result<(), OperationAdapterError> { let spec = request.spec(); if matches!( request.context().output_format, @@ -332,9 +336,61 @@ fn validate_request_contract(request: &MvpOperationRequest) -> Result<(), Operat message: format!("`{}` does not support --dry-run", spec.cli_path), }); } + validate_network_contract(request, config)?; Ok(()) } +fn validate_network_contract( + request: &MvpOperationRequest, + config: &RuntimeConfig, +) -> Result<(), OperationAdapterError> { + let spec = request.spec(); + let external = external_network_operation(spec.operation_id); + match request.context().network_mode { + OperationNetworkMode::Default => Ok(()), + OperationNetworkMode::Offline => { + if external && !request.context().dry_run { + return Err(OperationAdapterError::OfflineForbidden { + operation_id: spec.operation_id.to_owned(), + message: format!( + "`{}` requires relay, provider, or workflow network access", + spec.cli_path + ), + }); + } + Ok(()) + } + OperationNetworkMode::Online => { + if external && !request.context().dry_run && config.relay.urls.is_empty() { + return Err(OperationAdapterError::NetworkUnavailable { + operation_id: spec.operation_id.to_owned(), + message: format!( + "`{}` requires at least one configured relay for online execution", + spec.cli_path + ), + }); + } + Ok(()) + } + } +} + +fn external_network_operation(operation_id: &str) -> bool { + matches!( + operation_id, + "sync.pull" + | "sync.push" + | "sync.watch" + | "market.refresh" + | "farm.publish" + | "listing.publish" + | "listing.archive" + | "order.submit" + | "order.event.watch" + | "job.watch" + ) +} + fn failure_envelope(request: &MvpOperationRequest, error: OperationAdapterError) -> OutputEnvelope { OutputEnvelope::failure( request.operation_id(), diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -302,6 +302,16 @@ pub enum OperationAdapterError { operation_id: String, message: String, }, + #[error("operation `{operation_id}` is forbidden while offline: {message}")] + OfflineForbidden { + operation_id: String, + message: String, + }, + #[error("operation `{operation_id}` cannot run online: {message}")] + NetworkUnavailable { + operation_id: String, + message: String, + }, #[error("operation runtime error: {0}")] Runtime(String), #[error("operation `{operation_id}` is unavailable or unconfigured: {message}")] @@ -329,6 +339,16 @@ impl OperationAdapterError { Self::InvalidInput { message, .. } => { OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput) } + Self::OfflineForbidden { message, .. } => OutputError::new( + "offline_forbidden", + message.clone(), + CliExitCode::SyncOrNetworkFailure, + ), + Self::NetworkUnavailable { message, .. } => OutputError::new( + "network_unavailable", + message.clone(), + CliExitCode::SyncOrNetworkFailure, + ), Self::UnknownOperation(operation_id) => OutputError::new( "unknown_operation", format!("unknown operation `{operation_id}`"), diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -152,6 +152,72 @@ fn unsupported_ndjson_returns_structured_invalid_input() { } #[test] +fn offline_forbids_external_network_operations() { + let output = radroots() + .args(["--format", "json", "--offline", "sync", "pull"]) + .output() + .expect("run offline sync pull"); + + assert!(!output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(value["operation_id"], "sync.pull"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "offline_forbidden"); + assert_eq!(value["errors"][0]["exit_code"], 8); +} + +#[test] +fn offline_allows_supported_external_dry_run() { + let sandbox = RadrootsCliSandbox::new(); + let listing_file = sandbox.root().join("listing.toml"); + let listing_file = listing_file.to_string_lossy().into_owned(); + + let publish = json_success( + &sandbox, + &[ + "--format", + "json", + "--offline", + "--dry-run", + "listing", + "publish", + listing_file.as_str(), + ], + ); + + assert_eq!(publish["operation_id"], "listing.publish"); + assert_eq!(publish["result"]["state"], "dry_run"); +} + +#[test] +fn online_requires_relay_for_external_network_operations() { + let output = radroots() + .args(["--format", "json", "--online", "market", "refresh"]) + .output() + .expect("run online market refresh"); + + assert!(!output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(value["operation_id"], "market.refresh"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["exit_code"], 8); +} + +#[test] +fn online_allows_local_diagnostics() { + let value = json_success( + &RadrootsCliSandbox::new(), + &["--format", "json", "--online", "workspace", "get"], + ); + + assert_eq!(value["operation_id"], "workspace.get"); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); +} + +#[test] fn required_approval_missing_token_returns_structured_error() { let output = radroots() .args(["--format", "json", "order", "submit"])