Implement the comprehensive CLI (stage 5)

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<dyn Error>
CLI reports per existing convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 12:13:51 +02:00
parent c4abadc8e4
commit 0a0d50f748
23 changed files with 1704 additions and 102 deletions

3
.gitignore vendored
View File

@ -2,6 +2,9 @@ target
/target /target
# generated CLI assets (gen-cli-assets writes here)
/dist
# devenv # devenv
.devenv* .devenv*
devenv.local.nix devenv.local.nix

7
Cargo.lock generated
View File

@ -654,7 +654,9 @@ dependencies = [
name = "cbd" name = "cbd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cbd-cli",
"cbd-tui", "cbd-tui",
"clap",
"crabidy-core", "crabidy-core",
"crabidy-server", "crabidy-server",
"dirs", "dirs",
@ -682,6 +684,8 @@ name = "cbd-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"cbd-cli",
"clap",
"crabidy-core", "crabidy-core",
"crossterm", "crossterm",
"dirs", "dirs",
@ -689,8 +693,10 @@ dependencies = [
"notify-rust", "notify-rust",
"ratatui", "ratatui",
"serde", "serde",
"tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"toml",
"tonic", "tonic",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
@ -1136,6 +1142,7 @@ dependencies = [
"axum", "axum",
"base64", "base64",
"blake3", "blake3",
"cbd-cli",
"clap", "clap",
"crabidy-core", "crabidy-core",
"dirs", "dirs",

View File

@ -76,6 +76,7 @@ server (e.g. a Raspberry Pi). A shared file would force one to follow
the other's `address`. the other's `address`.
```toml ```toml
[server]
# Where to find the server. Default (both files): localhost, which is # 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 # what cbd's own in-process server listens on. Point cbd-tui.toml at a
# remote server to use it as a remote control. # remote server to use it as a remote control.
@ -91,8 +92,16 @@ password = ""
spectrum = true spectrum = true
``` ```
Every option is also available as a command-line flag Every option is also available as a command-line flag before the
(`cbd-tui --address ...`, `cbd --address ...`). 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 ### `crabidy-server.toml` — roles and rights
@ -109,8 +118,8 @@ every RPC and hands out *roles* (see `architecture/roles-auth.md`):
```toml ```toml
[auth] [auth]
# One PHC hash per role; omit a role to disable it. Generate with: # One PHC hash per role; omit a role to disable it. Generate and store
# crabidy-server hash-password (reads the password from stdin) # a hash with: crabidy-server guard <role> (see the CLI section).
owner = "$argon2id$v=19$m=19456,t=2,p=1$..." owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$..." queue_owner = "$argon2id$..."
queue_appender = "$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 plain HTTP/2: fine on a trusted home network, but anything exposed
further needs TLS termination (reverse proxy, VPN) in front. 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 <role> [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 <path> [--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 <role> [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 ## Using the library
Navigation is vim-style: `j`/`k` select, `l` enters the selected Navigation is vim-style: `j`/`k` select, `l` enters the selected

View File

@ -1,9 +1,13 @@
//! The gRPC executor for the remote `library`/`queue`/`global` commands //! The gRPC executor for the remote `library`/`queue`/`global` commands
//! (`client` feature). Connects a generated `crabidy-core` client with a //! (`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 //! Transport and gRPC `Status` errors are mapped to a short one-line message
//! per-command dispatch is finalized by the implement stage. //! (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 base64::Engine;
use tonic::metadata::MetadataValue; use tonic::metadata::MetadataValue;
@ -11,9 +15,27 @@ use tonic::service::{interceptor::InterceptedService, Interceptor};
use tonic::transport::{Channel, Endpoint}; use tonic::transport::{Channel, Endpoint};
use tonic::{Request, Status}; 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 /// Attaches `authorization: Basic …` when a user is configured; an empty user
/// means an open server. The header value is a secret and never logged. /// means an open server. The header value is a secret and never logged.
@ -51,9 +73,13 @@ impl Interceptor for AuthInterceptor {
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>; type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
/// 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<Client, Box<dyn std::error::Error>> { async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>> {
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)?; let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?;
Ok(CrabidyServiceClient::with_interceptor( Ok(CrabidyServiceClient::with_interceptor(
endpoint, endpoint,
@ -61,12 +87,287 @@ async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>
)) ))
} }
/// 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<dyn std::error::Error> {
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. /// Runs one remote command against the server and prints the result.
pub async fn run_remote( pub async fn run_remote(
conn: &Connection, conn: &Connection,
cmd: RemoteCmd, cmd: RemoteCmd,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let _client = connect(conn).await?; let mut client = connect(conn).await?;
let _ = cmd; match cmd {
todo!("dispatch each Library/Queue/Global variant to its RPC and print") 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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
} }

View File

@ -304,3 +304,152 @@ pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, b
mod client; mod client;
#[cfg(feature = "client")] #[cfg(feature = "client")]
pub use client::run_remote; 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))
));
}
}

View File

@ -5,9 +5,12 @@ edition.workspace = true
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
cbd-cli = { workspace = true, features = ["client"] }
crabidy-core.workspace = true crabidy-core.workspace = true
crossterm.workspace = true crossterm.workspace = true
clap.workspace = true
dirs.workspace = true dirs.workspace = true
toml.workspace = true
flume.workspace = true flume.workspace = true
notify-rust.workspace = true notify-rust.workspace = true
ratatui.workspace = true ratatui.workspace = true
@ -18,3 +21,11 @@ tonic = { workspace = true, features = ["channel", "codegen"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.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

23
cbd-tui/build.rs Normal file
View File

@ -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}");
}
}
}

View File

@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde}; use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
#[derive(ClapSerde, Serialize, Debug)] #[derive(ClapSerde, Serialize, Debug)]
@ -8,6 +10,140 @@ pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
} }
/// The on-disk path of a client config file:
/// `dirs::config_dir()/crabidy/<file_name>`.
pub fn config_path(file_name: &str) -> Option<PathBuf> {
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<Config> {
let text = std::fs::read_to_string(path).ok()?;
match toml::from_str::<<Config as ClapSerde>::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<String>,
user: Option<String>,
password: Option<String>,
spectrum: Option<bool>,
) {
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<PathBuf, String> {
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<PathBuf, String> {
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)] #[derive(ClapSerde, Serialize, Debug)]
pub struct ServerConfig { pub struct ServerConfig {
/// Server address /// Server address
@ -34,3 +170,69 @@ pub struct ServerConfig {
#[clap(long)] #[clap(long)]
pub spectrum: bool, 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");
}
}

View File

@ -1,11 +1,19 @@
//! The standalone TUI binary: file-based tracing (the terminal belongs //! The standalone TUI binary: a clap-derive CLI ([`cbd_cli::TuiCli`]). With
//! to the UI), config init, and [`cbd_tui::run`]. All client logic lives //! no subcommand it loads the client config (writing defaults on first run),
//! in the library so the bundled `cbd` binary can host it too //! applies the `--address/--user/--password/--spectrum` overrides, and runs
//! (architecture/cbd-bundle.md D1). //! 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 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<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
@ -43,7 +51,80 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _log_guard = init_tracing(); let cli = TuiCli::parse();
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml")); match cli.command {
cbd_tui::run(config).await // 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<dyn std::error::Error>> {
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),
}
} }

View File

@ -4,7 +4,9 @@ version.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
cbd-cli = { workspace = true, features = ["client"] }
cbd-tui.workspace = true cbd-tui.workspace = true
clap.workspace = true
crabidy-core.workspace = true crabidy-core.workspace = true
crabidy-server.workspace = true crabidy-server.workspace = true
dirs.workspace = true dirs.workspace = true
@ -12,3 +14,8 @@ tokio = { workspace = true, features = ["full"] }
tracing.workspace = true tracing.workspace = true
tracing-appender.workspace = true tracing-appender.workspace = true
tracing-subscriber.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

23
cbd/build.rs Normal file
View File

@ -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}");
}
}
}

View File

@ -13,9 +13,15 @@ use std::error::Error;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Duration; 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}; 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<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
/// How long to wait for the server socket before giving up. Generous: /// 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] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
// `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<dyn Error>> {
// Both halves share one file-based subscriber: the terminal belongs // Both halves share one file-based subscriber: the terminal belongs
// to the TUI, so the server's usual stderr logging would corrupt it. // to the TUI, so the server's usual stderr logging would corrupt it.
let _log_guard = init_tracing(); let _log_guard = init_tracing();
@ -34,7 +59,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
// `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single // `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single
// shared `address` would force one to follow the other. `cbd` // shared `address` would force one to follow the other. `cbd`
// defaults to localhost, which matches its embedded server. // 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 addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?;
let mut server = tokio::spawn(crabidy_server::serve(addr)); let mut server = tokio::spawn(crabidy_server::serve(addr));
@ -49,6 +82,60 @@ async fn main() -> Result<(), Box<dyn Error>> {
cbd_tui::run(config).await 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<dyn Error>> {
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 /// Waits until something accepts TCP connections on the TUI's configured
/// server address (scheme stripped): the in-process server coming up, or /// server address (scheme stripped): the in-process server coming up, or
/// an already-running standalone one (in which case our `serve` fails /// an already-running standalone one (in which case our `serve` fails

View File

@ -27,6 +27,9 @@ realfft.workspace = true
tonic-web = { workspace = true, optional = true } tonic-web = { workspace = true, optional = true }
tower.workspace = true tower.workspace = true
audio-player.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 crabidy-core.workspace = true
dirs.workspace = true dirs.workspace = true
flume.workspace = true flume.workspace = true
@ -52,3 +55,9 @@ base64.workspace = true
http.workspace = true http.workspace = true
tempfile.workspace = true tempfile.workspace = true
tower.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

View File

@ -8,6 +8,32 @@
use std::path::Path; use std::path::Path;
fn main() { 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). // Rerun when the bundle changes (or appears).
println!("cargo:rerun-if-changed=../cbd-web/dist"); println!("cargo:rerun-if-changed=../cbd-web/dist");
if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() { if std::env::var_os("CARGO_FEATURE_WEB_UI").is_none() {

346
crabidy-server/src/cli.rs Normal file
View File

@ -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<PathBuf, Box<dyn Error>> {
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<String>) -> Result<String, Box<dyn Error>> {
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 <role> [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<dyn Error>> {
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 <path> [--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<dyn Error>> {
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
/// `<stem>.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<ScanSummary, Box<dyn Error>> {
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<bool, Box<dyn Error>> {
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());
}
}

View File

@ -640,6 +640,79 @@ impl CrabidyStore {
Ok(false) 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 `<name>.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<StoreName, CaptureError> {
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 /// The store entry name a local path resolves to, if it lives inside the
/// store root (a `<name>` bare file). Used to detect already-captured /// store root (a `<name>` bare file). Used to detect already-captured
/// sources. /// 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] #[tokio::test]
async fn annotate_marks_tracks_present_in_the_store() { async fn annotate_marks_tracks_present_in_the_store() {
let (store, dir) = open_store().await; let (store, dir) = open_store().await;

View File

@ -3,6 +3,7 @@ pub mod auth;
pub mod web; pub mod web;
pub mod capture; pub mod capture;
pub mod cli;
pub mod crabidy_store; pub mod crabidy_store;
pub mod playback; pub mod playback;
pub mod provider; pub mod provider;

View File

@ -1,48 +1,60 @@
//! The standalone server binary: stderr tracing plus //! The standalone server binary: a clap-derive CLI
//! [`crabidy_server::serve`] on the fixed listen address. The whole stack //! ([`cbd_cli::ServerCli`]). With no subcommand it runs the server (stderr
//! lives in the library so the bundled `cbd` binary can host it too //! tracing plus [`crabidy_server::serve`] on the fixed listen address),
//! (architecture/cbd-bundle.md D1). //! 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}; use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser)]
#[command(author, version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[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] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cli = Cli::parse(); let cli = ServerCli::parse();
if let Some(Command::HashPassword) = cli.command { match cli.command {
return hash_password(); // 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. /// Dispatches a server subcommand.
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn run_command(
let mut password = String::new(); remote: &cbd_cli::RemoteArgs,
std::io::stdin().read_line(&mut password)?; command: ServerCommand,
let password = password.trim_end_matches(['\r', '\n']); ) -> Result<(), Box<dyn std::error::Error>> {
if password.is_empty() { match command {
return Err("empty password".into()); 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. /// Installs the global tracing subscriber.

View File

@ -9,13 +9,13 @@
use std::path::Path; use std::path::Path;
use serde::Deserialize; use serde::{Deserialize, Serialize};
/// The server config file name inside the crabidy config directory. /// The server config file name inside the crabidy config directory.
pub const SETTINGS_FILE: &str = "crabidy-server.toml"; pub const SETTINGS_FILE: &str = "crabidy-server.toml";
/// Contents of `crabidy-server.toml`. /// Contents of `crabidy-server.toml`.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ServerSettings { pub struct ServerSettings {
/// Role credentials; absent (or empty) means the server runs open. /// 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 /// 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 <role>`.
/// Hashes are not passwords, but the file should stay private anyway. /// Hashes are not passwords, but the file should stay private anyway.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthSettings { pub struct AuthSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>, pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_owner: Option<String>, pub queue_owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_appender: Option<String>, pub queue_appender: Option<String>,
} }
@ -59,6 +62,22 @@ impl ServerSettings {
}; };
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display())) 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)] #[cfg(test)]
@ -90,6 +109,40 @@ mod tests {
assert!(settings.auth.owner.is_none()); 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] #[test]
fn a_broken_file_is_a_startup_error_not_an_open_server() { fn a_broken_file_is_a_startup_error_not_an_open_server() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");

View File

@ -64,6 +64,15 @@ in
scripts.serve-web.exec = '' scripts.serve-web.exec = ''
cd "$DEVENV_ROOT/cbd-web" && RUSTFLAGS="" trunk serve "$@" 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 = ""; enterShell = "";

View File

@ -13,58 +13,58 @@ gate(s)/test(s) it satisfies.
## Remote executor (`cbd-cli` `client` feature) ## 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; to its RPC via the generated client; pretty-print listings and the queue;
concise messages for mutations. Add the missing `Stop` call. _(Gates: remote concise messages for mutations. Add the missing `Stop` call. _(Gates: remote
commands. Tests: a request-mapping unit test per group against a fake tonic commands. Tests: a request-mapping unit test per group against a fake tonic
server, or at least argument-parse tests for the clap tree.)_ 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.)_ (no chain dump for "unreachable"). _(Gate: remote errors.)_
## Config writers ## 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: (preserve other roles, flat shape). _(Gate: guard config write. Test:
set one role, reload, other roles intact.)_ 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.)_ _(Gate: auth. Test: round-trip user/password/address.)_
## Server binary (`crabidy-server`) ## 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.)_ `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.)_ `--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 `CrabidyStore::ingest_file(path, move) -> StoreName` (factor the local-source
half of the D4 capture flow). _(Gate: scan. Tests: toml written; capture half of the D4 capture flow). _(Gate: scan. Tests: toml written; capture
dedups; move removes source; existing toml skipped.)_ 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`). `completions``print_completions`.
## Client binary (`cbd-tui`) and `cbd` ## 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)`. and apply `remote`/`spectrum` overrides; no subcommand → `run(config)`.
_(Gate: parsing/defaults. Tests: flag overrides file; first-run writes file.)_ _(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. `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 subcommands dispatch to the server (guard/scan), client (auth), or remote
(library/queue/global) paths. _(Gate: cbd union.)_ (library/queue/global) paths. _(Gate: cbd union.)_
## Assets + build ## 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` `generate_assets` for its `Command` into `OUT_DIR` and, when `CBD_ASSET_DIR`
is set, into that dir. _(Gate: assets.)_ 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.)_ _(Gate: assets.)_
## Docs + verification ## 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`. password-in-argv note); update the config section for `auth`/`guard`.
- [ ] `plan/summary.md`: what was built + deviations. - [x] `plan/summary.md`: what was built + deviations.
- [ ] `cargo test --workspace` green; clippy `-D warnings`, fmt, markdownlint - [x] `cargo test --workspace` green; clippy `-D warnings`, fmt, markdownlint
clean (via `devenv shell`). clean (via `devenv shell`).

View File

@ -880,3 +880,64 @@ Deviations from the plan/architecture:
(stage 2) then implemented (stage 5). (stage 2) then implemented (stage 5).
- Store **garbage collection** stays out of scope (D10): deleting a toml - Store **garbage collection** stays out of scope (D10): deleting a toml
never reclaims store audio, so orphans can accumulate. 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 <role> --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<dyn Error>` 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).

View File

@ -6,72 +6,74 @@ checked as the implement stage verifies them; deviations noted inline.
## Parsing and defaults ## 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. `--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 server, `cbd-tui` runs the TUI, `cbd` runs the in-process server + TUI. No
extra output, no behavior change on the default path. 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; provided `--address/--user/--password/--spectrum` overrides the file value;
omitted flags fall back to the file. The `cbd-tui.toml`/`cbd.toml` schema is omitted flags fall back to the file. The `cbd-tui.toml`/`cbd.toml` schema is
unchanged (same keys). unchanged (same keys).
## `guard` (server) ## `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. (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 (`owner`/`queue_owner`/`queue_appender`) of `crabidy-server.toml`, creating the
file if missing and **preserving the other roles** and the flat shape file if missing and **preserving the other roles** and the flat shape
(`deny_unknown_fields` still parses the result). (`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`). `hash-password`).
- [ ] A missing password argument reads one line from stdin. - [x] A missing password argument reads one line from stdin.
## `scan` (server) ## `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 `<stem>.cbd-track.toml` beside each with a `Playable::File` and writes a `<stem>.cbd-track.toml` beside each with a `Playable::File`
pointing at the file's own name; an existing toml is left untouched (warned). 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. 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. 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. continues.
## `auth` (client) ## `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 given, into the client config, preserving other fields; creates the file if
missing. The help text says the password is stored in plaintext. missing. The help text says the password is stored in plaintext.
## `library` / `queue` / `global` (remote) ## `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). 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. 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 report (color-eyre chain) is dumped for an ordinary "server unreachable" or a
gRPC status — those map to a concise message. 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). (cbd = union of server + client commands).
## Assets ## 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` man page for each binary from its top-level `Command` in `build.rs`
(`OUT_DIR`), and into `$CBD_ASSET_DIR` when set. (`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). tonic on ordinary builds).
- [ ] A `completions <shell>` subcommand prints a script to stdout. - [x] A `completions <shell>` subcommand prints a script to stdout.
- [ ] A devenv `gen-cli-assets` script produces `dist/completions/**` and - [x] A devenv `gen-cli-assets` script produces `dist/completions/**` and
`dist/man/*.1`. `dist/man/*.1`.
## Errors and safety ## 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. 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<dyn Error>`, 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. password argument is documented and a stdin path is offered.