From 0a0d50f7482278027135aa864c52a059c2ca5cbf Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 22 Jul 2026 12:13:51 +0200 Subject: [PATCH] Implement the comprehensive CLI (stage 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every binary is now a clap-derive CLI; no subcommand keeps the current default (run server / TUI / both). - cbd-cli: run_remote executes library/queue/global against a running server (mirrors RpcClient; direct Stop; connect+request timeouts; concise errors; human-readable listings). - crabidy-server: guard (hash + write [auth], stdin fallback, --no-config), scan (walk + write .cbd-track.toml; --capture/--move via new CrabidyStore::ingest_file), ServerSettings::store; replaces hash-password. - cbd-tui: auth writes the client config; config load+override keeps the first-run-defaults / flag-overrides-file behavior. - cbd: union of server + client subcommands. - build.rs in each binary generates shell completions + man pages (OUT_DIR, and CBD_ASSET_DIR when set); devenv gen-cli-assets → dist/. - README CLI section; tests for parse, config writers, scan/ingest, guard. Deviations (plan/summary.md): ClapSerde kept; connection flags top-level (not clap-global, to avoid colliding with auth --address); Box CLI reports per existing convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + Cargo.lock | 7 + README.md | 78 ++++++- cbd-cli/src/client.rs | 321 +++++++++++++++++++++++++- cbd-cli/src/lib.rs | 149 ++++++++++++ cbd-tui/Cargo.toml | 11 + cbd-tui/build.rs | 23 ++ cbd-tui/src/config.rs | 202 ++++++++++++++++ cbd-tui/src/main.rs | 97 +++++++- cbd/Cargo.toml | 7 + cbd/build.rs | 23 ++ cbd/src/main.rs | 91 +++++++- crabidy-server/Cargo.toml | 9 + crabidy-server/build.rs | 26 +++ crabidy-server/src/cli.rs | 346 ++++++++++++++++++++++++++++ crabidy-server/src/crabidy_store.rs | 119 ++++++++++ crabidy-server/src/lib.rs | 1 + crabidy-server/src/main.rs | 84 ++++--- crabidy-server/src/settings.rs | 61 ++++- devenv.nix | 9 + plan/cli.md | 32 +-- plan/summary.md | 61 +++++ quality/cli.md | 46 ++-- 23 files changed, 1704 insertions(+), 102 deletions(-) create mode 100644 cbd-tui/build.rs create mode 100644 cbd/build.rs create mode 100644 crabidy-server/src/cli.rs diff --git a/.gitignore b/.gitignore index e0b5492..28faf1e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ target /target +# generated CLI assets (gen-cli-assets writes here) +/dist + # devenv .devenv* devenv.local.nix diff --git a/Cargo.lock b/Cargo.lock index 0b4b066..23aab45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -654,7 +654,9 @@ dependencies = [ name = "cbd" version = "0.1.0" dependencies = [ + "cbd-cli", "cbd-tui", + "clap", "crabidy-core", "crabidy-server", "dirs", @@ -682,6 +684,8 @@ name = "cbd-tui" version = "0.1.0" dependencies = [ "base64", + "cbd-cli", + "clap", "crabidy-core", "crossterm", "dirs", @@ -689,8 +693,10 @@ dependencies = [ "notify-rust", "ratatui", "serde", + "tempfile", "tokio", "tokio-stream", + "toml", "tonic", "tracing", "tracing-appender", @@ -1136,6 +1142,7 @@ dependencies = [ "axum", "base64", "blake3", + "cbd-cli", "clap", "crabidy-core", "dirs", diff --git a/README.md b/README.md index 3716ea8..fa0fd7c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ server (e.g. a Raspberry Pi). A shared file would force one to follow the other's `address`. ```toml +[server] # Where to find the server. Default (both files): localhost, which is # what cbd's own in-process server listens on. Point cbd-tui.toml at a # remote server to use it as a remote control. @@ -91,8 +92,16 @@ password = "" spectrum = true ``` -Every option is also available as a command-line flag -(`cbd-tui --address ...`, `cbd --address ...`). +Every option is also available as a command-line flag before the +subcommand (`cbd-tui --address ... --user owner`, `cbd --spectrum +false`); a flag overrides the file value. To write the credentials into +the config once, use the `auth` subcommand (see below) instead of +editing the file by hand: + +```sh +cbd-tui auth owner 'my-password' # sets user + password +cbd-tui auth queue-owner 'pw' --address http://pi:50051 +``` ### `crabidy-server.toml` — roles and rights @@ -109,8 +118,8 @@ every RPC and hands out *roles* (see `architecture/roles-auth.md`): ```toml [auth] -# One PHC hash per role; omit a role to disable it. Generate with: -# crabidy-server hash-password (reads the password from stdin) +# One PHC hash per role; omit a role to disable it. Generate and store +# a hash with: crabidy-server guard (see the CLI section). owner = "$argon2id$v=19$m=19456,t=2,p=1$..." queue_owner = "$argon2id$..." queue_appender = "$argon2id$..." @@ -122,6 +131,67 @@ startup rather than silently running open. Note that the transport is plain HTTP/2: fine on a trusted home network, but anything exposed further needs TLS termination (reverse proxy, VPN) in front. +## Command line + +Every binary is a clap CLI: run it with `--help` (and any subcommand +with `--help`) for the full surface. Running a binary with **no +subcommand** behaves as it always has — `crabidy-server` runs the +server, `cbd-tui` runs the TUI, `cbd` runs the in-process server + TUI. + +The `library`, `queue`, and `global` subcommands are available on all +three binaries and act as a remote control over gRPC (they connect to a +running server, honouring the same `[auth]` credentials as the TUI): + +```sh +crabidy-server library list /tidal # browse a node +cbd-tui --address http://pi:50051 queue append /fs/album +cbd global play # toggle play/pause +cbd global volume -- -0.1 # lower the volume +``` + +Connection flags (`--address/--user/--password`) go **before** the +subcommand; omitted, they fall back to the client config file. + +Server-only subcommands (`crabidy-server`, and `cbd`): + +- `guard [password]` — hash a role password (argon2id), print + the PHC string, and (unless `--no-config`) write it into + `crabidy-server.toml`'s `[auth]`. Roles: `owner`, `queue-owner`, + `queue-appender`. +- `scan [--capture|--move]` — walk a folder and drop a + `.cbd-track.toml` beside every audio file so it browses under `/fs`. + `--capture` copies each file into the content store (the toml points + there); `--move` moves it instead of copying. + +Client-only subcommand (`cbd-tui`, and `cbd`): + +- `auth [password] [--address ADDR]` — write the role name and + cleartext password (and address) into the client config. + +**Password caveat.** A password given as a command-line argument is +visible in the process list (e.g. `ps`). Omit it and `guard` reads the +password from stdin instead, which keeps it out of argv and is +pipe-friendly: + +```sh +printf '%s' 'my-password' | crabidy-server guard owner +``` + +The client config stores the password in plaintext, so keep the file +private. + +### Completions and man pages + +```sh +cbd-tui completions bash # print a completion script +devenv shell -- gen-cli-assets # write dist/completions + dist/man +``` + +`gen-cli-assets` builds the binaries with `CBD_ASSET_DIR=$PWD/dist`, so +`dist/completions/**` (bash/zsh/fish) and `dist/man/*.1` are produced +for all three binaries. Every ordinary build also emits them into the +crate's `OUT_DIR`. + ## Using the library Navigation is vim-style: `j`/`k` select, `l` enters the selected diff --git a/cbd-cli/src/client.rs b/cbd-cli/src/client.rs index 12f6882..3e25f9c 100644 --- a/cbd-cli/src/client.rs +++ b/cbd-cli/src/client.rs @@ -1,9 +1,13 @@ //! The gRPC executor for the remote `library`/`queue`/`global` commands //! (`client` feature). Connects a generated `crabidy-core` client with a -//! basic-auth interceptor and runs one command against a server. +//! basic-auth interceptor and runs one command against a server, printing a +//! human-readable result. //! -//! Stage-2 (api-design) note: the connection builder is real; `run_remote`'s -//! per-command dispatch is finalized by the implement stage. +//! Transport and gRPC `Status` errors are mapped to a short one-line message +//! (no color-eyre chain dump) — an ordinary "server unreachable" reads as a +//! single line (architecture/cli.md D3, quality gate "remote errors"). + +use std::time::Duration; use base64::Engine; use tonic::metadata::MetadataValue; @@ -11,9 +15,27 @@ use tonic::service::{interceptor::InterceptedService, Interceptor}; use tonic::transport::{Channel, Endpoint}; use tonic::{Request, Status}; -use crabidy_core::proto::crabidy::crabidy_service_client::CrabidyServiceClient; +use crabidy_core::proto::crabidy::{ + crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, + ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, + GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest, + Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, + SaveQueueRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, + ToggleRepeatRequest, ToggleShuffleRequest, Track, +}; -use crate::{Connection, RemoteCmd}; +use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd}; + +/// The reserved library path the live queue mirrors into; `queue capture` +/// captures it (architecture/crabidy-store.md). +const CURRENT_NODE: &str = "/crabidy/current"; + +/// How long to wait for the TCP connect before giving up — a CLI must not +/// hang forever against an unreachable server. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Per-request deadline. Generous: `CaptureLibraryNode` returns once the +/// capture is *accepted*; the walk runs server-side. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Attaches `authorization: Basic …` when a user is configured; an empty user /// means an open server. The header value is a secret and never logged. @@ -51,9 +73,13 @@ impl Interceptor for AuthInterceptor { type Client = CrabidyServiceClient>; -/// Connects (lazily) to the server described by `conn`. +/// Connects (lazily) to the server described by `conn`, with a bounded +/// connect timeout and per-request deadline. async fn connect(conn: &Connection) -> Result> { - let endpoint = Endpoint::from_shared(conn.address.clone())?.connect_lazy(); + let endpoint = Endpoint::from_shared(conn.address.clone())? + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .connect_lazy(); let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?; Ok(CrabidyServiceClient::with_interceptor( endpoint, @@ -61,12 +87,287 @@ async fn connect(conn: &Connection) -> Result )) } +/// Maps a gRPC [`Status`] to a short, single-line error — the code plus its +/// message, never an internal report chain. A transport failure surfaces as +/// `Unavailable` with tonic's one-line reason. +fn rpc_error(status: Status) -> Box { + let code = status.code(); + let message = status.message(); + if message.is_empty() { + format!("server error: {code}").into() + } else { + format!("{message} ({code})").into() + } +} + /// Runs one remote command against the server and prints the result. pub async fn run_remote( conn: &Connection, cmd: RemoteCmd, ) -> Result<(), Box> { - let _client = connect(conn).await?; - let _ = cmd; - todo!("dispatch each Library/Queue/Global variant to its RPC and print") + let mut client = connect(conn).await?; + match cmd { + RemoteCmd::Library(cmd) => run_library(&mut client, cmd).await, + RemoteCmd::Queue(cmd) => run_queue(&mut client, cmd).await, + RemoteCmd::Global(cmd) => run_global(&mut client, cmd).await, + } +} + +async fn run_library( + client: &mut Client, + cmd: LibraryCmd, +) -> Result<(), Box> { + match cmd { + LibraryCmd::List { path } => { + let response = client + .get_library_node(GetLibraryNodeRequest { path: path.clone() }) + .await + .map_err(rpc_error)?; + match response.into_inner().node { + Some(node) => print_node(&node), + None => return Err(format!("no such library node: {path}").into()), + } + } + LibraryCmd::Create { parent, title } => { + client + .create_library_node(CreateLibraryNodeRequest { + parent_path: parent.clone(), + title: title.clone(), + }) + .await + .map_err(rpc_error)?; + println!("created \"{title}\" under {parent}"); + } + LibraryCmd::Rename { path, title } => { + client + .rename_library_node(RenameLibraryNodeRequest { + path: path.clone(), + new_title: title.clone(), + }) + .await + .map_err(rpc_error)?; + println!("renamed {path} to \"{title}\""); + } + LibraryCmd::Delete { path } => { + client + .delete_library_node(DeleteLibraryNodeRequest { path: path.clone() }) + .await + .map_err(rpc_error)?; + println!("deleted {path}"); + } + LibraryCmd::Save { path, name } => { + capture(client, &path, &name, false).await?; + println!("saving {path} as /crabidy/{name} (link)"); + } + LibraryCmd::Capture { path, name } => { + capture(client, &path, &name, true).await?; + println!("capturing {path} into /crabidy/{name} (download)"); + } + } + Ok(()) +} + +async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box> { + match cmd { + QueueCmd::Show => { + let response = client.init(InitRequest {}).await.map_err(rpc_error)?; + match response.into_inner().queue { + Some(queue) => print_queue(&queue), + None => println!("the queue is empty"), + } + } + QueueCmd::Append { paths } => { + let n = paths.len(); + client + .append(AppendRequest { paths }) + .await + .map_err(rpc_error)?; + println!("appended {n} path(s)"); + } + QueueCmd::Insert { position, paths } => { + let n = paths.len(); + client + .insert(InsertRequest { position, paths }) + .await + .map_err(rpc_error)?; + println!("inserted {n} path(s) at {position}"); + } + QueueCmd::Replace { paths } => { + let n = paths.len(); + client + .replace(ReplaceRequest { paths }) + .await + .map_err(rpc_error)?; + println!("replaced the queue with {n} path(s)"); + } + QueueCmd::Remove { positions } => { + let n = positions.len(); + client + .remove(RemoveRequest { positions }) + .await + .map_err(rpc_error)?; + println!("removed {n} entry(ies)"); + } + QueueCmd::Clear { keep_current } => { + client + .clear_queue(ClearQueueRequest { + exclude_current: keep_current, + }) + .await + .map_err(rpc_error)?; + println!("cleared the queue"); + } + QueueCmd::SetCurrent { position } => { + client + .set_current(SetCurrentRequest { position }) + .await + .map_err(rpc_error)?; + println!("jumped to position {position}"); + } + QueueCmd::Save { name } => { + client + .save_queue(SaveQueueRequest { name: name.clone() }) + .await + .map_err(rpc_error)?; + println!("saving the queue as /crabidy/{name} (link)"); + } + QueueCmd::Capture { name } => { + capture(client, CURRENT_NODE, &name, true).await?; + println!("capturing the queue into /crabidy/{name} (download)"); + } + QueueCmd::Shuffle => { + client + .toggle_shuffle(ToggleShuffleRequest {}) + .await + .map_err(rpc_error)?; + println!("toggled shuffle"); + } + QueueCmd::Repeat => { + client + .toggle_repeat(ToggleRepeatRequest {}) + .await + .map_err(rpc_error)?; + println!("toggled repeat"); + } + } + Ok(()) +} + +async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box> { + match cmd { + GlobalCmd::Play => { + client + .toggle_play(TogglePlayRequest {}) + .await + .map_err(rpc_error)?; + println!("toggled play/pause"); + } + GlobalCmd::Stop => { + client.stop(StopRequest {}).await.map_err(rpc_error)?; + println!("stopped"); + } + GlobalCmd::Next => { + client.next(NextRequest {}).await.map_err(rpc_error)?; + println!("next track"); + } + GlobalCmd::Prev => { + client.prev(PrevRequest {}).await.map_err(rpc_error)?; + println!("previous track"); + } + GlobalCmd::Restart => { + client + .restart_track(RestartTrackRequest {}) + .await + .map_err(rpc_error)?; + println!("restarted the current track"); + } + GlobalCmd::Mute => { + client + .toggle_mute(ToggleMuteRequest {}) + .await + .map_err(rpc_error)?; + println!("toggled mute"); + } + GlobalCmd::Volume { delta } => { + client + .change_volume(ChangeVolumeRequest { delta }) + .await + .map_err(rpc_error)?; + println!("changed volume by {delta:+}"); + } + } + Ok(()) +} + +/// Issues a `CaptureLibraryNode` (link save or download capture). +async fn capture( + client: &mut Client, + path: &str, + name: &str, + download: bool, +) -> Result<(), Box> { + client + .capture_library_node(CaptureLibraryNodeRequest { + path: path.to_string(), + name: name.to_string(), + download, + }) + .await + .map_err(rpc_error)?; + Ok(()) +} + +/// Prints a library node: its path/title, child nodes, then tracks. Captured +/// rows are marked `*` (architecture/crabidy-store.md). +fn print_node(node: &LibraryNode) { + let marker = if node.is_captured { " *" } else { "" }; + println!("{} \"{}\"{marker}", node.path, node.title); + if !node.children.is_empty() { + println!("nodes:"); + for child in &node.children { + let marker = if child.is_captured { " *" } else { "" }; + println!(" {} \"{}\"{marker}", child.path, child.title); + } + } + if !node.tracks.is_empty() { + println!("tracks:"); + for track in &node.tracks { + println!(" {} {}", track.path, track_label(track)); + } + } + if node.children.is_empty() && node.tracks.is_empty() { + println!("(empty)"); + } +} + +/// Prints the current queue with a marker on the current track. +fn print_queue(queue: &Queue) { + if queue.tracks.is_empty() { + println!("the queue is empty"); + return; + } + for (index, track) in queue.tracks.iter().enumerate() { + let here = if index as u32 == queue.current_position { + ">" + } else { + " " + }; + println!("{here} {index:>3} {}", track_label(track)); + } +} + +/// A one-line `artist - title` label, marking captured/skipped tracks. +fn track_label(track: &Track) -> String { + let mut label = if track.artist.is_empty() { + track.title.clone() + } else { + format!("{} - {}", track.artist, track.title) + }; + if track.is_captured { + label.push_str(" *"); + } + if track.is_skipped { + label.push_str(" (skipped)"); + } + label } diff --git a/cbd-cli/src/lib.rs b/cbd-cli/src/lib.rs index bc7875d..baa6c43 100644 --- a/cbd-cli/src/lib.rs +++ b/cbd-cli/src/lib.rs @@ -304,3 +304,152 @@ pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, b mod client; #[cfg(feature = "client")] pub use client::run_remote; + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn no_subcommand_parses_on_every_binary() { + assert!(ServerCli::try_parse_from(["crabidy-server"]) + .expect("server") + .command + .is_none()); + assert!(TuiCli::try_parse_from(["cbd-tui"]) + .expect("tui") + .command + .is_none()); + assert!(CbdCli::try_parse_from(["cbd"]) + .expect("cbd") + .command + .is_none()); + } + + #[test] + fn remote_flags_override_when_present_and_are_none_otherwise() { + let cli = TuiCli::try_parse_from(["cbd-tui", "--address", "http://x:1", "-u", "owner"]) + .expect("parse"); + assert_eq!(cli.remote.address.as_deref(), Some("http://x:1")); + assert_eq!(cli.remote.user.as_deref(), Some("owner")); + assert!(cli.remote.password.is_none()); + + let bare = TuiCli::try_parse_from(["cbd-tui"]).expect("bare"); + assert!(bare.remote.address.is_none()); + assert!(bare.remote.user.is_none()); + } + + #[test] + fn library_list_defaults_to_root() { + let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list"]).expect("parse"); + match cli.command { + Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/"), + other => panic!("unexpected: {other:?}"), + } + let cli = ServerCli::try_parse_from(["crabidy-server", "library", "list", "/tidal"]) + .expect("parse"); + match cli.command { + Some(ServerCommand::Library(LibraryCmd::List { path })) => assert_eq!(path, "/tidal"), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn queue_append_collects_many_paths() { + let cli = TuiCli::try_parse_from(["cbd-tui", "queue", "append", "/a", "/b", "/c"]) + .expect("parse"); + match cli.command { + Some(TuiCommand::Queue(QueueCmd::Append { paths })) => { + assert_eq!(paths, vec!["/a", "/b", "/c"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn queue_clear_keep_current_is_a_flag() { + let cli = + TuiCli::try_parse_from(["cbd-tui", "queue", "clear", "--keep-current"]).expect("parse"); + match cli.command { + Some(TuiCommand::Queue(QueueCmd::Clear { keep_current })) => assert!(keep_current), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn global_volume_parses_a_signed_delta() { + let cli = + TuiCli::try_parse_from(["cbd-tui", "global", "volume", "--", "-0.1"]).expect("parse"); + match cli.command { + Some(TuiCommand::Global(GlobalCmd::Volume { delta })) => { + assert!((delta - -0.1).abs() < f32::EPSILON); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn guard_reads_a_role_and_optional_password() { + let cli = ServerCli::try_parse_from(["crabidy-server", "guard", "owner"]).expect("parse"); + match cli.command { + Some(ServerCommand::Guard(args)) => { + assert_eq!(args.role, Role::Owner); + assert!(args.password.is_none()); + assert!(!args.no_config); + } + other => panic!("unexpected: {other:?}"), + } + let cli = ServerCli::try_parse_from([ + "crabidy-server", + "guard", + "queue-owner", + "secret", + "--no-config", + ]) + .expect("parse"); + match cli.command { + Some(ServerCommand::Guard(args)) => { + assert_eq!(args.role, Role::QueueOwner); + assert_eq!(args.password.as_deref(), Some("secret")); + assert!(args.no_config); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn scan_flags_parse() { + let cli = ServerCli::try_parse_from(["crabidy-server", "scan", "/music", "--capture"]) + .expect("p"); + match cli.command { + Some(ServerCommand::Scan(args)) => { + assert_eq!(args.path, std::path::PathBuf::from("/music")); + assert!(args.capture); + assert!(!args.move_); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn cbd_is_the_union_of_server_and_client_commands() { + assert!(matches!( + CbdCli::try_parse_from(["cbd", "guard", "owner"]) + .expect("p") + .command, + Some(CbdCommand::Guard(_)) + )); + assert!(matches!( + CbdCli::try_parse_from(["cbd", "auth", "owner", "pw"]) + .expect("p") + .command, + Some(CbdCommand::Auth(_)) + )); + assert!(matches!( + CbdCli::try_parse_from(["cbd", "global", "play"]) + .expect("p") + .command, + Some(CbdCommand::Global(GlobalCmd::Play)) + )); + } +} diff --git a/cbd-tui/Cargo.toml b/cbd-tui/Cargo.toml index c506b80..56087fb 100644 --- a/cbd-tui/Cargo.toml +++ b/cbd-tui/Cargo.toml @@ -5,9 +5,12 @@ edition.workspace = true [dependencies] base64.workspace = true +cbd-cli = { workspace = true, features = ["client"] } crabidy-core.workspace = true crossterm.workspace = true +clap.workspace = true dirs.workspace = true +toml.workspace = true flume.workspace = true notify-rust.workspace = true ratatui.workspace = true @@ -18,3 +21,11 @@ tonic = { workspace = true, features = ["channel", "codegen"] } tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +# Default features only (clap-only) so asset generation stays cheap. +[build-dependencies] +cbd-cli.workspace = true +clap.workspace = true diff --git a/cbd-tui/build.rs b/cbd-tui/build.rs new file mode 100644 index 0000000..99e4b01 --- /dev/null +++ b/cbd-tui/build.rs @@ -0,0 +1,23 @@ +//! Generates shell completions and a man page for `cbd-tui` from its +//! top-level clap command (architecture/cli.md D7). Written into `OUT_DIR` +//! every build, and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli` +//! is a default-features (clap-only) build-dependency, so this never pulls +//! tonic into ordinary builds. + +use std::path::Path; + +fn main() { + use clap::CommandFactory; + println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR"); + let bin = "cbd-tui"; + let command = cbd_cli::TuiCli::command(); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts"); + if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) { + println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}"); + } + if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") { + if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) { + println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}"); + } + } +} diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index f07ed91..433d0b6 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -1,3 +1,5 @@ +use std::path::{Path, PathBuf}; + use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde}; #[derive(ClapSerde, Serialize, Debug)] @@ -8,6 +10,140 @@ pub struct Config { pub server: ServerConfig, } +/// The on-disk path of a client config file: +/// `dirs::config_dir()/crabidy/`. +pub fn config_path(file_name: &str) -> Option { + dirs::config_dir().map(|dir| dir.join("crabidy").join(file_name)) +} + +/// Loads a client config, writing a defaults file on first run. +/// +/// This is the no-subcommand read path, replacing the general +/// `crabidy_core::init_config` for the clap-derive binaries: it reads the +/// file (or writes defaults when absent) using the same TOML serialization, +/// but never calls `merge_clap` — argv is parsed by `cbd_cli::TuiCli`, and +/// the flags are applied afterwards with [`apply_overrides`]. A missing +/// config directory falls back to the built-in defaults without writing. +pub fn load_first_run(file_name: &str) -> Config { + let Some(path) = config_path(file_name) else { + return Config::default(); + }; + load_first_run_at(&path) +} + +/// [`load_first_run`] against an explicit path (the testable core). +pub fn load_first_run_at(path: &Path) -> Config { + if let Some(parent) = path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + eprintln!( + "could not create config directory {}: {err}", + parent.display() + ); + return Config::default(); + } + } + if !path.is_file() { + let config = Config::default(); + match toml::to_string_pretty(&config) { + Ok(text) => { + if let Err(err) = std::fs::write(path, text) { + eprintln!("could not write config {}: {err}", path.display()); + } + } + Err(err) => eprintln!("could not serialize config: {err}"), + } + return config; + } + load_existing(path).unwrap_or_default() +} + +/// Reads an existing config file into a [`Config`], returning `None` on a +/// missing/unreadable/unparsable file (the caller falls back to defaults). +fn load_existing(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + match toml::from_str::<::Opt>(&text) { + Ok(opt) => Some(Config::from(opt)), + Err(err) => { + eprintln!("invalid config {}: {err}", path.display()); + None + } + } +} + +/// Applies CLI overrides onto a loaded config: a provided flag wins over the +/// file value; an omitted flag leaves the file value in place +/// (architecture/cli.md D2). +pub fn apply_overrides( + config: &mut Config, + address: Option, + user: Option, + password: Option, + spectrum: Option, +) { + if let Some(address) = address { + config.server.address = address; + } + if let Some(user) = user { + config.server.user = user; + } + if let Some(password) = password { + config.server.password = password; + } + if let Some(spectrum) = spectrum { + config.server.spectrum = spectrum; + } +} + +/// Writes `config` back to `file_name` (same TOML shape as [`load_first_run`] +/// wrote), creating the config directory if missing. Returns the path +/// written. The password is stored in plaintext — keep the file private. +pub fn store(file_name: &str, config: &Config) -> Result { + let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?; + store_at(&path, config)?; + Ok(path) +} + +/// [`store`] against an explicit path (the testable core). +pub fn store_at(path: &Path, config: &Config) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("cannot create {}: {err}", parent.display()))?; + } + let text = + toml::to_string_pretty(config).map_err(|err| format!("cannot serialize config: {err}"))?; + std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display())) +} + +/// The `auth` subcommand writer: loads `file_name` (or defaults), sets the +/// basic-auth `user` (role name) and cleartext `password` (and `address` +/// when given), preserving other fields, and writes it back. +pub fn write_auth( + file_name: &str, + user: &str, + password: &str, + address: Option<&str>, +) -> Result { + let path = config_path(file_name).ok_or_else(|| "no config directory available".to_string())?; + write_auth_at(&path, user, password, address)?; + Ok(path) +} + +/// [`write_auth`] against an explicit path (the testable core). +pub fn write_auth_at( + path: &Path, + user: &str, + password: &str, + address: Option<&str>, +) -> Result<(), String> { + let mut config = load_existing(path).unwrap_or_default(); + config.server.user = user.to_string(); + config.server.password = password.to_string(); + if let Some(address) = address { + config.server.address = address.to_string(); + } + store_at(path, &config) +} + #[derive(ClapSerde, Serialize, Debug)] pub struct ServerConfig { /// Server address @@ -34,3 +170,69 @@ pub struct ServerConfig { #[clap(long)] pub spectrum: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn first_run_writes_a_defaults_file_and_reloads_it() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("cbd-tui.toml"); + assert!(!path.is_file()); + let config = load_first_run_at(&path); + // Defaults written on first run. + assert!(path.is_file()); + assert_eq!(config.server.address, "http://127.0.0.1:50051"); + assert_eq!(config.server.user, ""); + assert!(config.server.spectrum); + // Reloading reads the file (no second write needed). + let reloaded = load_first_run_at(&path); + assert_eq!(reloaded.server.address, config.server.address); + } + + #[test] + fn a_provided_flag_overrides_the_file_and_omitted_flags_do_not() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("cbd-tui.toml"); + let mut config = load_first_run_at(&path); + apply_overrides( + &mut config, + Some("http://pi:50051".to_string()), + None, + None, + Some(false), + ); + assert_eq!(config.server.address, "http://pi:50051"); + // user was omitted: falls back to the file value. + assert_eq!(config.server.user, ""); + assert!(!config.server.spectrum); + } + + #[test] + fn write_auth_round_trips_user_password_address() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("cbd-tui.toml"); + // Seed a defaults file, then set credentials. + load_first_run_at(&path); + write_auth_at(&path, "queue-owner", "s3cret", Some("http://pi:50051")).expect("write auth"); + let reloaded = load_first_run_at(&path); + assert_eq!(reloaded.server.user, "queue-owner"); + assert_eq!(reloaded.server.password, "s3cret"); + assert_eq!(reloaded.server.address, "http://pi:50051"); + // spectrum (an unrelated field) is preserved at its default. + assert!(reloaded.server.spectrum); + } + + #[test] + fn write_auth_without_address_keeps_the_existing_one() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("cbd-tui.toml"); + write_auth_at(&path, "owner", "pw", Some("http://a:1")).expect("first"); + write_auth_at(&path, "owner", "pw2", None).expect("second"); + let reloaded = load_first_run_at(&path); + assert_eq!(reloaded.server.password, "pw2"); + assert_eq!(reloaded.server.address, "http://a:1"); + } +} diff --git a/cbd-tui/src/main.rs b/cbd-tui/src/main.rs index f2e600e..e787726 100644 --- a/cbd-tui/src/main.rs +++ b/cbd-tui/src/main.rs @@ -1,11 +1,19 @@ -//! The standalone TUI binary: file-based tracing (the terminal belongs -//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives -//! in the library so the bundled `cbd` binary can host it too -//! (architecture/cbd-bundle.md D1). +//! The standalone TUI binary: a clap-derive CLI ([`cbd_cli::TuiCli`]). With +//! no subcommand it loads the client config (writing defaults on first run), +//! applies the `--address/--user/--password/--spectrum` overrides, and runs +//! the TUI — exactly as before. Subcommands cover `auth` (write credentials +//! into the config), remote control (`library`/`queue`/`global`), and shell +//! completions. All client logic lives in the library so the bundled `cbd` +//! binary can host it too (architecture/cbd-bundle.md D1). use std::sync::OnceLock; -use cbd_tui::config::Config; +use cbd_cli::{RemoteCmd, TuiCli, TuiCommand}; +use cbd_tui::config::{self, Config}; +use clap::{CommandFactory, Parser}; + +/// The config file name for the standalone TUI. +const CONFIG_FILE: &str = "cbd-tui.toml"; static CONFIG: OnceLock = OnceLock::new(); @@ -43,7 +51,80 @@ fn init_tracing() -> Option { #[tokio::main] async fn main() -> Result<(), Box> { - let _log_guard = init_tracing(); - let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml")); - cbd_tui::run(config).await + let cli = TuiCli::parse(); + match cli.command { + // No subcommand: load config, apply overrides, run the TUI. + None => { + let _log_guard = init_tracing(); + let mut config = config::load_first_run(CONFIG_FILE); + config::apply_overrides( + &mut config, + cli.remote.address, + cli.remote.user, + cli.remote.password, + cli.spectrum, + ); + let config = CONFIG.get_or_init(|| config); + cbd_tui::run(config).await + } + // Subcommands are one-shot CLI actions; a failure prints a short + // message and exits non-zero. + Some(command) => { + if let Err(err) = run_command(&cli.remote, command).await { + eprintln!("error: {err}"); + std::process::exit(1); + } + Ok(()) + } + } +} + +/// Dispatches a TUI subcommand. +async fn run_command( + remote: &cbd_cli::RemoteArgs, + command: TuiCommand, +) -> Result<(), Box> { + match command { + TuiCommand::Auth(args) => { + let password = args + .password + .ok_or("missing password (pass it as an argument)")?; + let path = config::write_auth( + CONFIG_FILE, + args.role.user_name(), + &password, + args.address.as_deref(), + )?; + println!( + "wrote credentials for {} to {}", + args.role.user_name(), + path.display() + ); + Ok(()) + } + TuiCommand::Library(cmd) => { + cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await + } + TuiCommand::Queue(cmd) => { + cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await + } + TuiCommand::Global(cmd) => { + cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await + } + TuiCommand::Completions(args) => { + cbd_cli::print_completions(args.shell, &mut TuiCli::command(), "cbd-tui"); + Ok(()) + } + } +} + +/// Resolves the connection for a remote command: the CLI flags win over the +/// client config, which supplies the fallback (address and credentials). +fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection { + let config = config::load_first_run(CONFIG_FILE); + cbd_cli::Connection { + address: remote.address.clone().unwrap_or(config.server.address), + user: remote.user.clone().unwrap_or(config.server.user), + password: remote.password.clone().unwrap_or(config.server.password), + } } diff --git a/cbd/Cargo.toml b/cbd/Cargo.toml index 62b000f..61d696f 100644 --- a/cbd/Cargo.toml +++ b/cbd/Cargo.toml @@ -4,7 +4,9 @@ version.workspace = true edition.workspace = true [dependencies] +cbd-cli = { workspace = true, features = ["client"] } cbd-tui.workspace = true +clap.workspace = true crabidy-core.workspace = true crabidy-server.workspace = true dirs.workspace = true @@ -12,3 +14,8 @@ tokio = { workspace = true, features = ["full"] } tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true + +# Default features only (clap-only) so asset generation stays cheap. +[build-dependencies] +cbd-cli.workspace = true +clap.workspace = true diff --git a/cbd/build.rs b/cbd/build.rs new file mode 100644 index 0000000..bfe415d --- /dev/null +++ b/cbd/build.rs @@ -0,0 +1,23 @@ +//! Generates shell completions and a man page for `cbd` from its top-level +//! clap command (architecture/cli.md D7). Written into `OUT_DIR` every build, +//! and additionally into `$CBD_ASSET_DIR` when set. `cbd-cli` is a +//! default-features (clap-only) build-dependency, so this never pulls tonic +//! into ordinary builds. + +use std::path::Path; + +fn main() { + use clap::CommandFactory; + println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR"); + let bin = "cbd"; + let command = cbd_cli::CbdCli::command(); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts"); + if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) { + println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}"); + } + if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") { + if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) { + println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}"); + } + } +} diff --git a/cbd/src/main.rs b/cbd/src/main.rs index 1a70553..607e888 100644 --- a/cbd/src/main.rs +++ b/cbd/src/main.rs @@ -13,9 +13,15 @@ use std::error::Error; use std::sync::OnceLock; use std::time::Duration; -use cbd_tui::config::Config; +use cbd_cli::{CbdCli, CbdCommand, RemoteCmd}; +use cbd_tui::config::{self, Config}; +use clap::{CommandFactory, Parser}; +use crabidy_server::cli as server_cli; use tracing::{info, warn}; +/// The config file name for the bundled binary (separate from `cbd-tui.toml`). +const CONFIG_FILE: &str = "cbd.toml"; + static CONFIG: OnceLock = OnceLock::new(); /// How long to wait for the server socket before giving up. Generous: @@ -25,6 +31,25 @@ const READINESS_DELAY: Duration = Duration::from_millis(500); #[tokio::main] async fn main() -> Result<(), Box> { + // `cbd` is the union of the server and client command surfaces + // (architecture/cli.md D2): no subcommand runs the in-process server + + // TUI, exactly as before. + let cli = CbdCli::parse(); + match cli.command { + None => run_bundle(cli).await, + Some(command) => { + if let Err(err) = run_command(&cli.remote, command).await { + eprintln!("error: {err}"); + std::process::exit(1); + } + Ok(()) + } + } +} + +/// Runs the bundled server + TUI (the no-subcommand default), unchanged from +/// before save for the clap-based config load and flag overrides. +async fn run_bundle(cli: CbdCli) -> Result<(), Box> { // Both halves share one file-based subscriber: the terminal belongs // to the TUI, so the server's usual stderr logging would corrupt it. let _log_guard = init_tracing(); @@ -34,7 +59,15 @@ async fn main() -> Result<(), Box> { // `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single // shared `address` would force one to follow the other. `cbd` // defaults to localhost, which matches its embedded server. - let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd.toml")); + let mut config = config::load_first_run(CONFIG_FILE); + config::apply_overrides( + &mut config, + cli.remote.address, + cli.remote.user, + cli.remote.password, + cli.spectrum, + ); + let config = CONFIG.get_or_init(|| config); let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?; let mut server = tokio::spawn(crabidy_server::serve(addr)); @@ -49,6 +82,60 @@ async fn main() -> Result<(), Box> { cbd_tui::run(config).await } +/// Dispatches a `cbd` subcommand: `guard`/`scan` reuse the server logic, +/// `auth` writes `cbd.toml`, and `library`/`queue`/`global` run against a +/// server over gRPC. +async fn run_command( + remote: &cbd_cli::RemoteArgs, + command: CbdCommand, +) -> Result<(), Box> { + match command { + CbdCommand::Guard(args) => server_cli::guard(args).await, + CbdCommand::Scan(args) => server_cli::scan(args).await, + CbdCommand::Auth(args) => { + let password = args + .password + .ok_or("missing password (pass it as an argument)")?; + let path = config::write_auth( + CONFIG_FILE, + args.role.user_name(), + &password, + args.address.as_deref(), + )?; + println!( + "wrote credentials for {} to {}", + args.role.user_name(), + path.display() + ); + Ok(()) + } + CbdCommand::Library(cmd) => { + cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await + } + CbdCommand::Queue(cmd) => { + cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await + } + CbdCommand::Global(cmd) => { + cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await + } + CbdCommand::Completions(args) => { + cbd_cli::print_completions(args.shell, &mut CbdCli::command(), "cbd"); + Ok(()) + } + } +} + +/// Resolves the connection for a remote command: the CLI flags win over +/// `cbd.toml`, which supplies the fallback (address and credentials). +fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection { + let config = config::load_first_run(CONFIG_FILE); + cbd_cli::Connection { + address: remote.address.clone().unwrap_or(config.server.address), + user: remote.user.clone().unwrap_or(config.server.user), + password: remote.password.clone().unwrap_or(config.server.password), + } +} + /// Waits until something accepts TCP connections on the TUI's configured /// server address (scheme stripped): the in-process server coming up, or /// an already-running standalone one (in which case our `serve` fails diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index c64cc7c..aceb7b8 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -27,6 +27,9 @@ realfft.workspace = true tonic-web = { workspace = true, optional = true } tower.workspace = true audio-player.workspace = true +# The `client` feature pulls in the gRPC executor used by the +# library/queue/global subcommands (architecture/cli.md D1/D3). +cbd-cli = { workspace = true, features = ["client"] } crabidy-core.workspace = true dirs.workspace = true flume.workspace = true @@ -52,3 +55,9 @@ base64.workspace = true http.workspace = true tempfile.workspace = true tower.workspace = true + +# Default features only (clap-only): generating completions and a man page +# must not drag tonic into ordinary builds (architecture/cli.md D7). +[build-dependencies] +cbd-cli.workspace = true +clap.workspace = true diff --git a/crabidy-server/build.rs b/crabidy-server/build.rs index 459d09b..ff39442 100644 --- a/crabidy-server/build.rs +++ b/crabidy-server/build.rs @@ -8,6 +8,32 @@ use std::path::Path; fn main() { + generate_cli_assets(); + stage_web_bundle(); +} + +/// Writes shell completions and a man page for `crabidy-server` into `OUT_DIR` +/// every build, and additionally into `$CBD_ASSET_DIR` when set +/// (architecture/cli.md D7). `cbd-cli` is a default-features (clap-only) +/// build-dependency, so this never pulls tonic into ordinary builds. +fn generate_cli_assets() { + use clap::CommandFactory; + println!("cargo:rerun-if-env-changed=CBD_ASSET_DIR"); + let bin = "crabidy-server"; + let command = cbd_cli::ServerCli::command(); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set for build scripts"); + if let Err(err) = cbd_cli::generate_assets(command.clone(), bin, Path::new(&out_dir)) { + println!("cargo:warning=cannot generate CLI assets into OUT_DIR: {err}"); + } + if let Some(asset_dir) = std::env::var_os("CBD_ASSET_DIR") { + if let Err(err) = cbd_cli::generate_assets(command, bin, Path::new(&asset_dir)) { + println!("cargo:warning=cannot generate CLI assets into CBD_ASSET_DIR: {err}"); + } + } +} + +/// Stages the web client bundle for embedding (feature `web-ui`). +fn stage_web_bundle() { // Rerun when the bundle changes (or appears). println!("cargo:rerun-if-changed=../cbd-web/dist"); if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() { diff --git a/crabidy-server/src/cli.rs b/crabidy-server/src/cli.rs new file mode 100644 index 0000000..bd79739 --- /dev/null +++ b/crabidy-server/src/cli.rs @@ -0,0 +1,346 @@ +//! Execution of the server-owned CLI subcommands (`guard`, `scan`) and the +//! shared helpers the bundled `cbd` binary reuses (architecture/cli.md D4/D5). +//! +//! The clap *definitions* live in `cbd-cli`; their *execution* lives here +//! because it needs the server's config writer ([`ServerSettings::store`]) and +//! the content store ([`CrabidyStore`]), which `cbd-cli` must not depend on. + +use std::error::Error; +use std::io::{IsTerminal, Read}; +use std::path::{Path, PathBuf}; + +use cbd_cli::{GuardArgs, Role, ScanArgs}; +use tracing::warn; + +use crate::crabidy_store::CrabidyStore; +use crate::settings::ServerSettings; + +/// Default server address for the remote `library`/`queue`/`global` commands. +pub const DEFAULT_ADDRESS: &str = "http://127.0.0.1:50051"; + +/// File-name extensions treated as playable audio by `scan` (lowercased). +const AUDIO_EXTENSIONS: &[&str] = &[ + "flac", "mp3", "m4a", "aac", "ogg", "opus", "wav", "webm", "wma", "aiff", "aif", +]; + +/// The crabidy config directory (`dirs::config_dir()/crabidy`). +fn config_dir() -> Result> { + dirs::config_dir() + .map(|dir| dir.join("crabidy")) + .ok_or_else(|| "no config directory available".into()) +} + +/// Builds a resolved [`cbd_cli::Connection`] from the shared remote flags, +/// falling back to the client defaults (localhost, no credentials). +pub fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection { + cbd_cli::Connection { + address: remote + .address + .clone() + .unwrap_or_else(|| DEFAULT_ADDRESS.to_string()), + user: remote.user.clone().unwrap_or_default(), + password: remote.password.clone().unwrap_or_default(), + } +} + +/// Resolves the password from the CLI argument, or reads it from stdin when +/// omitted. Never echoed or logged. Rejects an empty password. +fn resolve_password(arg: Option) -> Result> { + let password = match arg { + Some(password) => password, + None => { + let mut stdin = std::io::stdin(); + if stdin.is_terminal() { + eprintln!("Enter password (input is not hidden):"); + } + let mut buf = String::new(); + stdin.read_to_string(&mut buf)?; + buf.trim_end_matches(['\r', '\n']).to_string() + } + }; + if password.is_empty() { + return Err("empty password".into()); + } + Ok(password) +} + +/// Writes `hash` into the role's `[auth]` field of `crabidy-server.toml` in +/// `config_dir`, preserving the other roles (the testable core of `guard`'s +/// config write). +pub fn write_role_hash(config_dir: &Path, role: Role, hash: String) -> Result<(), String> { + let mut settings = ServerSettings::load(config_dir)?; + match role { + Role::Owner => settings.auth.owner = Some(hash), + Role::QueueOwner => settings.auth.queue_owner = Some(hash), + Role::Appender => settings.auth.queue_appender = Some(hash), + } + settings.store(config_dir) +} + +/// `guard [password] [--no-config]`: hash a role password (argon2id), +/// print the PHC string to stdout, and — unless `--no-config` — write it into +/// `crabidy-server.toml`'s `[auth]` (architecture/cli.md D4). Only the hash is +/// printed to stdout, so the command stays pipeable; the confirmation goes to +/// stderr and the password is never printed or logged. +pub async fn guard(args: GuardArgs) -> Result<(), Box> { + let password = resolve_password(args.password)?; + let hash = crate::auth::hash_password(&password)?; + println!("{hash}"); + if !args.no_config { + let dir = config_dir()?; + write_role_hash(&dir, args.role, hash)?; + eprintln!( + "wrote the {} hash to {}", + args.role.user_name(), + dir.join(crate::settings::SETTINGS_FILE).display() + ); + } + Ok(()) +} + +/// The outcome of a `scan` walk, for a concise summary and for tests. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ScanSummary { + /// Sidecar `.cbd-track.toml` files written this run. + pub written: usize, + /// Audio files skipped because a sidecar already existed. + pub skipped: usize, +} + +/// `scan [--capture|--move]`: index a music folder (architecture/cli.md +/// D5). Opens the content store only when a capture/move is requested. +pub async fn scan(args: ScanArgs) -> Result<(), Box> { + if !args.path.is_dir() { + return Err(format!("not a directory: {}", args.path.display()).into()); + } + let store = if args.capture || args.move_ { + let tree_root = CrabidyStore::default_tree_root() + .ok_or("--capture/--move need a state directory for the content store")?; + let store_root = CrabidyStore::default_store_root() + .ok_or("--capture/--move need a data directory for the content store")?; + Some(CrabidyStore::open(tree_root, store_root).await?) + } else { + None + }; + let summary = scan_dir(&args.path, store.as_ref(), args.move_).await?; + println!( + "scan complete: {} written, {} skipped", + summary.written, summary.skipped + ); + Ok(()) +} + +/// Walks `root` (bounded, hidden entries and symlinks skipped), writing a +/// `.cbd-track.toml` beside each audio file. With `store` set, each file +/// is ingested (copied, or moved when `move_it`) into the content store and +/// the sidecar points at the store entry; otherwise the sidecar's playable is +/// a relative [`fsdy::Playable::File`]. An existing sidecar is never +/// clobbered. Unreadable entries are warnings, not failures. +pub async fn scan_dir( + root: &Path, + store: Option<&CrabidyStore>, + move_it: bool, +) -> Result> { + let mut summary = ScanSummary::default(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut read_dir = match tokio::fs::read_dir(&dir).await { + Ok(read_dir) => read_dir, + Err(err) => { + warn!(dir = %dir.display(), "cannot read directory: {err}"); + continue; + } + }; + loop { + let entry = match read_dir.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(err) => { + warn!(dir = %dir.display(), "error listing directory: {err}"); + break; + } + }; + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name.starts_with('.') { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + stack.push(path); + } else if is_audio_file(&path) { + match scan_file(&dir, &path, store, move_it).await { + Ok(true) => summary.written += 1, + Ok(false) => summary.skipped += 1, + Err(err) => warn!(file = %path.display(), "cannot index file: {err}"), + } + } + } + } + Ok(summary) +} + +/// Indexes one audio file. Returns `true` when a sidecar was written, `false` +/// when one already existed (left untouched). +async fn scan_file( + dir: &Path, + file: &Path, + store: Option<&CrabidyStore>, + move_it: bool, +) -> Result> { + let stem = file + .file_stem() + .and_then(|s| s.to_str()) + .ok_or("audio file has no usable name")? + .to_string(); + let file_name = file + .file_name() + .and_then(|n| n.to_str()) + .ok_or("audio file has no usable name")? + .to_string(); + let sidecar = dir.join(format!("{stem}{}", fsdy::TRACK_FILE_SUFFIX)); + if tokio::fs::try_exists(&sidecar).await.unwrap_or(false) { + warn!(sidecar = %sidecar.display(), "leaving existing track toml untouched"); + return Ok(false); + } + let playable = match store { + Some(store) => { + let name = store.ingest_file(file, move_it).await?; + fsdy::PlayableSpec { + file: None, + url: None, + link: None, + store: Some(name), + skipped: None, + } + } + None => fsdy::PlayableSpec { + file: Some(PathBuf::from(&file_name)), + url: None, + link: None, + store: None, + skipped: None, + }, + }; + let track_file = fsdy::TrackFile { + title: stem, + artist: String::new(), + duration: None, + album: None, + playable, + }; + tokio::fs::write(&sidecar, track_file.to_toml()?).await?; + Ok(true) +} + +/// Whether `path`'s extension is a known audio extension (case-insensitive). +fn is_audio_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .map(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn guard_writes_the_role_hash_and_preserves_others() { + let dir = TempDir::new().expect("tempdir"); + write_role_hash(dir.path(), Role::Owner, "$argon2id$owner".to_string()).expect("owner"); + write_role_hash(dir.path(), Role::Appender, "$argon2id$app".to_string()).expect("app"); + let settings = ServerSettings::load(dir.path()).expect("reload"); + assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$owner")); + assert_eq!( + settings.auth.queue_appender.as_deref(), + Some("$argon2id$app") + ); + assert!(settings.auth.queue_owner.is_none()); + } + + #[tokio::test] + async fn scan_writes_file_playables_and_skips_existing_tomls() { + let dir = TempDir::new().expect("tempdir"); + let sub = dir.path().join("album"); + std::fs::create_dir(&sub).expect("mkdir"); + std::fs::write(sub.join("song.flac"), b"AUDIO").expect("write"); + std::fs::write(dir.path().join("cover.jpg"), b"jpg").expect("write"); + std::fs::write(dir.path().join(".hidden.mp3"), b"x").expect("write"); + + let summary = scan_dir(dir.path(), None, false).await.expect("scan"); + assert_eq!(summary.written, 1); + assert_eq!(summary.skipped, 0); + + let sidecar = sub.join("song.cbd-track.toml"); + assert!(sidecar.is_file()); + let text = std::fs::read_to_string(&sidecar).expect("read"); + let track = fsdy::TrackFile::parse(&text).expect("parse"); + assert_eq!(track.title, "song"); + assert_eq!( + track.playable().expect("playable"), + fsdy::Playable::File(PathBuf::from("song.flac")) + ); + + // A re-scan leaves the hand-written toml untouched. + let again = scan_dir(dir.path(), None, false).await.expect("rescan"); + assert_eq!(again.written, 0); + assert_eq!(again.skipped, 1); + } + + #[tokio::test] + async fn scan_capture_ingests_into_the_store_and_points_the_toml_there() { + let src = TempDir::new().expect("srcdir"); + std::fs::write(src.path().join("a.flac"), b"AUDIO").expect("write"); + let store_dir = TempDir::new().expect("storedir"); + let store = CrabidyStore::open( + store_dir.path().join("state"), + store_dir.path().join("store"), + ) + .await + .expect("open store"); + + let summary = scan_dir(src.path(), Some(&store), false) + .await + .expect("capture scan"); + assert_eq!(summary.written, 1); + // Source stays (copy), the store has the audio, the toml points there. + assert!(src.path().join("a.flac").is_file()); + let text = std::fs::read_to_string(src.path().join("a.cbd-track.toml")).expect("read"); + let track = fsdy::TrackFile::parse(&text).expect("parse"); + match track.playable().expect("playable") { + fsdy::Playable::Store(name) => assert!(store.store_dir().join(&name).is_file()), + other => panic!("expected a store playable, got {other:?}"), + } + } + + #[tokio::test] + async fn scan_move_removes_the_source_audio() { + let src = TempDir::new().expect("srcdir"); + std::fs::write(src.path().join("b.mp3"), b"BYTES").expect("write"); + let store_dir = TempDir::new().expect("storedir"); + let store = CrabidyStore::open( + store_dir.path().join("state"), + store_dir.path().join("store"), + ) + .await + .expect("open store"); + + scan_dir(src.path(), Some(&store), true) + .await + .expect("move scan"); + // The original audio is gone; only the toml remains beside it. + assert!(!src.path().join("b.mp3").exists()); + assert!(src.path().join("b.cbd-track.toml").is_file()); + } +} diff --git a/crabidy-server/src/crabidy_store.rs b/crabidy-server/src/crabidy_store.rs index bab5a15..3e17d59 100644 --- a/crabidy-server/src/crabidy_store.rs +++ b/crabidy-server/src/crabidy_store.rs @@ -640,6 +640,79 @@ impl CrabidyStore { Ok(false) } + /// Ingests a local audio file into the content store and returns its + /// store name — the local-source half of [`Self::capture_track`], factored + /// out for the `scan --capture`/`--move` CLI (architecture/cli.md D5). + /// + /// Hashes the file, de-duplicates by content hash (identical bytes reuse + /// the existing entry), and otherwise copies (or, with `move_it`, moves) + /// the audio into the store beside a freshly written `.cbd-store.toml` + /// sidecar. Idempotent: re-ingesting the same bytes yields the same name + /// and creates no second entry. With `move_it` the source file is removed + /// even on a de-dup hit, so the caller's original location keeps only its + /// `.cbd-track.toml`. + pub async fn ingest_file(&self, path: &Path, move_it: bool) -> Result { + match tokio::fs::metadata(path).await { + Ok(meta) if meta.is_file() => {} + _ => { + return Err(CaptureError::BadSource(format!( + "{} is not a readable file", + path.display() + ))) + } + } + let hash = hash_file(path).await?; + + // De-dup by content hash: identical bytes already in the store. + let by_hash = self.index.lock().await.by_hash(&hash).cloned(); + if let Some(name) = by_hash { + if move_it { + let _ = tokio::fs::remove_file(path).await; + } + return Ok(name); + } + + // New store entry: choose a bare name from the source file's name. + let natural = path + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .unwrap_or_else(|| "track.bin".to_string()); + let name = self.unique_store_name(&natural).await; + if move_it { + // A cross-device move is not a rename; fall back to copy+remove. + match tokio::fs::rename(path, self.store_root.join(&name)).await { + Ok(()) => {} + Err(_) => { + tokio::fs::copy(path, self.store_root.join(&name)).await?; + let _ = tokio::fs::remove_file(path).await; + } + } + } else { + tokio::fs::copy(path, self.store_root.join(&name)).await?; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&name) + .to_string(); + let sidecar = StoreSidecar { + hash, + providers: vec![ProviderEntry { + provider: "file".to_string(), + id: String::new(), + title: stem, + artist: String::new(), + duration: None, + album: None, + aliases: Vec::new(), + }], + }; + self.write_sidecar(&name, &sidecar).await?; + self.index.lock().await.insert(&name, &sidecar); + Ok(name) + } + /// The store entry name a local path resolves to, if it lives inside the /// store root (a `` bare file). Used to detect already-captured /// sources. @@ -1312,6 +1385,52 @@ mod tests { )); } + #[tokio::test] + async fn ingest_file_copies_into_the_store_and_dedups_by_hash() { + let (store, dir) = open_store().await; + let a = dir.path().join("a.flac"); + std::fs::write(&a, b"AUDIO").expect("write"); + let name = store.ingest_file(&a, false).await.expect("ingest"); + // One audio + one sidecar; the source stays put (copy). + assert_eq!(entries(store.store_dir()).len(), 2); + assert!(a.is_file(), "copy leaves the source"); + assert!(store.store_dir().join(&name).is_file()); + + // Identical bytes from a different file de-dup to the same entry. + let b = dir.path().join("b.flac"); + std::fs::write(&b, b"AUDIO").expect("write"); + let name2 = store.ingest_file(&b, false).await.expect("ingest dup"); + assert_eq!(name, name2); + assert_eq!(entries(store.store_dir()).len(), 2, "no second entry"); + } + + #[tokio::test] + async fn ingest_file_move_removes_the_source() { + let (store, dir) = open_store().await; + let a = dir.path().join("song.mp3"); + std::fs::write(&a, b"MOVED").expect("write"); + let name = store.ingest_file(&a, true).await.expect("ingest move"); + assert!(!a.exists(), "move removes the source"); + assert!(store.store_dir().join(&name).is_file()); + + // A move that de-dups still removes the source. + let b = dir.path().join("copy.mp3"); + std::fs::write(&b, b"MOVED").expect("write"); + let name2 = store.ingest_file(&b, true).await.expect("dedup move"); + assert_eq!(name, name2); + assert!(!b.exists(), "de-dup move still removes the source"); + } + + #[tokio::test] + async fn ingest_file_rejects_a_missing_source() { + let (store, dir) = open_store().await; + let missing = dir.path().join("nope.flac"); + assert!(matches!( + store.ingest_file(&missing, false).await, + Err(CaptureError::BadSource(_)) + )); + } + #[tokio::test] async fn annotate_marks_tracks_present_in_the_store() { let (store, dir) = open_store().await; diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index 5e0e066..e9530fe 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod web; pub mod capture; +pub mod cli; pub mod crabidy_store; pub mod playback; pub mod provider; diff --git a/crabidy-server/src/main.rs b/crabidy-server/src/main.rs index 74f1c1a..6223b41 100644 --- a/crabidy-server/src/main.rs +++ b/crabidy-server/src/main.rs @@ -1,48 +1,60 @@ -//! The standalone server binary: stderr tracing plus -//! [`crabidy_server::serve`] on the fixed listen address. The whole stack -//! lives in the library so the bundled `cbd` binary can host it too -//! (architecture/cbd-bundle.md D1). +//! The standalone server binary: a clap-derive CLI +//! ([`cbd_cli::ServerCli`]). With no subcommand it runs the server (stderr +//! tracing plus [`crabidy_server::serve`] on the fixed listen address), +//! exactly as before; subcommands cover role setup (`guard`), folder +//! indexing (`scan`), remote control (`library`/`queue`/`global`), and shell +//! completions. The whole server stack lives in the library so the bundled +//! `cbd` binary can host it too (architecture/cbd-bundle.md D1). -use clap::Parser; +use cbd_cli::{RemoteCmd, ServerCli, ServerCommand}; +use clap::{CommandFactory, Parser}; +use crabidy_server::cli; use tracing_subscriber::{prelude::*, EnvFilter}; -#[derive(Parser)] -#[command(author, version, about)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(clap::Subcommand)] -enum Command { - /// Hash a password for the `[auth]` section of crabidy-server.toml - /// (architecture/roles-auth.md). Reads the password as one line - /// from stdin and prints the PHC string — nothing else, so output - /// can be piped. The password itself is never printed or logged. - HashPassword, -} - #[tokio::main] async fn main() -> Result<(), Box> { - let cli = Cli::parse(); - if let Some(Command::HashPassword) = cli.command { - return hash_password(); + let cli = ServerCli::parse(); + match cli.command { + // No subcommand: run the server, exactly as before. + None => { + let _log_guard = init_tracing(); + crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?; + Ok(()) + } + // Subcommands are one-shot CLI actions: a failure prints a short + // message and exits non-zero, never a color-eyre report chain. + Some(command) => { + if let Err(err) = run_command(&cli.remote, command).await { + eprintln!("error: {err}"); + std::process::exit(1); + } + Ok(()) + } } - let _log_guard = init_tracing(); - crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?; - Ok(()) } -/// Reads one line from stdin and prints its argon2 PHC hash. -fn hash_password() -> Result<(), Box> { - let mut password = String::new(); - std::io::stdin().read_line(&mut password)?; - let password = password.trim_end_matches(['\r', '\n']); - if password.is_empty() { - return Err("empty password".into()); +/// Dispatches a server subcommand. +async fn run_command( + remote: &cbd_cli::RemoteArgs, + command: ServerCommand, +) -> Result<(), Box> { + match command { + ServerCommand::Guard(args) => cli::guard(args).await, + ServerCommand::Scan(args) => cli::scan(args).await, + ServerCommand::Library(cmd) => { + cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await + } + ServerCommand::Queue(cmd) => { + cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Queue(cmd)).await + } + ServerCommand::Global(cmd) => { + cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Global(cmd)).await + } + ServerCommand::Completions(args) => { + cbd_cli::print_completions(args.shell, &mut ServerCli::command(), "crabidy-server"); + Ok(()) + } } - println!("{}", crabidy_server::auth::hash_password(password)?); - Ok(()) } /// Installs the global tracing subscriber. diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index 6736090..41b14aa 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -9,13 +9,13 @@ use std::path::Path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// The server config file name inside the crabidy config directory. pub const SETTINGS_FILE: &str = "crabidy-server.toml"; /// Contents of `crabidy-server.toml`. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ServerSettings { /// Role credentials; absent (or empty) means the server runs open. @@ -24,13 +24,16 @@ pub struct ServerSettings { } /// One PHC password hash per role; a role without a hash cannot -/// authenticate. Generate hashes with `crabidy-server hash-password`. +/// authenticate. Generate hashes with `crabidy-server guard `. /// Hashes are not passwords, but the file should stay private anyway. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AuthSettings { + #[serde(skip_serializing_if = "Option::is_none")] pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub queue_owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub queue_appender: Option, } @@ -59,6 +62,22 @@ impl ServerSettings { }; toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) } + + /// Serializes the current `[auth]` back to `crabidy-server.toml` in + /// `config_dir`, creating the directory if missing. + /// + /// The flat `[auth]` shape is preserved (each role a top-level key), so a + /// reload under `#[serde(deny_unknown_fields)]` still parses. Roles left + /// `None` are simply omitted — they cannot authenticate, exactly as a + /// missing key. The password never appears here (only the PHC hash). + pub fn store(&self, config_dir: &Path) -> Result<(), String> { + std::fs::create_dir_all(config_dir) + .map_err(|err| format!("cannot create {}: {err}", config_dir.display()))?; + let text = toml::to_string_pretty(self) + .map_err(|err| format!("cannot serialize server settings: {err}"))?; + let file = config_dir.join(SETTINGS_FILE); + std::fs::write(&file, text).map_err(|err| format!("cannot write {}: {err}", file.display())) + } } #[cfg(test)] @@ -90,6 +109,40 @@ mod tests { assert!(settings.auth.owner.is_none()); } + #[test] + fn store_sets_one_role_and_preserves_the_others() { + let dir = TempDir::new().expect("tempdir"); + // Seed a file with two roles already set. + std::fs::write( + dir.path().join(SETTINGS_FILE), + "[auth]\nowner = \"$argon2id$owner\"\nqueue_owner = \"$argon2id$qo\"\n", + ) + .expect("seed"); + let mut settings = ServerSettings::load(dir.path()).expect("load"); + // Set the third role and write it back. + settings.auth.queue_appender = Some("$argon2id$appender".to_string()); + settings.store(dir.path()).expect("store"); + + // Reload: all three roles present, still parses under deny_unknown. + let reloaded = ServerSettings::load(dir.path()).expect("reload"); + assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$owner")); + assert_eq!(reloaded.auth.queue_owner.as_deref(), Some("$argon2id$qo")); + assert_eq!( + reloaded.auth.queue_appender.as_deref(), + Some("$argon2id$appender") + ); + } + + #[test] + fn store_creates_the_config_dir_when_missing() { + let dir = TempDir::new().expect("tempdir"); + let nested = dir.path().join("config").join("crabidy"); + let mut settings = ServerSettings::default(); + settings.auth.owner = Some("$argon2id$x".to_string()); + settings.store(&nested).expect("store creates dir"); + assert!(nested.join(SETTINGS_FILE).is_file()); + } + #[test] fn a_broken_file_is_a_startup_error_not_an_open_server() { let dir = TempDir::new().expect("tempdir"); diff --git a/devenv.nix b/devenv.nix index 8891ad1..05b0ad1 100644 --- a/devenv.nix +++ b/devenv.nix @@ -64,6 +64,15 @@ in scripts.serve-web.exec = '' cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@" ''; + # Generates the CLI shell completions and man pages into dist/ by building + # the native binaries with CBD_ASSET_DIR set (architecture/cli.md D7). Each + # binary's build.rs copies its assets into $CBD_ASSET_DIR when present, so + # dist/completions/** and dist/man/*.1 appear after this runs. Unlike + # build-web (a wasm build) this is a native build, so RUSTFLAGS is left + # alone — the mold linker flag is correct for the native toolchain. + scripts.gen-cli-assets.exec = '' + cd "$DEVENV_ROOT" && CBD_ASSET_DIR="$DEVENV_ROOT/dist" cargo build "$@" + ''; enterShell = ""; diff --git a/plan/cli.md b/plan/cli.md index 076555d..6c81b17 100644 --- a/plan/cli.md +++ b/plan/cli.md @@ -13,58 +13,58 @@ gate(s)/test(s) it satisfies. ## Remote executor (`cbd-cli` `client` feature) -- [ ] Implement `run_remote`: dispatch every `LibraryCmd`/`QueueCmd`/`GlobalCmd` +- [x] Implement `run_remote`: dispatch every `LibraryCmd`/`QueueCmd`/`GlobalCmd` to its RPC via the generated client; pretty-print listings and the queue; concise messages for mutations. Add the missing `Stop` call. _(Gates: remote commands. Tests: a request-mapping unit test per group against a fake tonic server, or at least argument-parse tests for the clap tree.)_ -- [ ] Map gRPC `Status`/transport errors to a short `color-eyre`/`eyre` report +- [x] Map gRPC `Status`/transport errors to a short `color-eyre`/`eyre` report (no chain dump for "unreachable"). _(Gate: remote errors.)_ ## Config writers -- [ ] Server: `ServerSettings::store(config_dir)` round-trips `[auth]` +- [x] Server: `ServerSettings::store(config_dir)` round-trips `[auth]` (preserve other roles, flat shape). _(Gate: guard config write. Test: set one role, reload, other roles intact.)_ -- [ ] Client: a `Config` writer (load/modify/save `cbd-tui.toml`/`cbd.toml`). +- [x] Client: a `Config` writer (load/modify/save `cbd-tui.toml`/`cbd.toml`). _(Gate: auth. Test: round-trip user/password/address.)_ ## Server binary (`crabidy-server`) -- [ ] Replace the `Cli`/`HashPassword` with `cbd_cli::ServerCli`. No subcommand +- [x] Replace the `Cli`/`HashPassword` with `cbd_cli::ServerCli`. No subcommand → `serve()` (unchanged). _(Gate: no-subcommand default.)_ -- [ ] `guard`: hash (stdin fallback), print PHC, write config unless +- [x] `guard`: hash (stdin fallback), print PHC, write config unless `--no-config`. Remove `hash-password`. _(Gate: guard.)_ -- [ ] `scan`: walk + write tomls; `--capture`/`--move` via a new +- [x] `scan`: walk + write tomls; `--capture`/`--move` via a new `CrabidyStore::ingest_file(path, move) -> StoreName` (factor the local-source half of the D4 capture flow). _(Gate: scan. Tests: toml written; capture dedups; move removes source; existing toml skipped.)_ -- [ ] `library`/`queue`/`global` → `cbd_cli::run_remote` (enable `cbd-cli` +- [x] `library`/`queue`/`global` → `cbd_cli::run_remote` (enable `cbd-cli` `client`). `completions` → `print_completions`. ## Client binary (`cbd-tui`) and `cbd` -- [ ] Replace ClapSerde parsing: parse `cbd_cli::TuiCli`; load the TOML config +- [x] Replace ClapSerde parsing: parse `cbd_cli::TuiCli`; load the TOML config and apply `remote`/`spectrum` overrides; no subcommand → `run(config)`. _(Gate: parsing/defaults. Tests: flag overrides file; first-run writes file.)_ -- [ ] `auth` writes the client config; `library`/`queue`/`global` → +- [x] `auth` writes the client config; `library`/`queue`/`global` → `run_remote`; `completions` prints. -- [ ] `cbd`: parse `cbd_cli::CbdCli`; no subcommand → server + TUI (unchanged); +- [x] `cbd`: parse `cbd_cli::CbdCli`; no subcommand → server + TUI (unchanged); subcommands dispatch to the server (guard/scan), client (auth), or remote (library/queue/global) paths. _(Gate: cbd union.)_ ## Assets + build -- [ ] `build.rs` in each binary: build-dep `cbd-cli` (default features), call +- [x] `build.rs` in each binary: build-dep `cbd-cli` (default features), call `generate_assets` for its `Command` into `OUT_DIR` and, when `CBD_ASSET_DIR` is set, into that dir. _(Gate: assets.)_ -- [ ] devenv `gen-cli-assets` script: `CBD_ASSET_DIR=$PWD/dist cargo build`. +- [x] devenv `gen-cli-assets` script: `CBD_ASSET_DIR=$PWD/dist cargo build`. _(Gate: assets.)_ ## Docs + verification -- [ ] README: a CLI section (subcommands per binary, completions/man, the +- [x] README: a CLI section (subcommands per binary, completions/man, the password-in-argv note); update the config section for `auth`/`guard`. -- [ ] `plan/summary.md`: what was built + deviations. -- [ ] `cargo test --workspace` green; clippy `-D warnings`, fmt, markdownlint +- [x] `plan/summary.md`: what was built + deviations. +- [x] `cargo test --workspace` green; clippy `-D warnings`, fmt, markdownlint clean (via `devenv shell`). diff --git a/plan/summary.md b/plan/summary.md index 2b992bd..26fd6e3 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -880,3 +880,64 @@ Deviations from the plan/architecture: (stage 2) then implemented (stage 5). - Store **garbage collection** stays out of scope (D10): deleting a toml never reclaims store audio, so orphans can accumulate. + +## The CLI (architecture/cli.md) + +A comprehensive clap-derive CLI across all three binaries, sharing one +command surface via the `cbd-cli` crate. + +### What was built + +- **`cbd-cli` executor** (`client` feature): `run_remote` dispatches every + `LibraryCmd`/`QueueCmd`/`GlobalCmd` variant to its gRPC call, mirroring + `cbd-tui`'s `RpcClient` request construction (with a direct `Stop` call, + which had no wrapper). Listings and the queue print human-readably; + captured rows are marked `*`. The endpoint has a 5 s connect timeout and + a 30 s per-request deadline, so a CLI never hangs. Transport/`Status` + errors map to a single-line message (no color-eyre chain). +- **Config writers**: `ServerSettings::store` round-trips `[auth]` + (`skip_serializing_if` keeps the flat shape parseable under + `deny_unknown_fields`); `cbd-tui`'s `config` module gained + `load_first_run`, `apply_overrides`, `store`, and `write_auth` (plus + path-based `_at` variants for tests). +- **`crabidy-server`**: `main` now parses `cbd_cli::ServerCli`; a new + `crabidy_server::cli` module holds `guard`, `scan`, `scan_dir`, + `write_role_hash`, and `connection`. `guard` prints the PHC hash to + stdout (pipeable) and the confirmation to stderr. `scan` walks a folder + (bounded, hidden/symlink-skipped) writing `.cbd-track.toml` sidecars; + `--capture`/`--move` use the new `CrabidyStore::ingest_file`. + `hash-password` was removed (`guard --no-config` replaces it). +- **`CrabidyStore::ingest_file`**: the local-source half of + `capture_track`, factored out — hash, de-dup by content hash, copy or + move into the store, write the sidecar, return the store name. +- **Clients**: `cbd-tui` and `cbd` parse their `cbd_cli` CLIs; the + no-subcommand path loads the TOML config (defaults written on first + run) and applies the flag overrides, then runs as before. `auth` writes + the client config; `library`/`queue`/`global` call `run_remote`. +- **Assets**: each binary crate has a `build.rs` that build-depends on + `cbd-cli` (default features only) and writes completions + a man page + into `OUT_DIR`, and into `$CBD_ASSET_DIR` when set. A devenv + `gen-cli-assets` script produces `dist/completions/**` and `dist/man`. + +### Deviations + +- **ClapSerde kept, not replaced.** Architecture D2 floated replacing + ClapSerde with plain serde. Instead the `Config` still derives + `ClapSerde` (so `Config::default()`/`Opt` load the file), and the new + `config` helpers read/write it *without* `merge_clap` — argv is parsed + by `cbd_cli::TuiCli`/`CbdCli` and applied via `apply_overrides`. This + keeps the on-disk schema byte-identical to what `init_config` wrote + (verified: the file is `[server]`-nested; the README example was + corrected to match). +- **Connection flags are top-level, not clap-`global`.** They must come + before the subcommand (`cbd-tui --address X queue show`). Making them + `global=true` would collide with `auth`'s own `--address`. Documented + in the README. +- **`Box` for CLI reports**, not color-eyre, following the + existing binaries' convention; errors are printed as one line with a + non-zero exit. +- **No fake-tonic-server test.** Coverage is argument-parse tests + (`cbd-cli`), config-writer round-trips (`settings`, `cbd-tui::config`), + and `scan`/`ingest_file` behaviour (`crabidy_server::cli`, + `crabidy_store`). The unreachable-server error path was verified + manually (concise message, exit 1). diff --git a/quality/cli.md b/quality/cli.md index 640ecb1..ca2cc00 100644 --- a/quality/cli.md +++ b/quality/cli.md @@ -6,72 +6,74 @@ checked as the implement stage verifies them; deviations noted inline. ## Parsing and defaults -- [ ] Every binary (`crabidy-server`, `cbd-tui`, `cbd`) parses with clap-derive; +- [x] Every binary (`crabidy-server`, `cbd-tui`, `cbd`) parses with clap-derive; `--help`, `--version`, and per-subcommand `--help` work. -- [ ] **No subcommand preserves today's behavior**: `crabidy-server` runs the +- [x] **No subcommand preserves today's behavior**: `crabidy-server` runs the server, `cbd-tui` runs the TUI, `cbd` runs the in-process server + TUI. No extra output, no behavior change on the default path. -- [ ] The client config still writes a defaults file on first run, and a +- [x] The client config still writes a defaults file on first run, and a provided `--address/--user/--password/--spectrum` overrides the file value; omitted flags fall back to the file. The `cbd-tui.toml`/`cbd.toml` schema is unchanged (same keys). ## `guard` (server) -- [ ] Prints the argon2id PHC hash to stdout and nothing else on the hash line +- [x] Prints the argon2id PHC hash to stdout and nothing else on the hash line (pipe-friendly); the password is never logged. -- [ ] Without `--no-config`, writes the hash into the correct `[auth]` field +- [x] Without `--no-config`, writes the hash into the correct `[auth]` field (`owner`/`queue_owner`/`queue_appender`) of `crabidy-server.toml`, creating the file if missing and **preserving the other roles** and the flat shape (`deny_unknown_fields` still parses the result). -- [ ] `--no-config` only prints (the exact replacement for the old +- [x] `--no-config` only prints (the exact replacement for the old `hash-password`). -- [ ] A missing password argument reads one line from stdin. +- [x] A missing password argument reads one line from stdin. ## `scan` (server) -- [ ] Walks the path (bounded, skips hidden), selects files by audio extension, +- [x] Walks the path (bounded, skips hidden), selects files by audio extension, and writes a `.cbd-track.toml` beside each with a `Playable::File` pointing at the file's own name; an existing toml is left untouched (warned). -- [ ] `--capture` ingests each file into the content store (hash + de-dup + +- [x] `--capture` ingests each file into the content store (hash + de-dup + sidecar) and writes a `Playable::Store` toml instead; re-scanning de-dups. -- [ ] `--move` moves the source into the store instead of copying; the original +- [x] `--move` moves the source into the store instead of copying; the original location keeps only the toml. `--capture`/`--move` require a store dir. -- [ ] No panic on unreadable files/dirs; each defect is a warning, the walk +- [x] No panic on unreadable files/dirs; each defect is a warning, the walk continues. ## `auth` (client) -- [ ] Writes `user` (role name) and `password` (cleartext), and `address` when +- [x] Writes `user` (role name) and `password` (cleartext), and `address` when given, into the client config, preserving other fields; creates the file if missing. The help text says the password is stored in plaintext. ## `library` / `queue` / `global` (remote) -- [ ] Each subcommand maps to the documented RPC (including a new `Stop` +- [x] Each subcommand maps to the documented RPC (including a new `Stop` wrapper); `library list` prints child nodes and tracks (captured rows marked). -- [ ] Connects with basic-auth from `--user/--password` (config fallback); an +- [x] Connects with basic-auth from `--user/--password` (config fallback); an empty user talks to an open server with no header. -- [ ] A server/RPC error exits non-zero with a readable message; no internal +- [x] A server/RPC error exits non-zero with a readable message; no internal report (color-eyre chain) is dumped for an ordinary "server unreachable" or a gRPC status — those map to a concise message. -- [ ] The commands are available on `crabidy-server`, `cbd-tui`, and `cbd` +- [x] The commands are available on `crabidy-server`, `cbd-tui`, and `cbd` (cbd = union of server + client commands). ## Assets -- [ ] `clap_complete` + `clap_mangen` generate bash/zsh/fish completions and a +- [x] `clap_complete` + `clap_mangen` generate bash/zsh/fish completions and a man page for each binary from its top-level `Command` in `build.rs` (`OUT_DIR`), and into `$CBD_ASSET_DIR` when set. -- [ ] `build.rs` build-depends on `cbd-cli` with **default features only** (no +- [x] `build.rs` build-depends on `cbd-cli` with **default features only** (no tonic on ordinary builds). -- [ ] A `completions ` subcommand prints a script to stdout. -- [ ] A devenv `gen-cli-assets` script produces `dist/completions/**` and +- [x] A `completions ` subcommand prints a script to stdout. +- [x] A devenv `gen-cli-assets` script produces `dist/completions/**` and `dist/man/*.1`. ## Errors and safety -- [ ] Library errors are `thiserror`; CLI reports use `color-eyre`; no panics on +- [x] Library errors are `thiserror`; CLI reports use `color-eyre`; no panics on bad input, missing config, or an unreachable server. -- [ ] Passwords never appear in logs or error messages; the argv-exposure of a + _(Deviation: CLI reports use `Box`, matching the existing + binaries' convention, not `color-eyre` — see plan/summary.md.)_ +- [x] Passwords never appear in logs or error messages; the argv-exposure of a password argument is documented and a stdin path is offered.