commit 5597fc481c64f1c72f5e77977e1c088768e7d88e
parent 9e4a5542e5e7a51b63a85d330f2077dd899b48e2
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 01:02:00 +0000
cli: add local farm commands
Diffstat:
8 files changed, 976 insertions(+), 15 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -56,6 +56,7 @@ pub enum Command {
Account(AccountArgs),
Config(ConfigArgs),
Doctor,
+ Farm(FarmArgs),
Find(FindArgs),
Job(JobArgs),
Listing(ListingArgs),
@@ -83,6 +84,11 @@ impl Command {
ConfigCommand::Show => "config show",
},
Self::Doctor => "doctor",
+ Self::Farm(farm) => match farm.command {
+ FarmCommand::Setup(_) => "farm setup",
+ FarmCommand::Status(_) => "farm status",
+ FarmCommand::Get(_) => "farm get",
+ },
Self::Find(_) => "find",
Self::Job(job) => match job.command {
JobCommand::Ls => "job ls",
@@ -179,6 +185,8 @@ impl Command {
self,
Self::Account(AccountArgs {
command: AccountCommand::New | AccountCommand::Use(_),
+ }) | Self::Farm(FarmArgs {
+ command: FarmCommand::Setup(_),
}) | Self::Local(LocalArgs {
command: LocalCommand::Init | LocalCommand::Export(_) | LocalCommand::Backup(_),
}) | Self::Sync(SyncArgs {
@@ -256,6 +264,61 @@ pub enum RelayCommand {
}
#[derive(Debug, Clone, Args)]
+pub struct FarmArgs {
+ #[command(subcommand)]
+ pub command: FarmCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum FarmCommand {
+ Setup(FarmSetupArgs),
+ Status(FarmScopedArgs),
+ Get(FarmScopedArgs),
+}
+
+#[derive(Debug, Clone, Args, Default)]
+pub struct FarmScopedArgs {
+ #[arg(long, value_enum)]
+ pub scope: Option<FarmScopeArg>,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct FarmSetupArgs {
+ #[arg(long, value_enum)]
+ pub scope: Option<FarmScopeArg>,
+ #[arg(long = "farm-d-tag")]
+ pub farm_d_tag: Option<String>,
+ #[arg(long)]
+ pub name: String,
+ #[arg(long = "display-name")]
+ pub display_name: Option<String>,
+ #[arg(long)]
+ pub about: Option<String>,
+ #[arg(long)]
+ pub website: Option<String>,
+ #[arg(long)]
+ pub picture: Option<String>,
+ #[arg(long)]
+ pub banner: Option<String>,
+ #[arg(long)]
+ pub location: String,
+ #[arg(long)]
+ pub city: Option<String>,
+ #[arg(long)]
+ pub region: Option<String>,
+ #[arg(long)]
+ pub country: Option<String>,
+ #[arg(long = "delivery-method", default_value = "pickup")]
+ pub delivery_method: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
+pub enum FarmScopeArg {
+ User,
+ Workspace,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct NetArgs {
#[command(subcommand)]
pub command: NetCommand,
@@ -511,10 +574,10 @@ pub struct RecordKeyArgs {
#[cfg(test)]
mod tests {
use super::{
- AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, JobWatchArgs, ListingCommand,
- LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, OrderWatchArgs,
- RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SignerCommand, SyncCommand,
- SyncWatchArgs,
+ AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmScopeArg, JobCommand,
+ JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MycCommand, NetCommand,
+ OrderCommand, OrderWatchArgs, RelayCommand, RpcCommand, RuntimeCommand,
+ RuntimeConfigCommand, SignerCommand, SyncCommand, SyncWatchArgs,
};
use crate::runtime::config::OutputFormat;
use clap::Parser;
@@ -682,6 +745,57 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let farm_setup = CliArgs::parse_from([
+ "radroots",
+ "farm",
+ "setup",
+ "--scope",
+ "workspace",
+ "--name",
+ "La Huerta",
+ "--location",
+ "San Francisco, CA",
+ "--city",
+ "San Francisco",
+ "--region",
+ "CA",
+ "--country",
+ "US",
+ "--delivery-method",
+ "local_delivery",
+ ]);
+ match farm_setup.command {
+ Command::Farm(args) => match args.command {
+ FarmCommand::Setup(setup) => {
+ assert_eq!(setup.scope, Some(FarmScopeArg::Workspace));
+ assert_eq!(setup.name, "La Huerta");
+ assert_eq!(setup.location, "San Francisco, CA");
+ assert_eq!(setup.city.as_deref(), Some("San Francisco"));
+ assert_eq!(setup.delivery_method, "local_delivery");
+ }
+ _ => panic!("unexpected farm subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let farm_status = CliArgs::parse_from(["radroots", "farm", "status", "--scope", "user"]);
+ match farm_status.command {
+ Command::Farm(args) => match args.command {
+ FarmCommand::Status(status) => assert_eq!(status.scope, Some(FarmScopeArg::User)),
+ _ => panic!("unexpected farm subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let farm_get = CliArgs::parse_from(["radroots", "farm", "get"]);
+ match farm_get.command {
+ Command::Farm(args) => match args.command {
+ FarmCommand::Get(get) => assert!(get.scope.is_none()),
+ _ => panic!("unexpected farm subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let relay = CliArgs::parse_from(["radroots", "relay", "ls"]);
match relay.command {
Command::Relay(args) => match args.command {
@@ -1085,6 +1199,27 @@ mod tests {
assert_eq!(account_new.command.display_name(), "account new");
assert!(!account_new.command.supports_dry_run());
+ let farm_setup = CliArgs::parse_from([
+ "radroots",
+ "farm",
+ "setup",
+ "--name",
+ "La Huerta",
+ "--location",
+ "San Francisco, CA",
+ ]);
+ assert_eq!(farm_setup.command.display_name(), "farm setup");
+ assert!(!farm_setup.command.supports_dry_run());
+
+ let farm_status = CliArgs::parse_from(["radroots", "farm", "status"]);
+ assert_eq!(farm_status.command.display_name(), "farm status");
+ assert!(farm_status.command.supports_dry_run());
+ assert!(
+ !farm_status
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
+
let find = CliArgs::parse_from(["radroots", "find", "eggs"]);
assert!(find.command.supports_output_format(OutputFormat::Ndjson));
diff --git a/src/commands/farm.rs b/src/commands/farm.rs
@@ -0,0 +1,72 @@
+use crate::cli::{FarmScopedArgs, FarmSetupArgs};
+use crate::domain::runtime::{
+ CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmSetupView, FarmStatusView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::farm::setup(config, args)?;
+ Ok(farm_setup_output(view))
+}
+
+pub fn status(
+ config: &RuntimeConfig,
+ args: &FarmScopedArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::farm::status(config, args)?;
+ Ok(farm_status_output(view))
+}
+
+pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::farm::get(config, args)?;
+ Ok(farm_get_output(view))
+}
+
+fn farm_setup_output(view: FarmSetupView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::FarmSetup(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::FarmSetup(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::FarmSetup(view))
+ }
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::FarmSetup(view)),
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::FarmSetup(view))
+ }
+ }
+}
+
+fn farm_status_output(view: FarmStatusView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::FarmStatus(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::FarmStatus(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::FarmStatus(view))
+ }
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::FarmStatus(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::FarmStatus(view))
+ }
+ }
+}
+
+fn farm_get_output(view: FarmGetView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::FarmGet(view)),
+ CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::FarmGet(view)),
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::FarmGet(view))
+ }
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::FarmGet(view)),
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::FarmGet(view))
+ }
+ }
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -1,4 +1,5 @@
pub mod doctor;
+pub mod farm;
pub mod find;
pub mod identity;
pub mod job;
@@ -14,9 +15,9 @@ pub mod signer;
pub mod sync;
use crate::cli::{
- AccountCommand, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, MycCommand,
- NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand,
- SignerCommand, SyncCommand,
+ AccountCommand, Command, ConfigCommand, FarmCommand, JobCommand, ListingCommand, LocalCommand,
+ MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand,
+ RuntimeConfigCommand, SignerCommand, SyncCommand,
};
use crate::domain::runtime::{CommandOutput, CommandView};
use crate::runtime::RuntimeError;
@@ -51,6 +52,11 @@ pub fn dispatch(
SignerCommand::Status => Ok(signer::status(config)),
},
Command::Doctor => doctor::report(config, logging),
+ Command::Farm(farm_command) => match &farm_command.command {
+ FarmCommand::Setup(args) => farm::setup(config, args),
+ FarmCommand::Status(args) => farm::status(config, args),
+ FarmCommand::Get(args) => farm::get(config, args),
+ },
Command::Find(find_args) => find::search(config, find_args),
Command::Job(job) => match &job.command {
JobCommand::Ls => Ok(job::list(config)),
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1,5 +1,8 @@
use std::process::ExitCode;
+use radroots_events::farm::RadrootsFarm;
+use radroots_events::listing::RadrootsListingLocation;
+use radroots_events::profile::RadrootsProfile;
use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord;
use serde::Serialize;
@@ -83,6 +86,9 @@ pub enum CommandView {
AccountWhoami(AccountWhoamiView),
ConfigShow(ConfigShowView),
Doctor(DoctorView),
+ FarmGet(FarmGetView),
+ FarmSetup(FarmSetupView),
+ FarmStatus(FarmStatusView),
Find(FindView),
JobGet(JobGetView),
JobList(JobListView),
@@ -632,6 +638,129 @@ pub struct LocalReplicaSyncView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct FarmSetupView {
+ pub state: String,
+ pub source: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub config: Option<FarmConfigSummaryView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl FarmSetupView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmStatusView {
+ pub state: String,
+ pub source: String,
+ pub scope: String,
+ pub path: String,
+ pub config_present: bool,
+ pub config_valid: bool,
+ pub account_state: String,
+ pub listing_defaults_state: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub config: Option<FarmConfigSummaryView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl FarmStatusView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmGetView {
+ pub state: String,
+ pub source: String,
+ pub scope: String,
+ pub path: String,
+ pub config_present: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub document: Option<FarmConfigDocumentView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl FarmGetView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmConfigSummaryView {
+ pub scope: String,
+ pub path: String,
+ pub selected_account_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub selected_account_pubkey: Option<String>,
+ pub farm_d_tag: String,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub location_primary: Option<String>,
+ pub delivery_method: String,
+ pub publication: FarmPublicationView,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmConfigDocumentView {
+ pub selection: FarmSelectionView,
+ pub profile: RadrootsProfile,
+ pub farm: RadrootsFarm,
+ pub listing_defaults: FarmListingDefaultsView,
+ pub publication: FarmPublicationView,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmSelectionView {
+ pub scope: String,
+ pub account: String,
+ pub farm_d_tag: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmListingDefaultsView {
+ pub delivery_method: String,
+ pub location: RadrootsListingLocation,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmPublicationView {
+ pub profile_state: String,
+ pub farm_state: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub profile_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub profile_published_at: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_published_at: Option<u64>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct FindView {
pub state: String,
pub source: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -2,13 +2,13 @@ use std::io::{self, Write};
use crate::domain::runtime::{
AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView,
- FindView, JobGetView, JobListView, JobWatchView, ListingGetView, ListingMutationView,
- ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView,
- LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView,
- OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView,
- OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView,
- RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView,
- SyncWatchView,
+ FarmConfigSummaryView, FarmGetView, FarmSetupView, FarmStatusView, FindView, JobGetView,
+ JobListView, JobWatchView, ListingGetView, ListingMutationView, ListingNewView,
+ ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView,
+ NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView,
+ OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, OrderWorkflowView,
+ RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView,
+ RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -114,6 +114,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::Doctor(view) => {
render_doctor(stdout, view)?;
}
+ CommandView::FarmGet(view) => {
+ render_farm_get(stdout, view)?;
+ }
+ CommandView::FarmSetup(view) => {
+ render_farm_setup(stdout, view)?;
+ }
+ CommandView::FarmStatus(view) => {
+ render_farm_status(stdout, view)?;
+ }
CommandView::Find(view) => {
render_find(stdout, view)?;
}
@@ -290,6 +299,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::FarmGet(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::FarmSetup(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::FarmStatus(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::Find(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -2149,6 +2170,126 @@ fn render_sync_watch(stdout: &mut dyn Write, view: &SyncWatchView) -> Result<(),
Ok(())
}
+fn render_farm_setup(stdout: &mut dyn Write, view: &FarmSetupView) -> Result<(), RuntimeError> {
+ write_context(
+ stdout,
+ match view.state.as_str() {
+ "configured" => "farm · configured",
+ "unconfigured" => "farm · unconfigured",
+ _ => "farm",
+ },
+ )?;
+ if let Some(config) = &view.config {
+ render_farm_summary(stdout, config)?;
+ }
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_farm_status(stdout: &mut dyn Write, view: &FarmStatusView) -> Result<(), RuntimeError> {
+ write_context(
+ stdout,
+ match view.state.as_str() {
+ "ready" => "farm · ready",
+ "unconfigured" => "farm · unconfigured",
+ _ => "farm",
+ },
+ )?;
+ let rows = vec![
+ ("scope", view.scope.clone()),
+ ("path", view.path.clone()),
+ ("config present", yes_no(view.config_present).to_owned()),
+ ("config valid", yes_no(view.config_valid).to_owned()),
+ ("account", view.account_state.clone()),
+ ("listing defaults", view.listing_defaults_state.clone()),
+ ];
+ render_owned_pairs(stdout, "status", rows.as_slice())?;
+ if let Some(config) = &view.config {
+ render_farm_summary(stdout, config)?;
+ }
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_farm_get(stdout: &mut dyn Write, view: &FarmGetView) -> Result<(), RuntimeError> {
+ write_context(
+ stdout,
+ match view.state.as_str() {
+ "ready" => "farm · selected",
+ "unconfigured" => "farm · unconfigured",
+ _ => "farm",
+ },
+ )?;
+ render_owned_pairs(
+ stdout,
+ "config",
+ &[
+ ("scope", view.scope.clone()),
+ ("path", view.path.clone()),
+ ("config present", yes_no(view.config_present).to_owned()),
+ ],
+ )?;
+ if let Some(document) = &view.document {
+ let mut rows = vec![
+ ("account id", document.selection.account.clone()),
+ ("farm d_tag", document.selection.farm_d_tag.clone()),
+ ("name", document.farm.name.clone()),
+ (
+ "delivery method",
+ document.listing_defaults.delivery_method.clone(),
+ ),
+ (
+ "profile publish",
+ document.publication.profile_state.clone(),
+ ),
+ ("farm publish", document.publication.farm_state.clone()),
+ ];
+ if let Some(location) = &document.farm.location {
+ if let Some(primary) = &location.primary {
+ rows.push(("location", primary.clone()));
+ }
+ }
+ render_owned_pairs(stdout, "farm", rows.as_slice())?;
+ }
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_farm_summary(
+ stdout: &mut dyn Write,
+ config: &FarmConfigSummaryView,
+) -> Result<(), RuntimeError> {
+ let mut rows = vec![
+ ("scope", config.scope.clone()),
+ ("path", config.path.clone()),
+ ("account id", config.selected_account_id.clone()),
+ ("farm d_tag", config.farm_d_tag.clone()),
+ ("name", config.name.clone()),
+ ("delivery method", config.delivery_method.clone()),
+ ("profile publish", config.publication.profile_state.clone()),
+ ("farm publish", config.publication.farm_state.clone()),
+ ];
+ if let Some(pubkey) = &config.selected_account_pubkey {
+ rows.insert(3, ("account pubkey", pubkey.clone()));
+ }
+ if let Some(location) = &config.location_primary {
+ rows.push(("location", location.clone()));
+ }
+ render_owned_pairs(stdout, "farm", rows.as_slice())
+}
+
fn render_local_init(stdout: &mut dyn Write, view: &LocalInitView) -> Result<(), RuntimeError> {
write_context(stdout, format!("local · {}", view.state).as_str())?;
render_pairs(
@@ -2559,6 +2700,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::AccountWhoami(_) => "account whoami",
CommandView::ConfigShow(_) => "config show",
CommandView::Doctor(_) => "doctor",
+ CommandView::FarmGet(_) => "farm get",
+ CommandView::FarmSetup(_) => "farm setup",
+ CommandView::FarmStatus(_) => "farm status",
CommandView::Find(_) => "find",
CommandView::JobGet(_) => "job get",
CommandView::JobList(_) => "job ls",
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -0,0 +1,462 @@
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation};
+use radroots_events::listing::RadrootsListingLocation;
+use radroots_events::profile::RadrootsProfile;
+use radroots_events_codec::d_tag::is_d_tag_base64url;
+
+use crate::cli::{FarmScopeArg, FarmScopedArgs, FarmSetupArgs};
+use crate::domain::runtime::{
+ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView,
+ FarmPublicationView, FarmSelectionView, FarmSetupView, FarmStatusView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::accounts::{self, AccountRecordView};
+use crate::runtime::config::RuntimeConfig;
+use crate::runtime::farm_config::{
+ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults,
+ FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION,
+};
+
+const FARM_CONFIG_SOURCE: &str = "farm config · local first";
+
+static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<FarmSetupView, RuntimeError> {
+ let scope = scope_from_arg(args.scope);
+ let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
+ let selected_account = match accounts::resolve_account(config)? {
+ Some(account) => account,
+ None => {
+ return Ok(FarmSetupView {
+ state: "unconfigured".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ config: None,
+ reason: Some("farm setup requires a selected local account".to_owned()),
+ actions: vec![
+ "radroots account new".to_owned(),
+ "radroots account whoami".to_owned(),
+ ],
+ });
+ }
+ };
+ let existing = farm_config::load(config, Some(resolved_scope))?;
+ let document = setup_document(args, resolved_scope, &selected_account, existing.as_ref())?;
+ let path = farm_config::write(&config.paths, resolved_scope, &document)?;
+ let summary = summary_view(
+ resolved_scope,
+ path.display().to_string(),
+ &document,
+ Some(
+ selected_account
+ .record
+ .public_identity
+ .public_key_hex
+ .as_str(),
+ ),
+ );
+
+ Ok(FarmSetupView {
+ state: "configured".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ config: Some(summary),
+ reason: None,
+ actions: vec![
+ "radroots farm status".to_owned(),
+ "radroots farm get".to_owned(),
+ ],
+ })
+}
+
+pub fn status(
+ config: &RuntimeConfig,
+ args: &FarmScopedArgs,
+) -> Result<FarmStatusView, RuntimeError> {
+ let scope = scope_from_arg(args.scope);
+ let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
+ let path = farm_config::config_path(&config.paths, resolved_scope)?;
+ let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else {
+ return Ok(FarmStatusView {
+ state: "unconfigured".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ scope: resolved_scope.as_str().to_owned(),
+ path: path.display().to_string(),
+ config_present: false,
+ config_valid: false,
+ account_state: "not_checked".to_owned(),
+ listing_defaults_state: "missing".to_owned(),
+ config: None,
+ reason: Some(format!("no farm config found at {}", path.display())),
+ actions: vec![setup_action(resolved_scope)],
+ });
+ };
+
+ let account = configured_account(config, &resolved.document.selection.account)?;
+ let account_state = if account.is_some() {
+ "ready"
+ } else {
+ "missing"
+ };
+ let state = if account.is_some() {
+ "ready"
+ } else {
+ "unconfigured"
+ };
+ let reason = if account.is_some() {
+ None
+ } else {
+ Some(format!(
+ "farm config account `{}` is not present in the local account store",
+ resolved.document.selection.account
+ ))
+ };
+ let mut actions = Vec::new();
+ if account.is_none() {
+ actions.push("radroots account new".to_owned());
+ actions.push(setup_action(resolved.scope));
+ }
+ let account_pubkey = account
+ .as_ref()
+ .map(|account| account.record.public_identity.public_key_hex.as_str());
+
+ Ok(FarmStatusView {
+ state: state.to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ scope: resolved.scope.as_str().to_owned(),
+ path: resolved.path.display().to_string(),
+ config_present: true,
+ config_valid: true,
+ account_state: account_state.to_owned(),
+ listing_defaults_state: "ready".to_owned(),
+ config: Some(summary_view(
+ resolved.scope,
+ resolved.path.display().to_string(),
+ &resolved.document,
+ account_pubkey,
+ )),
+ reason,
+ actions,
+ })
+}
+
+pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, RuntimeError> {
+ let scope = scope_from_arg(args.scope);
+ let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
+ let path = farm_config::config_path(&config.paths, resolved_scope)?;
+ let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else {
+ return Ok(FarmGetView {
+ state: "unconfigured".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ scope: resolved_scope.as_str().to_owned(),
+ path: path.display().to_string(),
+ config_present: false,
+ document: None,
+ reason: Some(format!("no farm config found at {}", path.display())),
+ actions: vec![setup_action(resolved_scope)],
+ });
+ };
+
+ Ok(FarmGetView {
+ state: "ready".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ scope: resolved.scope.as_str().to_owned(),
+ path: resolved.path.display().to_string(),
+ config_present: true,
+ document: Some(document_view(&resolved.document)),
+ reason: None,
+ actions: Vec::new(),
+ })
+}
+
+fn setup_document(
+ args: &FarmSetupArgs,
+ scope: FarmConfigScope,
+ account: &AccountRecordView,
+ existing: Option<&ResolvedFarmConfig>,
+) -> Result<FarmConfigDocument, RuntimeError> {
+ let existing_document = existing.map(|resolved| &resolved.document);
+ let name = required_text(args.name.as_str(), "farm.name")?;
+ let location_primary = required_text(args.location.as_str(), "farm.location.primary")?;
+ let delivery_method = required_text(
+ args.delivery_method.as_str(),
+ "listing_defaults.delivery_method",
+ )?;
+ let farm_d_tag = match args.farm_d_tag.as_deref() {
+ Some(value) => required_d_tag(value, "farm_d_tag")?,
+ None => existing_document
+ .map(|document| document.farm.d_tag.clone())
+ .unwrap_or_else(generate_d_tag),
+ };
+ if !is_d_tag_base64url(farm_d_tag.as_str()) {
+ return Err(RuntimeError::Config(
+ "farm_d_tag must be a 22-character base64url identifier".to_owned(),
+ ));
+ }
+
+ let about = optional_arg_or_existing(
+ args.about.as_ref(),
+ existing_document.and_then(|document| document.profile.about.as_ref()),
+ );
+ let website = optional_arg_or_existing(
+ args.website.as_ref(),
+ existing_document.and_then(|document| document.profile.website.as_ref()),
+ );
+ let picture = optional_arg_or_existing(
+ args.picture.as_ref(),
+ existing_document.and_then(|document| document.profile.picture.as_ref()),
+ );
+ let banner = optional_arg_or_existing(
+ args.banner.as_ref(),
+ existing_document.and_then(|document| document.profile.banner.as_ref()),
+ );
+ let display_name = optional_arg_or_existing(
+ args.display_name.as_ref(),
+ existing_document.and_then(|document| document.profile.display_name.as_ref()),
+ )
+ .or_else(|| Some(name.clone()));
+ let city = optional_arg_or_existing(
+ args.city.as_ref(),
+ existing_document
+ .and_then(|document| document.farm.location.as_ref())
+ .and_then(|location| location.city.as_ref()),
+ );
+ let region = optional_arg_or_existing(
+ args.region.as_ref(),
+ existing_document
+ .and_then(|document| document.farm.location.as_ref())
+ .and_then(|location| location.region.as_ref()),
+ );
+ let country = optional_arg_or_existing(
+ args.country.as_ref(),
+ existing_document
+ .and_then(|document| document.farm.location.as_ref())
+ .and_then(|location| location.country.as_ref()),
+ );
+ let publication = existing_document
+ .filter(|document| document.farm.d_tag == farm_d_tag)
+ .map(|document| document.publication.clone())
+ .unwrap_or_default();
+
+ Ok(FarmConfigDocument {
+ version: SUPPORTED_FARM_CONFIG_VERSION,
+ selection: FarmConfigSelection {
+ scope,
+ account: account.record.account_id.to_string(),
+ farm_d_tag: farm_d_tag.clone(),
+ },
+ profile: RadrootsProfile {
+ name: name.clone(),
+ display_name,
+ nip05: None,
+ about: about.clone(),
+ website: website.clone(),
+ picture: picture.clone(),
+ banner: banner.clone(),
+ lud06: None,
+ lud16: None,
+ bot: None,
+ },
+ farm: RadrootsFarm {
+ d_tag: farm_d_tag,
+ name,
+ about,
+ website,
+ picture,
+ banner,
+ location: Some(RadrootsFarmLocation {
+ primary: Some(location_primary.clone()),
+ city: city.clone(),
+ region: region.clone(),
+ country: country.clone(),
+ gcs: None,
+ }),
+ tags: None,
+ },
+ listing_defaults: FarmListingDefaults {
+ delivery_method,
+ location: RadrootsListingLocation {
+ primary: location_primary,
+ city,
+ region,
+ country,
+ lat: None,
+ lng: None,
+ geohash: None,
+ },
+ },
+ publication,
+ })
+}
+
+fn configured_account(
+ config: &RuntimeConfig,
+ account_id: &str,
+) -> Result<Option<AccountRecordView>, RuntimeError> {
+ let snapshot = accounts::snapshot(config)?;
+ Ok(snapshot
+ .accounts
+ .into_iter()
+ .find(|account| account.record.account_id.as_str() == account_id))
+}
+
+fn summary_view(
+ scope: FarmConfigScope,
+ path: String,
+ document: &FarmConfigDocument,
+ account_pubkey: Option<&str>,
+) -> FarmConfigSummaryView {
+ FarmConfigSummaryView {
+ scope: scope.as_str().to_owned(),
+ path,
+ selected_account_id: document.selection.account.clone(),
+ selected_account_pubkey: account_pubkey.map(str::to_owned),
+ farm_d_tag: document.selection.farm_d_tag.clone(),
+ name: document.farm.name.clone(),
+ location_primary: document
+ .farm
+ .location
+ .as_ref()
+ .and_then(|location| location.primary.clone()),
+ delivery_method: document.listing_defaults.delivery_method.clone(),
+ publication: publication_view(&document.publication),
+ }
+}
+
+fn document_view(document: &FarmConfigDocument) -> FarmConfigDocumentView {
+ FarmConfigDocumentView {
+ selection: FarmSelectionView {
+ scope: document.selection.scope.as_str().to_owned(),
+ account: document.selection.account.clone(),
+ farm_d_tag: document.selection.farm_d_tag.clone(),
+ },
+ profile: document.profile.clone(),
+ farm: document.farm.clone(),
+ listing_defaults: FarmListingDefaultsView {
+ delivery_method: document.listing_defaults.delivery_method.clone(),
+ location: document.listing_defaults.location.clone(),
+ },
+ publication: publication_view(&document.publication),
+ }
+}
+
+fn publication_view(publication: &FarmPublicationStatus) -> FarmPublicationView {
+ FarmPublicationView {
+ profile_state: publish_state(
+ publication.profile_event_id.as_deref(),
+ publication.profile_published_at,
+ )
+ .to_owned(),
+ farm_state: publish_state(
+ publication.farm_event_id.as_deref(),
+ publication.farm_published_at,
+ )
+ .to_owned(),
+ profile_event_id: publication.profile_event_id.clone(),
+ farm_event_id: publication.farm_event_id.clone(),
+ profile_published_at: publication.profile_published_at,
+ farm_published_at: publication.farm_published_at,
+ }
+}
+
+fn publish_state(event_id: Option<&str>, published_at: Option<u64>) -> &'static str {
+ if event_id.is_some_and(|value| !value.trim().is_empty()) || published_at.is_some() {
+ "published"
+ } else {
+ "not_published"
+ }
+}
+
+fn setup_action(scope: FarmConfigScope) -> String {
+ format!(
+ "radroots farm setup --scope {} --name <farm-name> --location <place>",
+ scope.as_str()
+ )
+}
+
+fn scope_from_arg(scope: Option<FarmScopeArg>) -> Option<FarmConfigScope> {
+ scope.map(|scope| match scope {
+ FarmScopeArg::User => FarmConfigScope::User,
+ FarmScopeArg::Workspace => FarmConfigScope::Workspace,
+ })
+}
+
+fn required_d_tag(value: &str, field: &str) -> Result<String, RuntimeError> {
+ let value = required_text(value, field)?;
+ if !is_d_tag_base64url(value.as_str()) {
+ return Err(RuntimeError::Config(format!(
+ "{field} must be a 22-character base64url identifier"
+ )));
+ }
+ Ok(value)
+}
+
+fn required_text(value: &str, field: &str) -> Result<String, RuntimeError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(RuntimeError::Config(format!("{field} must not be empty")));
+ }
+ Ok(trimmed.to_owned())
+}
+
+fn optional_arg_or_existing(arg: Option<&String>, existing: Option<&String>) -> Option<String> {
+ arg.and_then(|value| non_empty(value.as_str()))
+ .or_else(|| existing.and_then(|value| non_empty(value.as_str())))
+}
+
+fn non_empty(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+fn generate_d_tag() -> String {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or_default();
+ let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
+ encode_base64url_no_pad((nanos ^ counter).to_be_bytes())
+}
+
+fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
+ const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ let mut output = String::with_capacity(22);
+ let mut index = 0usize;
+ while index + 3 <= bytes.len() {
+ let block = ((bytes[index] as u32) << 16)
+ | ((bytes[index + 1] as u32) << 8)
+ | (bytes[index + 2] as u32);
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
+ output.push(ALPHABET[(block & 0x3f) as usize] as char);
+ index += 3;
+ }
+ let remaining = bytes.len() - index;
+ if remaining == 1 {
+ let block = (bytes[index] as u32) << 16;
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ } else if remaining == 2 {
+ let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8);
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
+ }
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::generate_d_tag;
+ use radroots_events_codec::d_tag::is_d_tag_base64url;
+
+ #[test]
+ fn generated_farm_d_tag_is_valid_base64url() {
+ assert!(is_d_tag_base64url(&generate_d_tag()));
+ }
+}
diff --git a/src/runtime/farm_config.rs b/src/runtime/farm_config.rs
@@ -4,13 +4,14 @@ use std::path::{Path, PathBuf};
use radroots_events::farm::RadrootsFarm;
use radroots_events::listing::{RadrootsListingDeliveryMethod, RadrootsListingLocation};
use radroots_events::profile::RadrootsProfile;
+use radroots_events_codec::d_tag::is_d_tag_base64url;
use serde::{Deserialize, Serialize};
use crate::runtime::RuntimeError;
use crate::runtime::config::{PathsConfig, RuntimeConfig};
const FARM_CONFIG_FILE_NAME: &str = "farm.toml";
-const SUPPORTED_FARM_CONFIG_VERSION: u32 = 1;
+pub const SUPPORTED_FARM_CONFIG_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -207,6 +208,12 @@ pub fn validate(
"farm config selection.farm_d_tag must not be empty".to_owned(),
));
}
+ if !is_d_tag_base64url(trimmed(document.selection.farm_d_tag.as_str())) {
+ return Err(RuntimeError::Config(
+ "farm config selection.farm_d_tag must be a 22-character base64url identifier"
+ .to_owned(),
+ ));
+ }
if trimmed(document.profile.name.as_str()).is_empty() {
return Err(RuntimeError::Config(
"farm config profile.name must not be empty".to_owned(),
@@ -217,6 +224,11 @@ pub fn validate(
"farm config farm.d_tag must not be empty".to_owned(),
));
}
+ if !is_d_tag_base64url(trimmed(document.farm.d_tag.as_str())) {
+ return Err(RuntimeError::Config(
+ "farm config farm.d_tag must be a 22-character base64url identifier".to_owned(),
+ ));
+ }
if trimmed(document.farm.name.as_str()).is_empty() {
return Err(RuntimeError::Config(
"farm config farm.name must not be empty".to_owned(),
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -1,6 +1,7 @@
pub mod accounts;
pub mod config;
pub mod daemon;
+pub mod farm;
#[allow(dead_code)]
pub mod farm_config;
pub mod find;