Headless Rust gRPC daemon to drive Bluetooth, HLS/DASH playback, and snapcast/shairport-sync/squeezelite on Raspberry Pi audio rigs.
1//! zerod — one binary, two modes.
2//!
3//! `zerod` (no args) → run as a gRPC server (reads zerod.toml).
4//! `zerod serve …` → explicit server invocation.
5//! `zerod bluetooth scan` → client subcommands. With no --host, the
6//! `zerod stream play …` client browses mDNS (`_zerod._tcp.local.`)
7//! `zerod systemd start …` and connects to the only responder. Use
8//! `zerod config get …` --name to disambiguate when several reply,
9//! `zerod discover` or --host to bypass discovery entirely.
10
11use anyhow::{anyhow, Context, Result};
12use clap::builder::styling::{Color, RgbColor, Style, Styles};
13use clap::{Parser, Subcommand};
14use std::path::PathBuf;
15use std::time::Duration;
16use tonic::metadata::MetadataValue;
17use tonic::transport::Channel;
18use tonic::Request;
19use zerod_proto::v1alpha1 as pb;
20
21mod service;
22
23// Charm-inspired neon violet palette for the clap help menu.
24// #7D56F4 — Charm signature purple-violet-blue (headers, usage line)
25// #A78BFA — bright violet (commands, flags, valid values)
26// #C4B5FD — soft lavender (placeholders / value names)
27// #FF5C8A — pink-red (errors, invalid values)
28const CHARM_VIOLET: Color = Color::Rgb(RgbColor(0x7D, 0x56, 0xF4));
29const NEON_VIOLET: Color = Color::Rgb(RgbColor(0xA7, 0x8B, 0xFA));
30const SOFT_LAVENDER: Color = Color::Rgb(RgbColor(0xC4, 0xB5, 0xFD));
31const NEON_PINK_ERR: Color = Color::Rgb(RgbColor(0xFF, 0x5C, 0x8A));
32
33const STYLES: Styles = Styles::styled()
34 .header(Style::new().bold().underline().fg_color(Some(CHARM_VIOLET)))
35 .usage(Style::new().bold().fg_color(Some(CHARM_VIOLET)))
36 .literal(Style::new().bold().fg_color(Some(NEON_VIOLET)))
37 .placeholder(Style::new().fg_color(Some(SOFT_LAVENDER)))
38 .valid(Style::new().bold().fg_color(Some(NEON_VIOLET)))
39 .invalid(Style::new().bold().fg_color(Some(NEON_PINK_ERR)))
40 .error(Style::new().bold().fg_color(Some(NEON_PINK_ERR)));
41
42#[derive(Parser)]
43#[command(
44 name = "zerod",
45 version,
46 about = "headless audio/bluetooth/systemd control daemon",
47 styles = STYLES,
48)]
49struct Cli {
50 /// Path to zerod.toml. Server mode only.
51 #[arg(long, global = true)]
52 config: Option<PathBuf>,
53
54 /// Client mode: target server host. When omitted, discovers a server via
55 /// mDNS on the LAN. Ignored for `serve`.
56 #[arg(long, global = true, env = "ZEROD_HOST")]
57 host: Option<String>,
58
59 /// Client mode: target server port. Ignored for `serve` and when --host
60 /// is omitted (the discovered port is used).
61 #[arg(long, global = true, env = "ZEROD_PORT", default_value_t = 50151)]
62 port: u16,
63
64 /// Client mode: bearer token to send.
65 #[arg(long, global = true, env = "ZEROD_BEARER_TOKEN")]
66 bearer_token: Option<String>,
67
68 /// Client mode: when discovering via mDNS, pick the responder whose
69 /// instance name matches this exactly. Has no effect when --host is set.
70 #[arg(long, global = true, env = "ZEROD_NAME")]
71 name: Option<String>,
72
73 /// Client mode: how long to browse mDNS before giving up (milliseconds).
74 #[arg(long, global = true, env = "ZEROD_DISCOVER_TIMEOUT_MS", default_value_t = 1500)]
75 discover_timeout_ms: u64,
76
77 #[command(subcommand)]
78 command: Option<Command>,
79}
80
81#[derive(Subcommand)]
82enum Command {
83 /// Run as a gRPC server (default when no subcommand is given).
84 Serve,
85 /// Bluetooth control (client).
86 #[command(subcommand)]
87 Bluetooth(BluetoothCmd),
88 /// HLS/DASH stream control (client).
89 #[command(subcommand)]
90 Stream(StreamCmd),
91 /// Systemd unit control (client).
92 #[command(subcommand)]
93 Systemd(SystemdCmd),
94 /// Managed config files (client).
95 #[command(subcommand)]
96 Config(ConfigCmd),
97 /// Server version / health (client).
98 #[command(subcommand)]
99 System(SystemCmd),
100 /// System volume control via ALSA mixer (client).
101 #[command(subcommand)]
102 Volume(VolumeCmd),
103 /// Manage zerod's own systemd --user unit (Linux only).
104 #[command(subcommand)]
105 Service(ServiceCmd),
106 /// Browse mDNS for zerod servers on the LAN.
107 Discover,
108}
109
110#[derive(Subcommand)]
111enum ServiceCmd {
112 /// Write ~/.config/systemd/user/zerod.service pointing at this binary.
113 /// A bearer token is pinned into the unit so it doesn't change on every
114 /// restart — pass --token to choose one, otherwise a random 32-byte hex
115 /// token is generated. Re-running with --force rotates the token unless
116 /// --token is supplied.
117 Install {
118 /// Overwrite an existing unit file (rotates the bearer token unless --token is given).
119 #[arg(long)]
120 force: bool,
121 /// Pin this exact bearer token into the unit instead of generating a random one.
122 #[arg(long)]
123 token: Option<String>,
124 },
125 /// Remove ~/.config/systemd/user/zerod.service.
126 Uninstall,
127 /// Print where the unit file would be installed.
128 Path,
129}
130
131#[derive(Subcommand)]
132enum BluetoothCmd {
133 Scan {
134 #[arg(long, default_value_t = 10)]
135 timeout_secs: u32,
136 },
137 List,
138 Pair { address: String },
139 Connect { address: String },
140 Disconnect { address: String },
141 Remove { address: String },
142}
143
144#[derive(Subcommand)]
145enum StreamCmd {
146 Play {
147 url: String,
148 #[arg(long, value_enum, default_value_t = OutputArg::Cpal)]
149 output: OutputArg,
150 #[arg(long)]
151 pipe_path: Option<String>,
152 },
153 Pause,
154 Resume,
155 Stop,
156 Status,
157 /// Per-stream software gain (0..=100), independent of the system mixer.
158 #[command(subcommand)]
159 Volume(StreamVolumeCmd),
160}
161
162#[derive(Subcommand)]
163enum StreamVolumeCmd {
164 Get,
165 Set { percent: u32 },
166}
167
168#[derive(Subcommand)]
169enum VolumeCmd {
170 /// List ALSA selems on a card (default card if --card omitted).
171 List {
172 #[arg(long)]
173 card: Option<String>,
174 },
175 Get {
176 #[arg(long)]
177 card: Option<String>,
178 #[arg(long)]
179 control: Option<String>,
180 #[arg(long, default_value_t = 0)]
181 index: u32,
182 },
183 Set {
184 percent: u32,
185 #[arg(long)]
186 card: Option<String>,
187 #[arg(long)]
188 control: Option<String>,
189 #[arg(long, default_value_t = 0)]
190 index: u32,
191 },
192 Mute {
193 #[arg(long)]
194 card: Option<String>,
195 #[arg(long)]
196 control: Option<String>,
197 #[arg(long, default_value_t = 0)]
198 index: u32,
199 },
200 Unmute {
201 #[arg(long)]
202 card: Option<String>,
203 #[arg(long)]
204 control: Option<String>,
205 #[arg(long, default_value_t = 0)]
206 index: u32,
207 },
208}
209
210#[derive(Clone, Copy, clap::ValueEnum)]
211enum OutputArg {
212 Cpal,
213 Stdout,
214 Pipe,
215}
216
217#[derive(Subcommand)]
218enum SystemdCmd {
219 List,
220 Status { name: String },
221 Start { name: String },
222 Stop { name: String },
223 Restart { name: String },
224 Reload { name: String },
225 Enable { name: String },
226 Disable { name: String },
227}
228
229#[derive(Subcommand)]
230enum ConfigCmd {
231 List,
232 Get {
233 key: String,
234 },
235 /// Write the contents of a local file as the new config and optionally
236 /// reload/restart the bound unit.
237 Put {
238 key: String,
239 file: PathBuf,
240 #[arg(long, value_enum, default_value_t = ActionArg::None)]
241 action: ActionArg,
242 },
243}
244
245#[derive(Clone, Copy, clap::ValueEnum)]
246enum ActionArg {
247 None,
248 Reload,
249 Restart,
250}
251
252#[derive(Subcommand)]
253enum SystemCmd {
254 Version,
255 Health,
256}
257
258#[tokio::main]
259async fn main() -> Result<()> {
260 tracing_subscriber::fmt()
261 .with_env_filter(
262 tracing_subscriber::EnvFilter::try_from_default_env()
263 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
264 )
265 .init();
266 let cli = Cli::parse();
267 let Cli {
268 config,
269 host,
270 port,
271 bearer_token,
272 name,
273 discover_timeout_ms,
274 command,
275 } = cli;
276 let discover_timeout = Duration::from_millis(discover_timeout_ms);
277 let needs_endpoint = !matches!(
278 command,
279 None | Some(Command::Serve) | Some(Command::Service(_)) | Some(Command::Discover)
280 );
281 let endpoint = if needs_endpoint {
282 Some(resolve_endpoint(host.as_deref(), port, name.as_deref(), discover_timeout)?)
283 } else {
284 None
285 };
286 match command {
287 None | Some(Command::Serve) => run_server(config.as_deref()).await,
288 Some(Command::Service(cmd)) => run_service(cmd),
289 Some(Command::Discover) => run_discover(discover_timeout),
290 Some(Command::Bluetooth(cmd)) => run_bluetooth(&endpoint.unwrap(), bearer_token, cmd).await,
291 Some(Command::Stream(cmd)) => run_stream(&endpoint.unwrap(), bearer_token, cmd).await,
292 Some(Command::Systemd(cmd)) => run_systemd(&endpoint.unwrap(), bearer_token, cmd).await,
293 Some(Command::Config(cmd)) => run_config(&endpoint.unwrap(), bearer_token, cmd).await,
294 Some(Command::System(cmd)) => run_system(&endpoint.unwrap(), bearer_token, cmd).await,
295 Some(Command::Volume(cmd)) => run_volume(&endpoint.unwrap(), bearer_token, cmd).await,
296 }
297}
298
299struct Endpoint {
300 host: String,
301 port: u16,
302}
303
304fn resolve_endpoint(
305 host: Option<&str>,
306 port: u16,
307 name: Option<&str>,
308 timeout: Duration,
309) -> Result<Endpoint> {
310 if let Some(h) = host.filter(|s| !s.is_empty()) {
311 return Ok(Endpoint { host: h.to_string(), port });
312 }
313 let mut found = zerod_discovery::discover(timeout).context("mDNS browse")?;
314 if let Some(n) = name.filter(|s| !s.is_empty()) {
315 found.retain(|d| d.name == n);
316 }
317 match found.len() {
318 0 => Err(anyhow!(
319 "no zerod server found on the LAN within {}ms. \
320 Pass --host explicitly, or check that the daemon is running with [mdns] enabled.",
321 timeout.as_millis(),
322 )),
323 1 => {
324 let d = found.remove(0);
325 let h = d.best_host().ok_or_else(|| {
326 anyhow!("discovered server {:?} has no usable IPv4 address", d.name)
327 })?;
328 tracing::info!("mDNS: using {} ({}:{})", d.name, h, d.port);
329 Ok(Endpoint { host: h, port: d.port })
330 }
331 _ => {
332 let mut msg = String::from(
333 "multiple zerod servers responded. Re-run with --name <name> to pick one:\n",
334 );
335 for d in &found {
336 let h = d.best_host().unwrap_or_else(|| "?".to_string());
337 msg.push_str(&format!(" {:30} {}:{}\n", d.name, h, d.port));
338 }
339 Err(anyhow!(msg))
340 }
341 }
342}
343
344fn run_discover(timeout: Duration) -> Result<()> {
345 let found = zerod_discovery::discover(timeout).context("mDNS browse")?;
346 if found.is_empty() {
347 println!("no zerod servers found on the LAN within {}ms", timeout.as_millis());
348 return Ok(());
349 }
350 for d in &found {
351 let host = d.best_host().unwrap_or_else(|| "?".to_string());
352 let version = d
353 .properties
354 .get("version")
355 .map(String::as_str)
356 .unwrap_or("?");
357 println!("{:30} {}:{} version={}", d.name, host, d.port, version);
358 }
359 Ok(())
360}
361
362fn run_service(cmd: ServiceCmd) -> Result<()> {
363 match cmd {
364 ServiceCmd::Install { force, token } => {
365 let installed = service::install(force, token)?;
366 println!("Wrote {}", installed.path.display());
367 println!();
368 println!("Bearer token (pinned in the unit, mode 0600):");
369 println!(" {}", installed.token);
370 println!();
371 println!("Next steps:");
372 println!(" systemctl --user daemon-reload");
373 println!(" systemctl --user enable --now zerod.service");
374 println!();
375 println!("Then check it's healthy:");
376 println!(" systemctl --user status zerod.service");
377 println!(" journalctl --user -u zerod.service -f");
378 println!();
379 println!("So the service survives logout / starts on boot:");
380 println!(" sudo loginctl enable-linger \"$USER\"");
381 println!();
382 println!("Use the token from any client (auto-discovers via mDNS):");
383 println!(" export ZEROD_BEARER_TOKEN={}", installed.token);
384 println!(" zerod system health");
385 println!();
386 println!("Or list every responder on the LAN:");
387 println!(" zerod discover");
388 Ok(())
389 }
390 ServiceCmd::Uninstall => match service::uninstall()? {
391 Some(path) => {
392 println!("Removed {}", path.display());
393 println!();
394 println!("Tell systemd to forget it:");
395 println!(" systemctl --user disable --now zerod.service");
396 println!(" systemctl --user daemon-reload");
397 Ok(())
398 }
399 None => {
400 println!("No unit file installed at {}", service::unit_path()?.display());
401 Ok(())
402 }
403 },
404 ServiceCmd::Path => {
405 println!("{}", service::unit_path()?.display());
406 Ok(())
407 }
408 }
409}
410
411async fn run_server(config: Option<&std::path::Path>) -> Result<()> {
412 let settings = zerod_server::load_settings(config)?;
413 zerod_server::serve(settings).await
414}
415
416// --- client helpers ---------------------------------------------------------
417
418async fn channel(ep: &Endpoint) -> Result<Channel> {
419 let url = format!("http://{}:{}", ep.host, ep.port);
420 Channel::from_shared(url.clone())
421 .with_context(|| format!("invalid endpoint {url}"))?
422 .connect()
423 .await
424 .with_context(|| format!("connect {url}"))
425}
426
427fn attach_token<T>(mut req: Request<T>, token: &Option<String>) -> Request<T> {
428 if let Some(t) = token.as_deref().filter(|s| !s.is_empty()) {
429 let v: MetadataValue<_> = format!("Bearer {t}").parse().expect("bearer token must be ascii");
430 req.metadata_mut().insert("authorization", v);
431 }
432 req
433}
434
435fn print_json(v: &serde_json::Value) -> Result<()> {
436 println!("{}", serde_json::to_string_pretty(v)?);
437 Ok(())
438}
439
440// --- bluetooth -------------------------------------------------------------
441
442async fn run_bluetooth(ep: &Endpoint, token: Option<String>, cmd: BluetoothCmd) -> Result<()> {
443 let ch = channel(ep).await?;
444 let mut client = pb::bluetooth_service_client::BluetoothServiceClient::new(ch);
445 match cmd {
446 BluetoothCmd::Scan { timeout_secs } => {
447 let r = client
448 .scan(attach_token(Request::new(pb::ScanRequest { timeout_secs }), &token))
449 .await?
450 .into_inner();
451 for d in r.devices {
452 println!(
453 "{} {:30} paired={} trusted={} connected={} rssi={:?}",
454 d.address, d.name, d.paired, d.trusted, d.connected, d.rssi
455 );
456 }
457 }
458 BluetoothCmd::List => {
459 let r = client
460 .list_devices(attach_token(Request::new(pb::ListDevicesRequest {}), &token))
461 .await?
462 .into_inner();
463 for d in r.devices {
464 println!(
465 "{} {:30} paired={} trusted={} connected={} rssi={:?}",
466 d.address, d.name, d.paired, d.trusted, d.connected, d.rssi
467 );
468 }
469 }
470 BluetoothCmd::Pair { address } => {
471 client.pair(attach_token(Request::new(pb::PairRequest { address }), &token)).await?;
472 println!("ok");
473 }
474 BluetoothCmd::Connect { address } => {
475 client.connect_device(attach_token(Request::new(pb::ConnectDeviceRequest { address }), &token)).await?;
476 println!("ok");
477 }
478 BluetoothCmd::Disconnect { address } => {
479 client.disconnect(attach_token(Request::new(pb::DisconnectRequest { address }), &token)).await?;
480 println!("ok");
481 }
482 BluetoothCmd::Remove { address } => {
483 client.remove(attach_token(Request::new(pb::RemoveRequest { address }), &token)).await?;
484 println!("ok");
485 }
486 }
487 Ok(())
488}
489
490// --- stream ----------------------------------------------------------------
491
492async fn run_stream(ep: &Endpoint, token: Option<String>, cmd: StreamCmd) -> Result<()> {
493 let ch = channel(ep).await?;
494 let mut client = pb::stream_service_client::StreamServiceClient::new(ch);
495 match cmd {
496 StreamCmd::Play { url, output, pipe_path } => {
497 let output = match output {
498 OutputArg::Cpal => pb::AudioOutput::Cpal,
499 OutputArg::Stdout => pb::AudioOutput::Stdout,
500 OutputArg::Pipe => pb::AudioOutput::Pipe,
501 } as i32;
502 client
503 .play(attach_token(
504 Request::new(pb::PlayRequest {
505 url,
506 output,
507 pipe_path,
508 cpal_device: None,
509 }),
510 &token,
511 ))
512 .await?;
513 println!("ok");
514 }
515 StreamCmd::Pause => {
516 client.pause(attach_token(Request::new(pb::PauseRequest {}), &token)).await?;
517 println!("ok");
518 }
519 StreamCmd::Resume => {
520 client.resume(attach_token(Request::new(pb::ResumeRequest {}), &token)).await?;
521 println!("ok");
522 }
523 StreamCmd::Stop => {
524 client.stop(attach_token(Request::new(pb::StopRequest {}), &token)).await?;
525 println!("ok");
526 }
527 StreamCmd::Status => {
528 let r = client
529 .status(attach_token(Request::new(pb::StatusRequest {}), &token))
530 .await?
531 .into_inner();
532 let state = pb::PlayerState::try_from(r.state).unwrap_or(pb::PlayerState::Unspecified);
533 let out = pb::AudioOutput::try_from(r.output).unwrap_or(pb::AudioOutput::Unspecified);
534 println!(
535 "state={state:?} url={} position_ms={} duration_ms={} is_live={} output={out:?} volume={}% error={:?}",
536 r.url, r.position_ms, r.duration_ms, r.is_live, r.volume_percent, r.error,
537 );
538 }
539 StreamCmd::Volume(cmd) => match cmd {
540 StreamVolumeCmd::Get => {
541 let r = client
542 .get_stream_volume(attach_token(Request::new(pb::GetStreamVolumeRequest {}), &token))
543 .await?
544 .into_inner();
545 println!("{}%", r.volume_percent);
546 }
547 StreamVolumeCmd::Set { percent } => {
548 client
549 .set_stream_volume(attach_token(
550 Request::new(pb::SetStreamVolumeRequest { volume_percent: percent }),
551 &token,
552 ))
553 .await?;
554 println!("ok");
555 }
556 },
557 }
558 Ok(())
559}
560
561async fn run_volume(ep: &Endpoint, token: Option<String>, cmd: VolumeCmd) -> Result<()> {
562 let ch = channel(ep).await?;
563 let mut client = pb::volume_service_client::VolumeServiceClient::new(ch);
564 let mk = |card: Option<String>, control: Option<String>, index: u32| pb::MixerSelector {
565 card: card.unwrap_or_default(),
566 control: control.unwrap_or_default(),
567 index,
568 };
569 match cmd {
570 VolumeCmd::List { card } => {
571 let r = client
572 .list_mixers(attach_token(Request::new(pb::ListMixersRequest { card }), &token))
573 .await?
574 .into_inner();
575 for m in r.mixers {
576 println!(
577 "{:20} index={} volume={} switch={}",
578 m.control, m.index, m.has_volume, m.has_switch
579 );
580 }
581 }
582 VolumeCmd::Get { card, control, index } => {
583 let r = client
584 .get_volume(attach_token(
585 Request::new(pb::GetVolumeRequest { mixer: Some(mk(card, control, index)) }),
586 &token,
587 ))
588 .await?
589 .into_inner();
590 if let Some(s) = r.status {
591 println!("{}% muted={}", s.volume_percent, s.muted);
592 }
593 }
594 VolumeCmd::Set { percent, card, control, index } => {
595 client
596 .set_volume(attach_token(
597 Request::new(pb::SetVolumeRequest {
598 mixer: Some(mk(card, control, index)),
599 volume_percent: percent,
600 }),
601 &token,
602 ))
603 .await?;
604 println!("ok");
605 }
606 VolumeCmd::Mute { card, control, index } => {
607 client
608 .set_mute(attach_token(
609 Request::new(pb::SetMuteRequest {
610 mixer: Some(mk(card, control, index)),
611 muted: true,
612 }),
613 &token,
614 ))
615 .await?;
616 println!("ok");
617 }
618 VolumeCmd::Unmute { card, control, index } => {
619 client
620 .set_mute(attach_token(
621 Request::new(pb::SetMuteRequest {
622 mixer: Some(mk(card, control, index)),
623 muted: false,
624 }),
625 &token,
626 ))
627 .await?;
628 println!("ok");
629 }
630 }
631 Ok(())
632}
633
634// --- systemd ---------------------------------------------------------------
635
636async fn run_systemd(ep: &Endpoint, token: Option<String>, cmd: SystemdCmd) -> Result<()> {
637 let ch = channel(ep).await?;
638 let mut client = pb::systemd_service_client::SystemdServiceClient::new(ch);
639 match cmd {
640 SystemdCmd::List => {
641 let r = client
642 .list_managed_units(attach_token(Request::new(pb::ListManagedUnitsRequest {}), &token))
643 .await?
644 .into_inner();
645 for u in r.units {
646 let state = pb::UnitActiveState::try_from(u.active_state).unwrap_or(pb::UnitActiveState::Unspecified);
647 println!(
648 "{:35} {:?} sub={} enabled={} {}",
649 u.name, state, u.sub_state, u.enabled, u.description
650 );
651 }
652 }
653 SystemdCmd::Status { name } => {
654 let r = client
655 .status(attach_token(Request::new(pb::StatusRequestUnit { name }), &token))
656 .await?
657 .into_inner();
658 if let Some(u) = r.unit {
659 let state = pb::UnitActiveState::try_from(u.active_state).unwrap_or(pb::UnitActiveState::Unspecified);
660 println!(
661 "{:35} {:?} sub={} enabled={} {}",
662 u.name, state, u.sub_state, u.enabled, u.description
663 );
664 }
665 }
666 SystemdCmd::Start { name } => { client.start(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); }
667 SystemdCmd::Stop { name } => { client.stop(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); }
668 SystemdCmd::Restart { name } => { client.restart(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); }
669 SystemdCmd::Reload { name } => { client.reload(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); }
670 SystemdCmd::Enable { name } => { client.enable(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); }
671 SystemdCmd::Disable { name } => { client.disable(attach_token(Request::new(pb::UnitRequest { name }), &token)).await?; println!("ok"); }
672 }
673 Ok(())
674}
675
676// --- config ----------------------------------------------------------------
677
678async fn run_config(ep: &Endpoint, token: Option<String>, cmd: ConfigCmd) -> Result<()> {
679 let ch = channel(ep).await?;
680 let mut client = pb::config_service_client::ConfigServiceClient::new(ch);
681 match cmd {
682 ConfigCmd::List => {
683 let r = client
684 .list_configs(attach_token(Request::new(pb::ListConfigsRequest {}), &token))
685 .await?
686 .into_inner();
687 for c in r.configs {
688 println!("{:20} {} unit={}", c.key, c.path, c.unit);
689 }
690 }
691 ConfigCmd::Get { key } => {
692 let r = client
693 .get_config(attach_token(Request::new(pb::GetConfigRequest { key }), &token))
694 .await?
695 .into_inner();
696 if let Some(c) = r.config {
697 eprintln!("# {} ← {} (unit={})", c.key, c.path, c.unit);
698 }
699 print!("{}", r.content);
700 }
701 ConfigCmd::Put { key, file, action } => {
702 let content = tokio::fs::read_to_string(&file)
703 .await
704 .with_context(|| format!("read {}", file.display()))?;
705 let action = match action {
706 ActionArg::None => pb::PostWriteAction::None,
707 ActionArg::Reload => pb::PostWriteAction::Reload,
708 ActionArg::Restart => pb::PostWriteAction::Restart,
709 } as i32;
710 let r = client
711 .put_config(attach_token(
712 Request::new(pb::PutConfigRequest { key, content, action }),
713 &token,
714 ))
715 .await?
716 .into_inner();
717 print_json(&serde_json::json!({"action_applied": r.action_applied}))?;
718 }
719 }
720 Ok(())
721}
722
723// --- system ----------------------------------------------------------------
724
725async fn run_system(ep: &Endpoint, token: Option<String>, cmd: SystemCmd) -> Result<()> {
726 let ch = channel(ep).await?;
727 let mut client = pb::system_service_client::SystemServiceClient::new(ch);
728 match cmd {
729 SystemCmd::Version => {
730 let r = client
731 .version(attach_token(Request::new(pb::VersionRequest {}), &token))
732 .await?
733 .into_inner();
734 print_json(&serde_json::json!({"version": r.version, "os": r.os, "arch": r.arch}))?;
735 }
736 SystemCmd::Health => {
737 let r = client
738 .health(attach_token(Request::new(pb::HealthRequest {}), &token))
739 .await?
740 .into_inner();
741 print_json(&serde_json::json!({"ok": r.ok}))?;
742 }
743 }
744 Ok(())
745}