cli

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

core.rs (55819B)


      1 use std::path::PathBuf;
      2 
      3 use serde::Serialize;
      4 use serde_json::{Value, json};
      5 
      6 use crate::cli::global::LocalExportFormatArg;
      7 use crate::ops::{
      8     AccountAttachSecretRequest, AccountAttachSecretResult, AccountCreateRequest,
      9     AccountCreateResult, AccountGetRequest, AccountGetResult, AccountImportRequest,
     10     AccountImportResult, AccountListRequest, AccountListResult, AccountRemoveRequest,
     11     AccountRemoveResult, AccountSelectionClearRequest, AccountSelectionClearResult,
     12     AccountSelectionGetRequest, AccountSelectionGetResult, AccountSelectionUpdateRequest,
     13     AccountSelectionUpdateResult, ConfigGetRequest, ConfigGetResult, HealthCheckRunRequest,
     14     HealthCheckRunResult, HealthStatusGetRequest, HealthStatusGetResult, OperationAdapterError,
     15     OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult,
     16     OperationResultData, OperationService, StoreBackupCreateRequest, StoreBackupCreateResult,
     17     StoreBackupRestoreRequest, StoreBackupRestoreResult, StoreExportRequest, StoreExportResult,
     18     StoreInitRequest, StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult,
     19     WorkspaceGetRequest, WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult,
     20 };
     21 use crate::runtime::RuntimeError;
     22 use crate::runtime::account::{
     23     AccountResolution, AccountRuntimeFailure, account_resolution_view, account_summary_view,
     24     attach_identity_secret, clear_default_account, create_or_migrate_default_account,
     25     import_public_identity, preview_account_removal, preview_identity_secret_attachment,
     26     preview_public_identity_import, remove_account, resolve_account_resolution,
     27     resolve_account_selector, secret_backend_status, select_account, snapshot,
     28     unresolved_account_reason,
     29 };
     30 use crate::runtime::config::{PublishTransport, RuntimeConfig, SignerBackend};
     31 use crate::runtime::logging::LoggingState;
     32 use crate::runtime::sdk::CliSdkAdapterError;
     33 use crate::runtime::signer::resolve_signer_status;
     34 use crate::view::runtime::{
     35     CommandDisposition, LocalBackupView, LocalRestoreView, PublishProviderRuntimeView,
     36     PublishRelayRuntimeView, PublishRuntimeView,
     37 };
     38 
     39 pub struct CoreOperationService<'a> {
     40     config: &'a RuntimeConfig,
     41     logging: &'a LoggingState,
     42 }
     43 
     44 impl<'a> CoreOperationService<'a> {
     45     pub fn new(config: &'a RuntimeConfig, logging: &'a LoggingState) -> Self {
     46         Self { config, logging }
     47     }
     48 }
     49 
     50 impl OperationService<WorkspaceInitRequest> for CoreOperationService<'_> {
     51     type Result = WorkspaceInitResult;
     52 
     53     fn execute(
     54         &self,
     55         request: OperationRequest<WorkspaceInitRequest>,
     56     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
     57         if request.context.dry_run {
     58             let local = map_runtime(crate::runtime::store::init_preflight(self.config))?;
     59             return json_operation_result::<WorkspaceInitResult>(json!({
     60                 "state": local.state,
     61                 "profile": self.config.paths.profile,
     62                 "local": local,
     63             }));
     64         }
     65 
     66         let local = map_runtime(crate::runtime::store::init(self.config))?;
     67         json_operation_result::<WorkspaceInitResult>(json!({
     68             "state": local.state,
     69             "profile": self.config.paths.profile,
     70             "local": local,
     71         }))
     72     }
     73 }
     74 
     75 impl OperationService<WorkspaceGetRequest> for CoreOperationService<'_> {
     76     type Result = WorkspaceGetResult;
     77 
     78     fn execute(
     79         &self,
     80         _request: OperationRequest<WorkspaceGetRequest>,
     81     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
     82         json_operation_result::<WorkspaceGetResult>(json!({
     83             "profile": self.config.paths.profile,
     84             "profile_source": self.config.paths.profile_source,
     85             "root_source": self.config.paths.root_source,
     86             "app_namespace": self.config.paths.app_namespace,
     87             "workspace_config_path": self.config.paths.workspace_config_path.as_ref().map(|path| path.display().to_string()),
     88             "app_config_path": self.config.paths.app_config_path.display().to_string(),
     89             "app_data_root": self.config.paths.app_data_root.display().to_string(),
     90             "app_logs_root": self.config.paths.app_logs_root.display().to_string(),
     91             "local_root": self.config.local.root.display().to_string(),
     92             "replica_db_path": self.config.local.replica_db_path.display().to_string(),
     93         }))
     94     }
     95 }
     96 
     97 impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> {
     98     type Result = HealthStatusGetResult;
     99 
    100     fn execute(
    101         &self,
    102         request: OperationRequest<HealthStatusGetRequest>,
    103     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    104         let store = map_sdk_adapter(
    105             request.operation_id(),
    106             crate::runtime::store::status(self.config),
    107         )?;
    108         let account = map_runtime(resolve_account_resolution(self.config))?;
    109         let publish = publish_runtime_view(self.config, true, &account);
    110         let signer = signer_health_view(self.config, &account);
    111         let state = health_status_state(&store.state, &publish);
    112         let actions = health_actions(self.config, store.state.as_str(), &account, &publish);
    113         json_operation_result::<HealthStatusGetResult>(json!({
    114             "state": state,
    115             "store": store,
    116             "account_resolution": account_resolution_view(&account),
    117             "signer": signer,
    118             "publish": publish,
    119             "logging": {
    120                 "initialized": self.logging.initialized,
    121                 "current_file": self.logging.current_file.as_ref().map(|path| path.display().to_string()),
    122             },
    123             "actions": actions,
    124         }))
    125     }
    126 }
    127 
    128 impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> {
    129     type Result = HealthCheckRunResult;
    130 
    131     fn execute(
    132         &self,
    133         request: OperationRequest<HealthCheckRunRequest>,
    134     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    135         let store = map_sdk_adapter(
    136             request.operation_id(),
    137             crate::runtime::store::status(self.config),
    138         )?;
    139         let account = map_runtime(resolve_account_resolution(self.config))?;
    140         let account_reason = if account.resolved_account.is_some() {
    141             None
    142         } else {
    143             Some(map_runtime(unresolved_account_reason(self.config))?)
    144         };
    145         let publish = publish_runtime_view(self.config, true, &account);
    146         let signer = signer_health_view(self.config, &account);
    147         let state = health_check_state(&store.state, account.resolved_account.is_some(), &publish);
    148         let actions = health_actions(self.config, store.state.as_str(), &account, &publish);
    149         json_operation_result::<HealthCheckRunResult>(json!({
    150             "state": state,
    151             "account_resolution": account_resolution_view(&account),
    152             "checks": {
    153                 "workspace": {
    154                     "state": "ready",
    155                     "profile": self.config.paths.profile,
    156                 },
    157                 "store": {
    158                     "state": store.state,
    159                     "source": store.source,
    160                     "canonical_store": store.canonical_store,
    161                     "sdk_storage": store.sdk_storage,
    162                     "sdk_root": store.sdk_root,
    163                     "sdk_existed_before_open": store.sdk_existed_before_open,
    164                     "event_store": store.event_store,
    165                     "outbox": store.outbox,
    166                     "integrity": store.integrity,
    167                     "legacy_replica": store.legacy_replica,
    168                     "reason": store.reason,
    169                 },
    170                 "account": {
    171                     "state": if account.resolved_account.is_some() { "ready" } else { "unconfigured" },
    172                     "reason": account_reason,
    173                 },
    174                 "signer": signer,
    175                 "publish": {
    176                     "state": publish.state,
    177                     "transport": publish.transport,
    178                     "executable": publish.executable,
    179                     "reason": publish.reason,
    180                 },
    181             },
    182             "actions": actions,
    183         }))
    184     }
    185 }
    186 
    187 impl OperationService<ConfigGetRequest> for CoreOperationService<'_> {
    188     type Result = ConfigGetResult;
    189 
    190     fn execute(
    191         &self,
    192         _request: OperationRequest<ConfigGetRequest>,
    193     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    194         let account = map_runtime(resolve_account_resolution(self.config))?;
    195         let publish = publish_runtime_view(self.config, true, &account);
    196         let write_plane =
    197             crate::runtime::provider::resolve_write_plane_provider(self.config, &publish);
    198         let actions = config_actions(self.config, &account, &publish);
    199         let mut result = json!({
    200             "output": {
    201                 "format": self.config.output.format.as_str(),
    202                 "verbosity": self.config.output.verbosity.as_str(),
    203                 "color": self.config.output.color,
    204                 "dry_run": self.config.output.dry_run,
    205             },
    206             "interaction": {
    207                 "input_enabled": self.config.interaction.input_enabled,
    208                 "prompts_allowed": self.config.interaction.prompts_allowed,
    209                 "confirmations_allowed": self.config.interaction.confirmations_allowed,
    210             },
    211             "paths": {
    212                 "profile": self.config.paths.profile,
    213                 "app_config_path": self.config.paths.app_config_path.display().to_string(),
    214                 "workspace_config_path": self.config.paths.workspace_config_path.as_ref().map(|path| path.display().to_string()),
    215                 "app_data_root": self.config.paths.app_data_root.display().to_string(),
    216                 "app_logs_root": self.config.paths.app_logs_root.display().to_string(),
    217             },
    218             "account": {
    219                 "selector": self.config.account.selector,
    220                 "store_path": self.config.account.store_path.display().to_string(),
    221                 "secrets_dir": self.config.account.secrets_dir.display().to_string(),
    222             },
    223             "account_resolution": account_resolution_view(&account),
    224             "signer": {
    225                 "mode": self.config.signer.backend.as_str(),
    226             },
    227             "publish": publish,
    228             "relay": {
    229                 "count": self.config.relay.urls.len(),
    230                 "urls": self.config.relay.urls,
    231                 "source": self.config.relay.source.as_str(),
    232             },
    233             "myc": {
    234                 "executable": self.config.myc.executable.display().to_string(),
    235                 "status_timeout_ms": self.config.myc.status_timeout_ms,
    236             },
    237             "hyf": {
    238                 "enabled": self.config.hyf.enabled,
    239                 "executable": self.config.hyf.executable.display().to_string(),
    240             },
    241             "write_plane": {
    242                 "provider_runtime_id": write_plane.provider_runtime_id,
    243                 "binding_model": write_plane.binding_model,
    244                 "state": write_plane.state,
    245                 "provenance": write_plane.provenance,
    246                 "source": write_plane.source,
    247                 "target_kind": write_plane.target_kind,
    248                 "target": write_plane.target,
    249                 "detail": write_plane.detail,
    250             },
    251             "local": {
    252                 "root": self.config.local.root.display().to_string(),
    253                 "replica_db_path": self.config.local.replica_db_path.display().to_string(),
    254                 "backups_dir": self.config.local.backups_dir.display().to_string(),
    255                 "exports_dir": self.config.local.exports_dir.display().to_string(),
    256             },
    257             "actions": actions,
    258         });
    259         if matches!(
    260             self.config.publish.transport,
    261             PublishTransport::RadrootsdProxy
    262         ) {
    263             result["radrootsd_proxy"] = json!({
    264                 "url": self.config.publish.radrootsd_proxy.url,
    265                 "token_file_configured": self.config.publish.radrootsd_proxy.token_file.is_some(),
    266                 "token_secret_id_configured": self.config.publish.radrootsd_proxy.token_secret_id.is_some(),
    267             });
    268         }
    269         json_operation_result::<ConfigGetResult>(result)
    270     }
    271 }
    272 
    273 impl OperationService<AccountCreateRequest> for CoreOperationService<'_> {
    274     type Result = AccountCreateResult;
    275 
    276     fn execute(
    277         &self,
    278         request: OperationRequest<AccountCreateRequest>,
    279     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    280         if request.context.dry_run {
    281             let secret_backend = secret_backend_status(self.config);
    282             if secret_backend.state != "ready" {
    283                 return Err(OperationAdapterError::OperationUnavailable {
    284                     operation_id: request.operation_id().to_owned(),
    285                     message: secret_backend
    286                         .reason
    287                         .unwrap_or_else(|| "account secret backend is not available".to_owned()),
    288                 });
    289             }
    290             return json_operation_result::<AccountCreateResult>(json!({
    291                 "state": "dry_run",
    292                 "store_path": self.config.account.store_path.display().to_string(),
    293                 "secrets_dir": self.config.account.secrets_dir.display().to_string(),
    294                 "secret_backend": {
    295                     "state": secret_backend.state,
    296                     "active_backend": secret_backend.active_backend,
    297                     "used_fallback": secret_backend.used_fallback,
    298                 },
    299             }));
    300         }
    301 
    302         let result = map_runtime(create_or_migrate_default_account(self.config))?;
    303         json_operation_result::<AccountCreateResult>(json!({
    304             "state": match result.mode {
    305                 crate::runtime::account::AccountCreateMode::Created => "created",
    306                 crate::runtime::account::AccountCreateMode::Migrated => "migrated",
    307             },
    308             "account": account_summary_view(&result.account),
    309         }))
    310     }
    311 }
    312 
    313 impl OperationService<AccountImportRequest> for CoreOperationService<'_> {
    314     type Result = AccountImportResult;
    315 
    316     fn execute(
    317         &self,
    318         request: OperationRequest<AccountImportRequest>,
    319     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    320         let path = required_path(&request, "path")?;
    321         let make_default = bool_input(&request, "default").unwrap_or(false);
    322         if request.context.dry_run {
    323             let account = map_expected_runtime(
    324                 request.operation_id(),
    325                 preview_public_identity_import(self.config, path.as_path(), make_default),
    326             )?;
    327             return json_operation_result::<AccountImportResult>(json!({
    328                 "state": "dry_run",
    329                 "path": path.display().to_string(),
    330                 "default": make_default,
    331                 "account": account_summary_view(&account),
    332             }));
    333         }
    334         if request.context.requires_approval_token() {
    335             return Err(OperationAdapterError::approval_required(
    336                 request.operation_id(),
    337             ));
    338         }
    339 
    340         let account = map_expected_runtime(
    341             request.operation_id(),
    342             import_public_identity(self.config, path.as_path(), make_default),
    343         )?;
    344         json_operation_result::<AccountImportResult>(json!({
    345             "state": "imported",
    346             "account": account_summary_view(&account),
    347         }))
    348     }
    349 }
    350 
    351 impl OperationService<AccountAttachSecretRequest> for CoreOperationService<'_> {
    352     type Result = AccountAttachSecretResult;
    353 
    354     fn execute(
    355         &self,
    356         request: OperationRequest<AccountAttachSecretRequest>,
    357     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    358         let selector = required_string(&request, "selector")?;
    359         let path = required_path(&request, "path")?;
    360         let make_default = bool_input(&request, "default").unwrap_or(false);
    361         if request.context.dry_run {
    362             let secret_backend = account_secret_backend_ready(request.operation_id(), self.config)?;
    363             let account = map_expected_runtime(
    364                 request.operation_id(),
    365                 preview_identity_secret_attachment(
    366                     self.config,
    367                     selector.as_str(),
    368                     path.as_path(),
    369                     make_default,
    370                 ),
    371             )?;
    372             return json_operation_result::<AccountAttachSecretResult>(json!({
    373                 "state": "dry_run",
    374                 "path": path.display().to_string(),
    375                 "default": make_default,
    376                 "secret_backend": {
    377                     "state": secret_backend.state,
    378                     "active_backend": secret_backend.active_backend,
    379                     "used_fallback": secret_backend.used_fallback,
    380                 },
    381                 "account": account_summary_view(&account),
    382             }));
    383         }
    384         if request.context.requires_approval_token() {
    385             return Err(OperationAdapterError::approval_required(
    386                 request.operation_id(),
    387             ));
    388         }
    389 
    390         let secret_backend = account_secret_backend_ready(request.operation_id(), self.config)?;
    391         let account = map_expected_runtime(
    392             request.operation_id(),
    393             attach_identity_secret(self.config, selector.as_str(), path.as_path(), make_default),
    394         )?;
    395         json_operation_result::<AccountAttachSecretResult>(json!({
    396             "state": "secret_attached",
    397             "default": make_default,
    398             "secret_backend": {
    399                 "state": secret_backend.state,
    400                 "active_backend": secret_backend.active_backend,
    401                 "used_fallback": secret_backend.used_fallback,
    402             },
    403             "account": account_summary_view(&account),
    404         }))
    405     }
    406 }
    407 
    408 impl OperationService<AccountGetRequest> for CoreOperationService<'_> {
    409     type Result = AccountGetResult;
    410 
    411     fn execute(
    412         &self,
    413         request: OperationRequest<AccountGetRequest>,
    414     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    415         let scoped;
    416         let config = if let Some(selector) = string_input(&request, "selector") {
    417             scoped = selected_config(self.config, selector);
    418             &scoped
    419         } else {
    420             self.config
    421         };
    422         let resolution = resolve_account_resolution(config).map_err(|error| {
    423             OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
    424         })?;
    425         let reason = if resolution.resolved_account.is_some() {
    426             None
    427         } else {
    428             Some(map_runtime(unresolved_account_reason(config))?)
    429         };
    430         json_operation_result::<AccountGetResult>(json!({
    431             "state": if resolution.resolved_account.is_some() { "ready" } else { "unconfigured" },
    432             "reason": reason,
    433             "account_resolution": account_resolution_view(&resolution),
    434         }))
    435     }
    436 }
    437 
    438 impl OperationService<AccountListRequest> for CoreOperationService<'_> {
    439     type Result = AccountListResult;
    440 
    441     fn execute(
    442         &self,
    443         _request: OperationRequest<AccountListRequest>,
    444     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    445         let snapshot = map_runtime(snapshot(self.config))?;
    446         let accounts = snapshot
    447             .accounts
    448             .iter()
    449             .map(account_summary_view)
    450             .collect::<Vec<_>>();
    451         json_operation_result::<AccountListResult>(json!({
    452             "source": crate::runtime::account::SHARED_ACCOUNT_STORE_SOURCE,
    453             "count": accounts.len(),
    454             "accounts": accounts,
    455         }))
    456     }
    457 }
    458 
    459 impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> {
    460     type Result = AccountRemoveResult;
    461 
    462     fn execute(
    463         &self,
    464         request: OperationRequest<AccountRemoveRequest>,
    465     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    466         let selector = required_string(&request, "selector")?;
    467         if request.context.dry_run {
    468             let preview =
    469                 preview_account_removal(self.config, selector.as_str()).map_err(|error| {
    470                     OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
    471                 })?;
    472             return json_operation_result::<AccountRemoveResult>(json!({
    473                 "state": "dry_run",
    474                 "removed_account": account_summary_view(&preview.account),
    475                 "default_would_clear": preview.default_would_clear,
    476                 "remaining_account_count": preview.remaining_account_count,
    477             }));
    478         }
    479         if request.context.requires_approval_token() {
    480             return Err(OperationAdapterError::approval_required(
    481                 request.operation_id(),
    482             ));
    483         }
    484 
    485         let result = remove_account(self.config, selector.as_str()).map_err(|error| {
    486             OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
    487         })?;
    488         json_operation_result::<AccountRemoveResult>(json!({
    489             "state": "removed",
    490             "removed_account": account_summary_view(&result.removed_account),
    491             "default_cleared": result.default_cleared,
    492             "remaining_account_count": result.remaining_account_count,
    493         }))
    494     }
    495 }
    496 
    497 impl OperationService<AccountSelectionGetRequest> for CoreOperationService<'_> {
    498     type Result = AccountSelectionGetResult;
    499 
    500     fn execute(
    501         &self,
    502         _request: OperationRequest<AccountSelectionGetRequest>,
    503     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    504         let resolution = map_runtime(resolve_account_resolution(self.config))?;
    505         json_operation_result::<AccountSelectionGetResult>(json!({
    506             "account_resolution": account_resolution_view(&resolution),
    507         }))
    508     }
    509 }
    510 
    511 impl OperationService<AccountSelectionUpdateRequest> for CoreOperationService<'_> {
    512     type Result = AccountSelectionUpdateResult;
    513 
    514     fn execute(
    515         &self,
    516         request: OperationRequest<AccountSelectionUpdateRequest>,
    517     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    518         let selector = required_string(&request, "selector")?;
    519         if request.context.dry_run {
    520             let account =
    521                 resolve_account_selector(self.config, selector.as_str()).map_err(|error| {
    522                     OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
    523                 })?;
    524             return json_operation_result::<AccountSelectionUpdateResult>(json!({
    525                 "state": "dry_run",
    526                 "account": account_summary_view(&account),
    527             }));
    528         }
    529 
    530         let account = select_account(self.config, selector.as_str()).map_err(|error| {
    531             OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
    532         })?;
    533         json_operation_result::<AccountSelectionUpdateResult>(json!({
    534             "state": "default",
    535             "account": account_summary_view(&account),
    536         }))
    537     }
    538 }
    539 
    540 impl OperationService<AccountSelectionClearRequest> for CoreOperationService<'_> {
    541     type Result = AccountSelectionClearResult;
    542 
    543     fn execute(
    544         &self,
    545         request: OperationRequest<AccountSelectionClearRequest>,
    546     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    547         if request.context.dry_run {
    548             let resolution = map_runtime(resolve_account_resolution(self.config))?;
    549             let account_snapshot = map_runtime(snapshot(self.config))?;
    550             return json_operation_result::<AccountSelectionClearResult>(json!({
    551                 "state": "dry_run",
    552                 "cleared_account": resolution.default_account.as_ref().map(account_summary_view),
    553                 "remaining_account_count": account_snapshot.accounts.len(),
    554             }));
    555         }
    556 
    557         let result = map_runtime(clear_default_account(self.config))?;
    558         json_operation_result::<AccountSelectionClearResult>(json!({
    559             "state": if result.cleared_account.is_some() { "cleared" } else { "already_clear" },
    560             "cleared_account": result.cleared_account.as_ref().map(account_summary_view),
    561             "remaining_account_count": result.remaining_account_count,
    562         }))
    563     }
    564 }
    565 
    566 impl OperationService<StoreInitRequest> for CoreOperationService<'_> {
    567     type Result = StoreInitResult;
    568 
    569     fn execute(
    570         &self,
    571         request: OperationRequest<StoreInitRequest>,
    572     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    573         if request.context.dry_run {
    574             let view = map_runtime(crate::runtime::store::init_preflight(self.config))?;
    575             return serialized_operation_result::<StoreInitResult, _>(&view);
    576         }
    577 
    578         let view = map_runtime(crate::runtime::store::init(self.config))?;
    579         serialized_operation_result::<StoreInitResult, _>(&view)
    580     }
    581 }
    582 
    583 impl OperationService<StoreStatusGetRequest> for CoreOperationService<'_> {
    584     type Result = StoreStatusGetResult;
    585 
    586     fn execute(
    587         &self,
    588         request: OperationRequest<StoreStatusGetRequest>,
    589     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    590         let view = map_sdk_adapter(
    591             request.operation_id(),
    592             crate::runtime::store::status(self.config),
    593         )?;
    594         serialized_operation_result::<StoreStatusGetResult, _>(&view)
    595     }
    596 }
    597 
    598 impl OperationService<StoreExportRequest> for CoreOperationService<'_> {
    599     type Result = StoreExportResult;
    600 
    601     fn execute(
    602         &self,
    603         request: OperationRequest<StoreExportRequest>,
    604     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    605         let output = optional_path(&request, "output")
    606             .unwrap_or_else(|| self.config.local.exports_dir.join("store-export.json"));
    607         let format = match string_input(&request, "format").as_deref() {
    608             Some("ndjson") => LocalExportFormatArg::Ndjson,
    609             Some("json") | None => LocalExportFormatArg::Json,
    610             Some(other) => {
    611                 return Err(invalid_input(
    612                     request.operation_id(),
    613                     format!("format must be `json` or `ndjson`, got `{other}`"),
    614                 ));
    615             }
    616         };
    617         if request.context.dry_run {
    618             return Err(invalid_input(
    619                 request.operation_id(),
    620                 "`radroots store export` does not support --dry-run".to_owned(),
    621             ));
    622         }
    623 
    624         let view = map_runtime(crate::runtime::store::export(
    625             self.config,
    626             format,
    627             output.as_path(),
    628         ))?;
    629         serialized_operation_result::<StoreExportResult, _>(&view)
    630     }
    631 }
    632 
    633 impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> {
    634     type Result = StoreBackupCreateResult;
    635 
    636     fn execute(
    637         &self,
    638         request: OperationRequest<StoreBackupCreateRequest>,
    639     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    640         let output = optional_path(&request, "output")
    641             .unwrap_or_else(|| self.config.local.backups_dir.join("sdk-store-backup"));
    642         if request.context.dry_run {
    643             let view = map_sdk_adapter(
    644                 request.operation_id(),
    645                 crate::runtime::store::backup_preflight(self.config, output.as_path()),
    646             )?;
    647             return local_backup_result(request.operation_id(), &view);
    648         }
    649 
    650         let view = map_sdk_adapter(
    651             request.operation_id(),
    652             crate::runtime::store::backup(self.config, output.as_path()),
    653         )?;
    654         local_backup_result(request.operation_id(), &view)
    655     }
    656 }
    657 
    658 impl OperationService<StoreBackupRestoreRequest> for CoreOperationService<'_> {
    659     type Result = StoreBackupRestoreResult;
    660 
    661     fn execute(
    662         &self,
    663         request: OperationRequest<StoreBackupRestoreRequest>,
    664     ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
    665         let source = required_path(&request, "source")?;
    666         let destination = optional_path(&request, "destination");
    667         let overwrite = bool_input(&request, "overwrite").unwrap_or(false);
    668         if overwrite && request.context.requires_approval_token() {
    669             return Err(OperationAdapterError::approval_required(
    670                 request.operation_id(),
    671             ));
    672         }
    673 
    674         let view = map_sdk_adapter(
    675             request.operation_id(),
    676             crate::runtime::store::restore(
    677                 self.config,
    678                 source.as_path(),
    679                 destination.as_deref(),
    680                 overwrite,
    681                 request.context.dry_run,
    682             ),
    683         )?;
    684         local_restore_result(request.operation_id(), &view)
    685     }
    686 }
    687 
    688 fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError>
    689 where
    690     R: OperationResultData,
    691     T: Serialize,
    692 {
    693     OperationResult::new(R::from_serializable(value)?)
    694 }
    695 
    696 fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError>
    697 where
    698     R: OperationResultData,
    699 {
    700     OperationResult::new(R::from_value(value))
    701 }
    702 
    703 fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> {
    704     result.map_err(|error| OperationAdapterError::Runtime(error.to_string()))
    705 }
    706 
    707 fn map_sdk_adapter<T>(
    708     operation_id: &str,
    709     result: Result<T, CliSdkAdapterError>,
    710 ) -> Result<T, OperationAdapterError> {
    711     result.map_err(|error| OperationAdapterError::sdk_adapter_failure(operation_id, error))
    712 }
    713 
    714 fn account_secret_backend_ready(
    715     operation_id: &str,
    716     config: &RuntimeConfig,
    717 ) -> Result<crate::runtime::account::AccountSecretBackendStatus, OperationAdapterError> {
    718     let secret_backend = secret_backend_status(config);
    719     if secret_backend.state == "ready" {
    720         return Ok(secret_backend);
    721     }
    722 
    723     Err(OperationAdapterError::OperationUnavailable {
    724         operation_id: operation_id.to_owned(),
    725         message: secret_backend
    726             .reason
    727             .unwrap_or_else(|| "account secret backend is not available".to_owned()),
    728     })
    729 }
    730 
    731 fn map_expected_runtime<T>(
    732     operation_id: &str,
    733     result: Result<T, RuntimeError>,
    734 ) -> Result<T, OperationAdapterError> {
    735     result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error))
    736 }
    737 
    738 fn local_backup_result(
    739     operation_id: &str,
    740     view: &LocalBackupView,
    741 ) -> Result<OperationResult<StoreBackupCreateResult>, OperationAdapterError> {
    742     match view.disposition() {
    743         CommandDisposition::Success => {
    744             serialized_operation_result::<StoreBackupCreateResult, _>(view)
    745         }
    746         disposition => Err(OperationAdapterError::from_command_disposition(
    747             operation_id,
    748             disposition,
    749             view.reason.clone().unwrap_or_else(|| match disposition {
    750                 CommandDisposition::Success => "store backup succeeded".to_owned(),
    751                 CommandDisposition::NotFound => "store backup target was not found".to_owned(),
    752                 CommandDisposition::ValidationFailed => "store backup validation failed".to_owned(),
    753                 CommandDisposition::Unconfigured => "store backup is unconfigured".to_owned(),
    754                 CommandDisposition::ExternalUnavailable => "store backup is unavailable".to_owned(),
    755                 CommandDisposition::Unsupported => "store backup is unsupported".to_owned(),
    756                 CommandDisposition::InternalError => "store backup failed".to_owned(),
    757             }),
    758         )),
    759     }
    760 }
    761 
    762 fn local_restore_result(
    763     operation_id: &str,
    764     view: &LocalRestoreView,
    765 ) -> Result<OperationResult<StoreBackupRestoreResult>, OperationAdapterError> {
    766     match view.disposition() {
    767         CommandDisposition::Success => {
    768             serialized_operation_result::<StoreBackupRestoreResult, _>(view)
    769         }
    770         disposition => Err(OperationAdapterError::from_command_disposition(
    771             operation_id,
    772             disposition,
    773             view.reason.clone().unwrap_or_else(|| match disposition {
    774                 CommandDisposition::Success => "store restore succeeded".to_owned(),
    775                 CommandDisposition::NotFound => "store restore source was not found".to_owned(),
    776                 CommandDisposition::ValidationFailed => {
    777                     "store restore validation failed".to_owned()
    778                 }
    779                 CommandDisposition::Unconfigured => "store restore is unconfigured".to_owned(),
    780                 CommandDisposition::ExternalUnavailable => {
    781                     "store restore is unavailable".to_owned()
    782                 }
    783                 CommandDisposition::Unsupported => "store restore is unsupported".to_owned(),
    784                 CommandDisposition::InternalError => "store restore failed".to_owned(),
    785             }),
    786         )),
    787     }
    788 }
    789 
    790 fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig {
    791     let mut config = config.clone();
    792     config.account.selector = Some(selector);
    793     config
    794 }
    795 
    796 fn publish_runtime_view(
    797     config: &RuntimeConfig,
    798     signed_write_required: bool,
    799     account: &AccountResolution,
    800 ) -> PublishRuntimeView {
    801     let relay_ready = !config.relay.urls.is_empty();
    802     let source = config.publish.source.as_str().to_owned();
    803     let relay = PublishRelayRuntimeView {
    804         ready: relay_ready,
    805         count: config.relay.urls.len(),
    806         source: config.relay.source.as_str().to_owned(),
    807     };
    808 
    809     match config.publish.transport {
    810         PublishTransport::DirectNostrRelay => {
    811             let (state, executable, reason) = direct_nostr_relay_publish_readiness(
    812                 config,
    813                 relay_ready,
    814                 signed_write_required,
    815                 account,
    816             );
    817             PublishRuntimeView {
    818                 transport: config.publish.transport.as_str().to_owned(),
    819                 source,
    820                 transport_family: config.publish.transport.transport_family().to_owned(),
    821                 state: state.to_owned(),
    822                 executable,
    823                 reason: reason.clone(),
    824                 signed_write_required,
    825                 relay,
    826                 provider: PublishProviderRuntimeView {
    827                     provider_runtime_id: "direct_nostr_relay".to_owned(),
    828                     state: state.to_owned(),
    829                     source: config.relay.source.as_str().to_owned(),
    830                     reason,
    831                 },
    832             }
    833         }
    834         PublishTransport::RadrootsdProxy => {
    835             let (state, executable, reason) = radrootsd_publish_readiness(config);
    836             PublishRuntimeView {
    837                 transport: config.publish.transport.as_str().to_owned(),
    838                 source,
    839                 transport_family: config.publish.transport.transport_family().to_owned(),
    840                 state: state.to_owned(),
    841                 executable,
    842                 reason: reason.clone(),
    843                 signed_write_required,
    844                 relay,
    845                 provider: PublishProviderRuntimeView {
    846                     provider_runtime_id: "radrootsd_proxy".to_owned(),
    847                     state: state.to_owned(),
    848                     source: "publish transport · local first".to_owned(),
    849                     reason,
    850                 },
    851             }
    852         }
    853     }
    854 }
    855 
    856 fn direct_nostr_relay_publish_readiness(
    857     config: &RuntimeConfig,
    858     relay_ready: bool,
    859     signed_write_required: bool,
    860     account: &AccountResolution,
    861 ) -> (&'static str, bool, Option<String>) {
    862     if !relay_ready {
    863         return (
    864             "unconfigured",
    865             false,
    866             Some(
    867                 "direct_nostr_relay publish transport requires at least one configured relay for writes"
    868                     .to_owned(),
    869             ),
    870         );
    871     }
    872 
    873     if !signed_write_required {
    874         return ("ready", true, None);
    875     }
    876 
    877     if matches!(config.signer.backend, SignerBackend::Myc) {
    878         let signer = resolve_signer_status(config);
    879         return if signer.state == "ready" {
    880             ("ready", true, None)
    881         } else {
    882             ("unconfigured", false, signer.reason)
    883         };
    884     }
    885 
    886     let Some(resolved_account) = account.resolved_account.as_ref() else {
    887         return (
    888             "unconfigured",
    889             false,
    890             Some(
    891                 "direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes"
    892                     .to_owned(),
    893             ),
    894         );
    895     };
    896 
    897     if !resolved_account.write_capable {
    898         return (
    899             "unconfigured",
    900             false,
    901             Some(
    902                 AccountRuntimeFailure::watch_only(&resolved_account.record.account_id).to_string(),
    903             ),
    904         );
    905     }
    906 
    907     ("ready", true, None)
    908 }
    909 
    910 fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, Option<String>) {
    911     if config.publish.radrootsd_proxy.token_file.is_none()
    912         && config.publish.radrootsd_proxy.token_secret_id.is_none()
    913     {
    914         return (
    915             "unconfigured",
    916             false,
    917             Some("radrootsd_proxy publish transport requires a configured token file or token secret id".to_owned()),
    918         );
    919     }
    920 
    921     if matches!(config.signer.backend, SignerBackend::Myc) {
    922         let signer = resolve_signer_status(config);
    923         return if signer.state == "ready" {
    924             ("ready", true, None)
    925         } else {
    926             ("unconfigured", false, signer.reason)
    927         };
    928     }
    929 
    930     ("ready", true, None)
    931 }
    932 
    933 fn signer_health_view(config: &RuntimeConfig, account: &AccountResolution) -> Value {
    934     match config.signer.backend {
    935         SignerBackend::Local => {
    936             let write_capable = account
    937                 .resolved_account
    938                 .as_ref()
    939                 .map(|account| account.write_capable)
    940                 .unwrap_or(false);
    941             json!({
    942                 "state": if write_capable { "ready" } else { "unconfigured" },
    943                 "backend": config.signer.backend.as_str(),
    944                 "write_capable_account": write_capable,
    945                 "reason": if write_capable {
    946                     Value::Null
    947                 } else {
    948                     json!("local signer requires a selected or default write-capable local account")
    949                 },
    950             })
    951         }
    952         SignerBackend::Myc => {
    953             let signer = resolve_signer_status(config);
    954             json!({
    955                 "state": signer.state,
    956                 "backend": config.signer.backend.as_str(),
    957                 "write_capable_account": signer.reason.is_none(),
    958                 "reason": signer.reason,
    959                 "binding_state": signer.binding.state,
    960             })
    961         }
    962     }
    963 }
    964 
    965 fn health_status_state(store_state: &str, publish: &PublishRuntimeView) -> &'static str {
    966     if store_state == "ready" && publish_runtime_ready(publish) {
    967         "ready"
    968     } else {
    969         "needs_attention"
    970     }
    971 }
    972 
    973 fn health_check_state(
    974     store_state: &str,
    975     account_ready: bool,
    976     publish: &PublishRuntimeView,
    977 ) -> &'static str {
    978     if store_state == "ready" && account_ready && publish_runtime_ready(publish) {
    979         "ready"
    980     } else {
    981         "needs_attention"
    982     }
    983 }
    984 
    985 fn publish_runtime_ready(publish: &PublishRuntimeView) -> bool {
    986     !publish.signed_write_required || publish.executable
    987 }
    988 
    989 fn health_actions(
    990     config: &RuntimeConfig,
    991     store_state: &str,
    992     account: &AccountResolution,
    993     publish: &PublishRuntimeView,
    994 ) -> Vec<String> {
    995     let mut actions = Vec::new();
    996     if store_state != "ready" {
    997         push_unique(&mut actions, "radroots store status get");
    998     }
    999     if let Some(resolved) = account.resolved_account.as_ref() {
   1000         if !resolved.write_capable {
   1001             push_unique(&mut actions, "radroots account attach-secret");
   1002         }
   1003     } else {
   1004         push_unique(&mut actions, "radroots account create");
   1005     }
   1006     for action in publish_recovery_actions(config, account, publish) {
   1007         push_unique(&mut actions, action);
   1008     }
   1009     actions
   1010 }
   1011 
   1012 fn config_actions(
   1013     config: &RuntimeConfig,
   1014     account: &AccountResolution,
   1015     publish: &PublishRuntimeView,
   1016 ) -> Vec<String> {
   1017     publish_recovery_actions(config, account, publish)
   1018 }
   1019 
   1020 fn publish_recovery_actions(
   1021     config: &RuntimeConfig,
   1022     account: &AccountResolution,
   1023     publish: &PublishRuntimeView,
   1024 ) -> Vec<String> {
   1025     if publish.state == "ready" {
   1026         return Vec::new();
   1027     }
   1028 
   1029     let mut actions = Vec::new();
   1030     match config.publish.transport {
   1031         PublishTransport::DirectNostrRelay => {
   1032             if config.relay.urls.is_empty() {
   1033                 push_unique(
   1034                     &mut actions,
   1035                     "radroots --relay wss://relay.example.com sync pull",
   1036                 );
   1037             }
   1038             if publish.signed_write_required {
   1039                 if matches!(config.signer.backend, SignerBackend::Myc) {
   1040                     push_unique(&mut actions, "radroots signer status get");
   1041                 } else if let Some(resolved) = account.resolved_account.as_ref() {
   1042                     if !resolved.write_capable {
   1043                         push_unique(&mut actions, "radroots account attach-secret");
   1044                     }
   1045                 } else {
   1046                     push_unique(&mut actions, "radroots account create");
   1047                 }
   1048             }
   1049         }
   1050         PublishTransport::RadrootsdProxy => {
   1051             if self::proxy_token_configured(config) {
   1052                 if publish.signed_write_required
   1053                     && matches!(config.signer.backend, SignerBackend::Myc)
   1054                 {
   1055                     push_unique(&mut actions, "radroots signer status get");
   1056                 }
   1057             } else {
   1058                 push_unique(
   1059                     &mut actions,
   1060                     "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID",
   1061                 );
   1062             }
   1063         }
   1064     }
   1065     actions
   1066 }
   1067 
   1068 fn proxy_token_configured(config: &RuntimeConfig) -> bool {
   1069     config.publish.radrootsd_proxy.token_file.is_some()
   1070         || config.publish.radrootsd_proxy.token_secret_id.is_some()
   1071 }
   1072 
   1073 fn push_unique(actions: &mut Vec<String>, action: impl Into<String>) {
   1074     let action = action.into();
   1075     if !actions.contains(&action) {
   1076         actions.push(action);
   1077     }
   1078 }
   1079 
   1080 fn required_string<P>(
   1081     request: &OperationRequest<P>,
   1082     key: &str,
   1083 ) -> Result<String, OperationAdapterError>
   1084 where
   1085     P: OperationRequestPayload + OperationRequestData,
   1086 {
   1087     string_input(request, key).ok_or_else(|| {
   1088         invalid_input(
   1089             request.operation_id(),
   1090             format!("missing required `{key}` input"),
   1091         )
   1092     })
   1093 }
   1094 
   1095 fn required_path<P>(
   1096     request: &OperationRequest<P>,
   1097     key: &str,
   1098 ) -> Result<PathBuf, OperationAdapterError>
   1099 where
   1100     P: OperationRequestPayload + OperationRequestData,
   1101 {
   1102     optional_path(request, key).ok_or_else(|| {
   1103         invalid_input(
   1104             request.operation_id(),
   1105             format!("missing required `{key}` input"),
   1106         )
   1107     })
   1108 }
   1109 
   1110 fn optional_path<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf>
   1111 where
   1112     P: OperationRequestPayload + OperationRequestData,
   1113 {
   1114     string_input(request, key).map(PathBuf::from)
   1115 }
   1116 
   1117 fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String>
   1118 where
   1119     P: OperationRequestPayload + OperationRequestData,
   1120 {
   1121     request
   1122         .payload
   1123         .input()
   1124         .get(key)
   1125         .and_then(Value::as_str)
   1126         .map(str::to_owned)
   1127 }
   1128 
   1129 fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool>
   1130 where
   1131     P: OperationRequestPayload + OperationRequestData,
   1132 {
   1133     request.payload.input().get(key).and_then(Value::as_bool)
   1134 }
   1135 
   1136 fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError {
   1137     OperationAdapterError::InvalidInput {
   1138         operation_id: operation_id.to_owned(),
   1139         message,
   1140     }
   1141 }
   1142 
   1143 #[cfg(test)]
   1144 mod tests {
   1145     use std::path::{Path, PathBuf};
   1146 
   1147     use radroots_runtime_paths::RadrootsMigrationReport;
   1148     use radroots_secret_vault::RadrootsSecretBackend;
   1149     use serde_json::{Map, Value};
   1150     use tempfile::tempdir;
   1151 
   1152     use super::CoreOperationService;
   1153     use crate::ops::{
   1154         AccountAttachSecretRequest, AccountCreateRequest, AccountImportRequest, AccountListRequest,
   1155         AccountRemoveRequest, OperationAdapter, OperationContext, OperationData, OperationRequest,
   1156         StoreStatusGetRequest, WorkspaceGetRequest,
   1157     };
   1158     use crate::runtime::config::{
   1159         AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
   1160         LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat,
   1161         PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig,
   1162         RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend,
   1163         SignerConfig, Verbosity,
   1164     };
   1165     use crate::runtime::logging::LoggingState;
   1166 
   1167     #[test]
   1168     fn core_service_envelopes_workspace_get() {
   1169         let dir = tempdir().expect("tempdir");
   1170         let config = sample_config(dir.path());
   1171         let logging = LoggingState {
   1172             initialized: true,
   1173             current_file: None,
   1174         };
   1175         let service = OperationAdapter::new(CoreOperationService::new(&config, &logging));
   1176         let request =
   1177             OperationRequest::new(OperationContext::default(), WorkspaceGetRequest::default())
   1178                 .expect("workspace request");
   1179         let result = service.execute(request).expect("workspace result");
   1180         let envelope = result
   1181             .to_envelope(OperationContext::default().envelope_context("req_workspace"))
   1182             .expect("workspace envelope");
   1183 
   1184         assert_eq!(envelope.operation_id, "workspace.get");
   1185         assert_eq!(envelope.kind, "workspace.get");
   1186         assert_eq!(envelope.request_id, "req_workspace");
   1187         assert_eq!(envelope.result["profile"], "interactive_user");
   1188         assert_eq!(
   1189             envelope.result["replica_db_path"],
   1190             config.local.replica_db_path.display().to_string()
   1191         );
   1192     }
   1193 
   1194     #[test]
   1195     fn core_service_backs_store_status() {
   1196         let dir = tempdir().expect("tempdir");
   1197         let config = sample_config(dir.path());
   1198         let logging = LoggingState {
   1199             initialized: false,
   1200             current_file: None,
   1201         };
   1202         let service = OperationAdapter::new(CoreOperationService::new(&config, &logging));
   1203         let request = OperationRequest::new(
   1204             OperationContext::default(),
   1205             StoreStatusGetRequest::default(),
   1206         )
   1207         .expect("store status request");
   1208         let result = service.execute(request).expect("store status result");
   1209         let envelope = result
   1210             .to_envelope(OperationContext::default().envelope_context("req_store"))
   1211             .expect("store status envelope");
   1212 
   1213         assert_eq!(envelope.operation_id, "store.status.get");
   1214         assert_eq!(envelope.result["state"], "ready");
   1215         assert_eq!(
   1216             envelope.result["source"],
   1217             "SDK canonical event store and outbox"
   1218         );
   1219         assert_eq!(envelope.result["canonical_store"], "sdk");
   1220         assert_eq!(envelope.result["sdk_storage"], "directory");
   1221         assert_eq!(
   1222             envelope.result["legacy_replica"]["source"],
   1223             "legacy local replica · derived/migration source"
   1224         );
   1225         assert_eq!(envelope.result["legacy_replica"]["state"], "unconfigured");
   1226         assert_eq!(
   1227             envelope.result["event_store"]["store"]["integrity_ok"],
   1228             true
   1229         );
   1230         assert_eq!(envelope.result["outbox"]["store"]["integrity_ok"], true);
   1231     }
   1232 
   1233     #[test]
   1234     fn core_service_backs_account_create_and_list() {
   1235         let dir = tempdir().expect("tempdir");
   1236         let config = sample_config(dir.path());
   1237         let logging = LoggingState {
   1238             initialized: false,
   1239             current_file: None,
   1240         };
   1241         let service = OperationAdapter::new(CoreOperationService::new(&config, &logging));
   1242         let create =
   1243             OperationRequest::new(OperationContext::default(), AccountCreateRequest::default())
   1244                 .expect("account create request");
   1245         let create_result = service.execute(create).expect("account create result");
   1246         let create_envelope = create_result
   1247             .to_envelope(OperationContext::default().envelope_context("req_create"))
   1248             .expect("account create envelope");
   1249 
   1250         assert_eq!(create_envelope.operation_id, "account.create");
   1251         assert_eq!(create_envelope.result["state"], "created");
   1252         assert!(create_envelope.result["account"]["id"].is_string());
   1253 
   1254         let list =
   1255             OperationRequest::new(OperationContext::default(), AccountListRequest::default())
   1256                 .expect("account list request");
   1257         let list_result = service.execute(list).expect("account list result");
   1258         let list_envelope = list_result
   1259             .to_envelope(OperationContext::default().envelope_context("req_list"))
   1260             .expect("account list envelope");
   1261 
   1262         assert_eq!(list_envelope.operation_id, "account.list");
   1263         assert_eq!(list_envelope.result["count"], 1);
   1264         assert_eq!(list_envelope.result["accounts"][0]["is_default"], true);
   1265     }
   1266 
   1267     #[test]
   1268     fn core_required_account_approvals_return_approval_error() {
   1269         let dir = tempdir().expect("tempdir");
   1270         let config = sample_config(dir.path());
   1271         let logging = LoggingState {
   1272             initialized: false,
   1273             current_file: None,
   1274         };
   1275         let service = OperationAdapter::new(CoreOperationService::new(&config, &logging));
   1276         let import = OperationRequest::new(
   1277             OperationContext::default(),
   1278             AccountImportRequest::from_data(data(&[("path", "account.json")])),
   1279         )
   1280         .expect("account import request");
   1281         let import_error = service.execute(import).expect_err("approval required");
   1282         assert_eq!(import_error.to_output_error().code, "approval_required");
   1283         assert_eq!(import_error.to_output_error().exit_code, 6);
   1284 
   1285         let attach_secret = OperationRequest::new(
   1286             OperationContext::default(),
   1287             AccountAttachSecretRequest::from_data(data(&[
   1288                 ("selector", "acct_test"),
   1289                 ("path", "account.json"),
   1290             ])),
   1291         )
   1292         .expect("account attach-secret request");
   1293         let attach_secret_error = service
   1294             .execute(attach_secret)
   1295             .expect_err("approval required");
   1296         assert_eq!(
   1297             attach_secret_error.to_output_error().code,
   1298             "approval_required"
   1299         );
   1300         assert_eq!(attach_secret_error.to_output_error().exit_code, 6);
   1301 
   1302         let remove = OperationRequest::new(
   1303             OperationContext::default(),
   1304             AccountRemoveRequest::from_data(data(&[("selector", "acct_test")])),
   1305         )
   1306         .expect("account remove request");
   1307         let remove_error = service.execute(remove).expect_err("approval required");
   1308         assert_eq!(remove_error.to_output_error().code, "approval_required");
   1309         assert_eq!(remove_error.to_output_error().exit_code, 6);
   1310     }
   1311 
   1312     fn sample_config(root: &Path) -> RuntimeConfig {
   1313         let data = root.join("data");
   1314         let logs = root.join("logs");
   1315         let secrets = root.join("secrets");
   1316         RuntimeConfig {
   1317             output: OutputConfig {
   1318                 format: OutputFormat::Human,
   1319                 verbosity: Verbosity::Normal,
   1320                 color: true,
   1321                 dry_run: false,
   1322             },
   1323             interaction: InteractionConfig {
   1324                 input_enabled: true,
   1325                 assume_yes: false,
   1326                 stdin_tty: false,
   1327                 stdout_tty: false,
   1328                 prompts_allowed: false,
   1329                 confirmations_allowed: false,
   1330             },
   1331             paths: PathsConfig {
   1332                 profile: "interactive_user".into(),
   1333                 profile_source: "test".into(),
   1334                 allowed_profiles: vec!["interactive_user".into(), "repo_local".into()],
   1335                 root_source: "test".into(),
   1336                 repo_local_root: None,
   1337                 repo_local_root_source: None,
   1338                 subordinate_path_override_source: "runtime_config".into(),
   1339                 app_namespace: "apps/cli".into(),
   1340                 shared_accounts_namespace: "shared/accounts".into(),
   1341                 shared_identities_namespace: "shared/identities".into(),
   1342                 app_config_path: root.join("config/apps/cli/config.toml"),
   1343                 workspace_config_path: None,
   1344                 app_data_root: data.join("apps/cli"),
   1345                 app_logs_root: logs.join("apps/cli"),
   1346                 shared_accounts_data_root: data.join("shared/accounts"),
   1347                 shared_accounts_secrets_root: secrets.join("shared/accounts"),
   1348                 default_identity_path: secrets.join("shared/identities/default.json"),
   1349             },
   1350             migration: MigrationConfig {
   1351                 report: RadrootsMigrationReport::empty(),
   1352             },
   1353             logging: LoggingConfig {
   1354                 filter: "info".into(),
   1355                 directory: None,
   1356                 stdout: false,
   1357             },
   1358             account: AccountConfig {
   1359                 selector: None,
   1360                 store_path: data.join("shared/accounts/store.json"),
   1361                 secrets_dir: secrets.join("shared/accounts"),
   1362                 secret_backend: RadrootsSecretBackend::EncryptedFile,
   1363                 secret_fallback: None,
   1364             },
   1365             account_secret_contract: AccountSecretContractConfig {
   1366                 default_backend: "host_vault".into(),
   1367                 default_fallback: Some("encrypted_file".into()),
   1368                 allowed_backends: vec!["host_vault".into(), "encrypted_file".into()],
   1369                 host_vault_policy: Some("desktop".into()),
   1370                 uses_protected_store: true,
   1371             },
   1372             identity: IdentityConfig {
   1373                 path: secrets.join("shared/identities/default.json"),
   1374             },
   1375             signer: SignerConfig {
   1376                 backend: SignerBackend::Local,
   1377             },
   1378             publish: PublishConfig {
   1379                 transport: PublishTransport::DirectNostrRelay,
   1380                 source: PublishTransportSource::Defaults,
   1381                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   1382             },
   1383             relay: RelayConfig {
   1384                 urls: Vec::new(),
   1385                 publish_policy: RelayPublishPolicy::Any,
   1386                 source: RelayConfigSource::Defaults,
   1387             },
   1388             local: LocalConfig {
   1389                 root: data.join("apps/cli/replica"),
   1390                 replica_db_path: data.join("apps/cli/replica/replica.sqlite"),
   1391                 backups_dir: data.join("apps/cli/replica/backups"),
   1392                 exports_dir: data.join("apps/cli/replica/exports"),
   1393             },
   1394             myc: MycConfig {
   1395                 executable: PathBuf::from("myc"),
   1396                 status_timeout_ms: 2_000,
   1397             },
   1398             hyf: HyfConfig {
   1399                 enabled: false,
   1400                 executable: PathBuf::from("hyfd"),
   1401             },
   1402             rpc: RpcConfig {
   1403                 url: "http://127.0.0.1:7070".into(),
   1404             },
   1405             rhi: crate::runtime::config::RhiConfig {
   1406                 trusted_worker_pubkeys: Vec::new(),
   1407             },
   1408             capability_bindings: Vec::new(),
   1409         }
   1410     }
   1411 
   1412     fn data(entries: &[(&str, &str)]) -> OperationData {
   1413         entries
   1414             .iter()
   1415             .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned())))
   1416             .collect::<Map<String, Value>>()
   1417     }
   1418 }