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:
parent
c4abadc8e4
commit
0a0d50f748
|
|
@ -2,6 +2,9 @@ target
|
|||
|
||||
/target
|
||||
|
||||
# generated CLI assets (gen-cli-assets writes here)
|
||||
/dist
|
||||
|
||||
# devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
78
README.md
78
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 <role> (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 <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
|
||||
|
||||
Navigation is vim-style: `j`/`k` select, `l` enters the selected
|
||||
|
|
|
|||
|
|
@ -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<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>> {
|
||||
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<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.
|
||||
pub async fn run_remote(
|
||||
conn: &Connection,
|
||||
cmd: RemoteCmd,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<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)]
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Config> = OnceLock::new();
|
||||
|
||||
|
|
@ -43,7 +51,80 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Config> = 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<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
|
||||
// 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<dyn Error>> {
|
|||
// `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<dyn Error>> {
|
|||
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
|
||||
/// server address (scheme stripped): the in-process server coming up, or
|
||||
/// an already-running standalone one (in which case our `serve` fails
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 `<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
|
||||
/// store root (a `<name>` 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 <role>`.
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub queue_owner: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub queue_appender: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
||||
|
|
|
|||
32
plan/cli.md
32
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`).
|
||||
|
|
|
|||
|
|
@ -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 <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).
|
||||
|
|
|
|||
|
|
@ -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 `<stem>.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 <shell>` subcommand prints a script to stdout.
|
||||
- [ ] A devenv `gen-cli-assets` script produces `dist/completions/**` and
|
||||
- [x] A `completions <shell>` 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<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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue