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
|
/target
|
||||||
|
|
||||||
|
# generated CLI assets (gen-cli-assets writes here)
|
||||||
|
/dist
|
||||||
|
|
||||||
# devenv
|
# devenv
|
||||||
.devenv*
|
.devenv*
|
||||||
devenv.local.nix
|
devenv.local.nix
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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`.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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};
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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::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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
|
|
||||||
|
|
|
||||||
32
plan/cli.md
32
plan/cli.md
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue