Skip to main content

vta_cli_common/commands/
services.rs

1//! `pnm services …` command implementations — unified CLI surface.
2//!
3//! Spec: `docs/05-design-notes/runtime-service-management.md` §5.1.
4//!
5//! Twelve commands across two transport kinds plus a top-level
6//! list/report. Each function calls the matching `vta-sdk` client
7//! method; the typed `VtaError` variants are surfaced via the
8//! existing CLI error renderer (`render::print_cli_error`) which
9//! attaches operator-actionable suggested-fix strings per
10//! CLAUDE.md.
11//!
12//! The retired `pnm mediator …` subcommand surface is replaced by
13//! `pnm services didcomm {update,rollback,drain {list,cancel}}` —
14//! see the migration cue in pnm-cli/cnm-cli for the
15//! retired-command UX.
16
17use vta_sdk::client::VtaClient;
18use vta_sdk::protocol::services::{
19    DisableRestRequest, EnableRestRequest, RollbackDidcommRequest, RollbackRestRequest,
20    UpdateRestRequest,
21};
22use vta_sdk::protocol::{DisableDidcommRequest, EnableDidcommRequest, UpdateDidcommRequest};
23
24// ── services list ──────────────────────────────────────────────────
25
26/// `pnm services list` — show current REST + DIDComm advertisements.
27pub async fn cmd_services_list(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
28    let response = client.list_services().await?;
29
30    println!("Services advertised on this VTA's DID document:");
31    println!();
32    for state in &response.services {
33        match state {
34            vta_sdk::protocol::services::ServiceState::Didcomm {
35                enabled,
36                mediator_did,
37                routing_keys,
38            } => {
39                let on = if *enabled { "on" } else { "off" };
40                println!("  DIDComm:  {on}");
41                if let Some(m) = mediator_did {
42                    println!("    Mediator:     {m}");
43                }
44                if !routing_keys.is_empty() {
45                    println!("    Routing keys: {}", routing_keys.join(", "));
46                }
47            }
48            vta_sdk::protocol::services::ServiceState::Rest { enabled, url } => {
49                let on = if *enabled { "on" } else { "off" };
50                println!("  REST:     {on}");
51                if let Some(u) = url {
52                    println!("    URL:          {u}");
53                }
54            }
55            vta_sdk::protocol::services::ServiceState::Webauthn { enabled, url } => {
56                let on = if *enabled { "on" } else { "off" };
57                println!("  WebAuthn: {on}");
58                if let Some(u) = url {
59                    println!("    URL:          {u}");
60                }
61            }
62        }
63    }
64    Ok(())
65}
66
67// ── services rest {enable, update, disable, rollback} ─────────────
68
69pub async fn cmd_services_rest_enable(
70    client: &VtaClient,
71    url: String,
72) -> Result<(), Box<dyn std::error::Error>> {
73    let req = EnableRestRequest::new(url);
74    let resp = client.enable_rest(req).await?;
75    println!("REST enabled.");
76    println!("  New version ID: {}", resp.log_entry_version_id);
77    println!("  Effective at:   {}", resp.effective_at);
78    print_serverless_hint(resp.serverless, &resp.vta_did);
79    Ok(())
80}
81
82pub async fn cmd_services_rest_update(
83    client: &VtaClient,
84    url: String,
85) -> Result<(), Box<dyn std::error::Error>> {
86    let req = UpdateRestRequest::new(url);
87    let resp = client.update_rest(req).await?;
88    println!("REST URL updated.");
89    println!("  New version ID: {}", resp.log_entry_version_id);
90    println!("  Effective at:   {}", resp.effective_at);
91    print_serverless_hint(resp.serverless, &resp.vta_did);
92    Ok(())
93}
94
95pub async fn cmd_services_rest_disable(
96    client: &VtaClient,
97) -> Result<(), Box<dyn std::error::Error>> {
98    let resp = client.disable_rest(DisableRestRequest::default()).await?;
99    println!("REST disabled.");
100    println!("  New version ID: {}", resp.log_entry_version_id);
101    println!("  Effective at:   {}", resp.effective_at);
102    print_serverless_hint(resp.serverless, &resp.vta_did);
103    Ok(())
104}
105
106pub async fn cmd_services_rest_rollback(
107    client: &VtaClient,
108) -> Result<(), Box<dyn std::error::Error>> {
109    let resp = client.rollback_rest(RollbackRestRequest::default()).await?;
110    print_rollback_result("REST", &resp);
111    Ok(())
112}
113
114// ── services webauthn {enable, update, disable, rollback} ─────────
115
116pub async fn cmd_services_webauthn_enable(
117    client: &VtaClient,
118    url: String,
119) -> Result<(), Box<dyn std::error::Error>> {
120    let req = vta_sdk::protocol::services::EnableWebauthnRequest::new(url);
121    let resp = client.enable_webauthn(req).await?;
122    println!("WebAuthn enabled.");
123    println!("  New version ID: {}", resp.log_entry_version_id);
124    println!("  Effective at:   {}", resp.effective_at);
125    print_serverless_hint(resp.serverless, &resp.vta_did);
126    Ok(())
127}
128
129pub async fn cmd_services_webauthn_update(
130    client: &VtaClient,
131    url: String,
132) -> Result<(), Box<dyn std::error::Error>> {
133    let req = vta_sdk::protocol::services::UpdateWebauthnRequest::new(url);
134    let resp = client.update_webauthn(req).await?;
135    println!("WebAuthn URL updated.");
136    println!("  New version ID: {}", resp.log_entry_version_id);
137    println!("  Effective at:   {}", resp.effective_at);
138    print_serverless_hint(resp.serverless, &resp.vta_did);
139    Ok(())
140}
141
142pub async fn cmd_services_webauthn_disable(
143    client: &VtaClient,
144) -> Result<(), Box<dyn std::error::Error>> {
145    eprintln!(
146        "WARNING: disabling WebAuthn will also REMOVE passkey verificationMethods from every DID \
147         this VTA controls. Any operator currently using passkey login will need to re-enrol \
148         after the next `services webauthn enable`."
149    );
150    let resp = client
151        .disable_webauthn(vta_sdk::protocol::services::DisableWebauthnRequest::default())
152        .await?;
153    println!("WebAuthn disabled.");
154    println!("  New version ID: {}", resp.log_entry_version_id);
155    println!("  Effective at:   {}", resp.effective_at);
156    print_serverless_hint(resp.serverless, &resp.vta_did);
157    Ok(())
158}
159
160pub async fn cmd_services_webauthn_rollback(
161    client: &VtaClient,
162) -> Result<(), Box<dyn std::error::Error>> {
163    let resp = client
164        .rollback_webauthn(vta_sdk::protocol::services::RollbackWebauthnRequest::default())
165        .await?;
166    print_rollback_result("WebAuthn", &resp);
167    Ok(())
168}
169
170// ── services didcomm {enable, update, disable, rollback} ──────────
171
172pub async fn cmd_services_didcomm_enable(
173    client: &VtaClient,
174    mediator_did: String,
175    force: bool,
176    handshake_timeout_secs: Option<u64>,
177) -> Result<(), Box<dyn std::error::Error>> {
178    let mut req = EnableDidcommRequest::new(&mediator_did);
179    req.force = force;
180    req.handshake_timeout_secs = handshake_timeout_secs;
181    let resp = client.enable_didcomm(req).await?;
182    println!("DIDComm enabled.");
183    println!("  Mediator DID:   {}", resp.mediator_did);
184    if !resp.mediator_endpoint.is_empty() {
185        println!("  Mediator URL:   {}", resp.mediator_endpoint);
186    }
187    println!("  New version ID: {}", resp.new_version_id);
188    if force {
189        println!();
190        println!("  Note: --force was set; mediator handshake steps 2-5 were bypassed.");
191    }
192    print_serverless_hint(resp.serverless, &resp.vta_did);
193    Ok(())
194}
195
196pub async fn cmd_services_didcomm_update(
197    client: &VtaClient,
198    new_mediator_did: String,
199    drain_ttl_secs: u64,
200    force: bool,
201    handshake_timeout_secs: Option<u64>,
202) -> Result<(), Box<dyn std::error::Error>> {
203    let mut req = UpdateDidcommRequest::new(&new_mediator_did, drain_ttl_secs);
204    req.force = force;
205    req.handshake_timeout_secs = handshake_timeout_secs;
206    let resp = client.update_didcomm(req).await?;
207    println!("DIDComm mediator updated.");
208    println!("  Prior mediator:  {}", resp.prior_mediator_did);
209    println!("  Active mediator: {}", resp.active_mediator_did);
210    if !resp.active_mediator_endpoint.is_empty() {
211        println!("  Active endpoint: {}", resp.active_mediator_endpoint);
212    }
213    println!("  New version ID:  {}", resp.new_version_id);
214    println!(
215        "  Drain deadline:  {} (prior listener stays up until then)",
216        resp.drains_until
217    );
218    print_serverless_hint(resp.serverless, &resp.vta_did);
219    Ok(())
220}
221
222pub async fn cmd_services_didcomm_disable(
223    client: &VtaClient,
224    drain_ttl_secs: u64,
225) -> Result<(), Box<dyn std::error::Error>> {
226    let req = DisableDidcommRequest::new(drain_ttl_secs);
227    let resp = client.disable_didcomm(req).await?;
228    println!("DIDComm disabled.");
229    println!("  Prior mediator: {}", resp.prior_mediator_did);
230    println!("  New version ID: {}", resp.new_version_id);
231    match resp.drains_until {
232        Some(deadline) => {
233            println!("  Drain deadline: {deadline}");
234            println!();
235            println!("  The listener stays up until the deadline so in-flight messages can drain.");
236            println!(
237                "  Cancel early with `pnm services didcomm drain cancel --mediator-did <did>`."
238            );
239        }
240        None => println!("  Listener torn down immediately (drain TTL was 0)."),
241    }
242    print_serverless_hint(resp.serverless, &resp.vta_did);
243    Ok(())
244}
245
246pub async fn cmd_services_didcomm_rollback(
247    client: &VtaClient,
248    drain_ttl_secs: Option<u64>,
249) -> Result<(), Box<dyn std::error::Error>> {
250    let req = RollbackDidcommRequest { drain_ttl_secs };
251    let resp = client.rollback_didcomm(req).await?;
252    print_rollback_result("DIDComm", &resp);
253    Ok(())
254}
255
256// ── services didcomm drain {list, cancel} ─────────────────────────
257
258pub async fn cmd_services_didcomm_drain_list(
259    client: &VtaClient,
260) -> Result<(), Box<dyn std::error::Error>> {
261    let resp = client.list_drain().await?;
262    if resp.entries.is_empty() {
263        println!("No mediators currently in drain.");
264        return Ok(());
265    }
266    println!("Drain set ({} mediator(s)):", resp.entries.len());
267    println!();
268    let header_did = "MEDIATOR DID";
269    let header_until = "DRAIN UNTIL";
270    println!("  {header_did:<60}  {header_until}");
271    for e in &resp.entries {
272        println!(
273            "  {:<60}  {}",
274            truncate(&e.mediator_did, 60),
275            e.drains_until
276        );
277    }
278    Ok(())
279}
280
281pub async fn cmd_services_didcomm_drain_cancel(
282    client: &VtaClient,
283    mediator_did: String,
284) -> Result<(), Box<dyn std::error::Error>> {
285    let req = vta_sdk::protocol::DrainCancelRequest { mediator_did };
286    let resp = client.drain_cancel(req).await?;
287    println!("Drain cancelled for {}.", resp.mediator_did);
288    println!("  Listener was torn down immediately.");
289    Ok(())
290}
291
292// ── services report ───────────────────────────────────────────────
293
294pub async fn cmd_services_report(
295    client: &VtaClient,
296    since: Option<String>,
297    until: Option<String>,
298    format: ReportFormat,
299) -> Result<(), Box<dyn std::error::Error>> {
300    let report = client
301        .mediator_report(since.as_deref(), until.as_deref())
302        .await?;
303
304    match format {
305        ReportFormat::Json => {
306            println!("{}", serde_json::to_string_pretty(&report)?);
307        }
308        ReportFormat::Table => {
309            println!("Service-management report");
310            if let Some(ref s) = report.since {
311                println!("  Window: {s} → {}", report.until);
312            } else {
313                println!("  Window: (all time) → {}", report.until);
314            }
315            println!();
316            if report.mediators.is_empty() {
317                println!("  No inbound DIDComm messages recorded.");
318            } else {
319                println!("  Per-mediator inbound counts (most recent first):");
320                let header_did = "MEDIATOR DID";
321                let header_count = "INBOUND";
322                println!("    {header_did:<60}  {header_count:>10}  LAST SEEN");
323                for m in &report.mediators {
324                    println!(
325                        "    {:<60}  {:>10}  {}",
326                        truncate(&m.mediator_did, 60),
327                        m.inbound_count,
328                        m.last_seen
329                    );
330                }
331            }
332            if !report.senders.is_empty() {
333                println!();
334                println!("  Senders by last-seen mediator:");
335                for s in &report.senders {
336                    println!(
337                        "    {} → {} (at {})",
338                        truncate(&s.sender_did, 50),
339                        truncate(&s.last_seen_mediator, 50),
340                        s.last_seen_at
341                    );
342                }
343            }
344        }
345    }
346    Ok(())
347}
348
349// ── shared helpers ────────────────────────────────────────────────
350
351fn print_rollback_result(kind: &str, resp: &vta_sdk::protocol::services::RollbackResponse) {
352    if resp.kind == "no_op" {
353        println!("{kind} rollback: no change required.");
354        println!("  Snapshot matches current state — nothing to do.");
355        return;
356    }
357    println!("{kind} rolled back.");
358    println!("  Action:         {}", resp.kind);
359    if !resp.log_entry_version_id.is_empty() {
360        println!("  New version ID: {}", resp.log_entry_version_id);
361    }
362    println!("  Effective at:   {}", resp.effective_at);
363    if let Some(ref drain_until) = resp.drain_until {
364        println!("  Drain deadline: {drain_until}");
365    }
366    if let Some(ref draining) = resp.draining_mediator {
367        println!("  Draining:       {draining}");
368    }
369    print_serverless_hint(resp.serverless, &resp.vta_did);
370}
371
372/// Print the "fetch did.jsonl + redeploy" hint when the mutation
373/// just wrote a LogEntry to a self-hosted VTA DID.
374///
375/// Silent when `serverless` is false (the VTA published to a host
376/// as part of the call — no follow-up needed) and when `vta_did`
377/// is empty (no LogEntry was written, e.g. no-op rollback).
378///
379/// Suffix is two operator-actionable lines: the command and the
380/// reason. Operators running scripted updates will see the line
381/// every time on serverless deployments — that's intentional,
382/// since the alternative is stale resolvers without an obvious
383/// cause.
384pub fn print_serverless_hint(serverless: bool, vta_did: &str) {
385    if !serverless || vta_did.is_empty() {
386        return;
387    }
388    println!();
389    println!("  This VTA's DID is self-hosted. Fetch the updated log:");
390    println!("    pnm did-mgmt dids get-log {vta_did} --out did.jsonl");
391    println!("  then redeploy did.jsonl to your host. Until you do,");
392    println!("  resolvers will keep returning the prior version.");
393}
394
395#[derive(Debug, Clone, Copy)]
396pub enum ReportFormat {
397    Json,
398    Table,
399}
400
401impl std::str::FromStr for ReportFormat {
402    type Err = String;
403    fn from_str(s: &str) -> Result<Self, Self::Err> {
404        match s {
405            "json" => Ok(Self::Json),
406            "table" => Ok(Self::Table),
407            other => Err(format!("unknown format `{other}` — use `json` or `table`")),
408        }
409    }
410}
411
412fn truncate(s: &str, max: usize) -> String {
413    if s.chars().count() <= max {
414        s.to_string()
415    } else {
416        let mut out: String = s.chars().take(max - 1).collect();
417        out.push('…');
418        out
419    }
420}