solid_pod_rs_server/cli/mod.rs
1//! Operator CLI subcommands — Sprint 11 rows 138, 163, 168.
2//!
3//! Three thin wrappers over library primitives:
4//!
5//! | Subcommand | JSS ref | Primitive |
6//! |--------------------------------------|------------------------|------------------------------------|
7//! | `quota reconcile <pod>` / `--all` | `bin/jss.js quota reconcile` | [`solid_pod_rs::quota::FsQuotaStore::reconcile`] |
8//! | `account delete <user-id>` | JSS #292 (`d9e56d8`) | [`solid_pod_rs_idp::UserStore::delete`] |
9//! | `invite create -u N [--expires-in]` | JSS #304 (`6578ab9`) | [`solid_pod_rs_idp::InviteStore`] |
10//!
11//! Every runner is async and takes an already-constructed store so
12//! the test harness can drive them with an in-memory double rather
13//! than a real filesystem / database.
14
15use clap::{Args, Subcommand};
16
17// ---------------------------------------------------------------------------
18// Public CLI surface
19// ---------------------------------------------------------------------------
20
21/// Operator subcommands. The binary entry point in `main.rs` parses
22/// this via `#[command(subcommand)]` alongside the existing
23/// server-run flags.
24#[derive(Debug, Subcommand)]
25pub enum OperatorCommand {
26 /// Quota operations — currently only `reconcile`.
27 #[command(subcommand)]
28 Quota(QuotaCommand),
29
30 /// Account lifecycle — currently only `delete`.
31 #[command(subcommand)]
32 Account(AccountCommand),
33
34 /// Invite-token operations — currently only `create`.
35 #[command(subcommand)]
36 Invite(InviteCommand),
37}
38
39// ---------------------------------------------------------------------------
40// `quota` — row 138
41// ---------------------------------------------------------------------------
42
43/// `quota` subcommands.
44#[derive(Debug, Subcommand)]
45pub enum QuotaCommand {
46 /// Walk the pod's storage tree, recompute used bytes, rewrite the
47 /// `.quota.json` sidecar. Pass `--all` to reconcile every pod
48 /// directly under `--root`.
49 Reconcile(QuotaReconcileArgs),
50}
51
52/// Arguments for `quota reconcile`.
53#[derive(Debug, Args, Clone)]
54pub struct QuotaReconcileArgs {
55 /// Pod directory name under `--root`. Mutually exclusive with
56 /// `--all`; clap enforces the requirement at parse time.
57 #[arg(required_unless_present = "all")]
58 pub pod_id: Option<String>,
59
60 /// Reconcile every immediate subdirectory of `--root`.
61 #[arg(long, conflicts_with = "pod_id")]
62 pub all: bool,
63
64 /// Filesystem root containing pod directories. Falls back to the
65 /// `JSS_STORAGE_ROOT` env var, then `./data`.
66 #[arg(long, env = "JSS_STORAGE_ROOT", default_value = "./data")]
67 pub root: std::path::PathBuf,
68
69 /// Default quota cap applied when a sidecar is absent or has
70 /// `limit_bytes == 0`. Bytes. `0` means "no cap".
71 #[arg(long, default_value_t = 0)]
72 pub default_limit: u64,
73}
74
75// ---------------------------------------------------------------------------
76// `account delete` — row 168
77// ---------------------------------------------------------------------------
78
79/// `account` subcommands.
80#[derive(Debug, Subcommand)]
81pub enum AccountCommand {
82 /// Remove a user, their pods, and their WebID profile.
83 Delete(AccountDeleteArgs),
84}
85
86/// Arguments for `account delete`.
87#[derive(Debug, Args, Clone)]
88pub struct AccountDeleteArgs {
89 /// Stable internal user id (the one stored on the [`User`] row).
90 pub user_id: String,
91
92 /// Skip the interactive confirmation prompt.
93 #[arg(long)]
94 pub yes: bool,
95}
96
97// ---------------------------------------------------------------------------
98// `invite create` — row 163
99// ---------------------------------------------------------------------------
100
101/// `invite` subcommands.
102#[derive(Debug, Subcommand)]
103pub enum InviteCommand {
104 /// Mint an opaque invite token, store it, print the invite URL.
105 Create(InviteCreateArgs),
106}
107
108/// Arguments for `invite create`.
109#[derive(Debug, Args, Clone)]
110pub struct InviteCreateArgs {
111 /// Maximum redemptions. Omit for unlimited uses.
112 #[arg(short = 'u', long = "uses")]
113 pub uses: Option<u32>,
114
115 /// Optional expiry, as `30s` / `5m` / `2h` / `7d` / `1w`, or a
116 /// bare integer (seconds).
117 #[arg(long = "expires-in")]
118 pub expires_in: Option<String>,
119
120 /// Base URL used to build the final invite URL. Defaults to an
121 /// operator-conventional `https://pod.invalid` so the CLI is
122 /// usable in non-interactive pipelines; production deployments
123 /// override this with their public origin.
124 #[arg(long = "base-url", default_value = "https://pod.invalid")]
125 pub base_url: String,
126}
127
128// ---------------------------------------------------------------------------
129// Runners — pure library surface, unit-testable without a process
130// ---------------------------------------------------------------------------
131
132/// Reconcile result — returned by the quota runner so integration
133/// tests can assert on post-state rather than parsing stdout.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ReconcileOutcome {
136 /// Pod dir name.
137 pub pod: String,
138 /// Recomputed used bytes.
139 pub used_bytes: u64,
140 /// Effective limit (may be the default-limit fallback).
141 pub limit_bytes: u64,
142}
143
144/// Run `quota reconcile`. Returns one outcome per reconciled pod.
145///
146/// Requires the `quota` Cargo feature on `solid-pod-rs` to be active
147/// transitively — this function is only compiled when the `quota`
148/// feature is on at this crate's level too (see `[features]` in
149/// `Cargo.toml`). Without that feature the binary falls back to
150/// the lower-case message runner below.
151#[cfg(feature = "quota")]
152pub async fn run_quota_reconcile(
153 args: &QuotaReconcileArgs,
154) -> anyhow::Result<Vec<ReconcileOutcome>> {
155 use solid_pod_rs::quota::{FsQuotaStore, QuotaPolicy};
156
157 // Resolve the pod set. `--all` iterates immediate subdirs of
158 // `--root`; otherwise we use the single positional pod id.
159 let pods: Vec<String> = if args.all {
160 let mut out = Vec::new();
161 let mut rd = match tokio::fs::read_dir(&args.root).await {
162 Ok(r) => r,
163 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
164 anyhow::bail!("storage root does not exist: {}", args.root.display());
165 }
166 Err(e) => return Err(e.into()),
167 };
168 while let Some(entry) = rd.next_entry().await? {
169 if entry.file_type().await?.is_dir() {
170 if let Some(name) = entry.file_name().to_str() {
171 // Skip hidden dotfiles (`.git`, `.cache`, etc).
172 if !name.starts_with('.') {
173 out.push(name.to_string());
174 }
175 }
176 }
177 }
178 out.sort();
179 out
180 } else {
181 vec![args
182 .pod_id
183 .clone()
184 .expect("clap guarantees pod_id or --all")]
185 };
186
187 let store = FsQuotaStore::new(args.root.clone(), args.default_limit);
188 let mut outcomes = Vec::with_capacity(pods.len());
189 for pod in pods {
190 let usage = store
191 .reconcile(&pod)
192 .await
193 .map_err(|e| anyhow::anyhow!("reconcile {pod}: {e}"))?;
194 outcomes.push(ReconcileOutcome {
195 pod,
196 used_bytes: usage.used_bytes,
197 limit_bytes: usage.limit_bytes,
198 });
199 }
200 Ok(outcomes)
201}
202
203/// Fallback runner compiled when the `quota` feature is off at this
204/// crate's level. Surfaces the actionable error rather than a cryptic
205/// `command not found`.
206#[cfg(not(feature = "quota"))]
207pub async fn run_quota_reconcile(
208 _args: &QuotaReconcileArgs,
209) -> anyhow::Result<Vec<ReconcileOutcome>> {
210 anyhow::bail!(
211 "`quota reconcile` requires the `quota` cargo feature. Rebuild with \
212 `--features solid-pod-rs-server/quota`."
213 )
214}
215
216/// Input reader trait so tests can feed confirmation text without
217/// touching real stdin.
218pub trait Prompt: Send {
219 /// Emit a prompt line and read the user's answer. Returns
220 /// `Ok(None)` when stdin is closed (EOF).
221 fn ask(&mut self, prompt: &str) -> std::io::Result<Option<String>>;
222}
223
224/// Stdin-backed [`Prompt`] used by the real binary.
225pub struct StdinPrompt;
226
227impl Prompt for StdinPrompt {
228 fn ask(&mut self, prompt: &str) -> std::io::Result<Option<String>> {
229 use std::io::{BufRead, Write};
230 let stderr = std::io::stderr();
231 let mut handle = stderr.lock();
232 write!(handle, "{prompt}")?;
233 handle.flush()?;
234 let stdin = std::io::stdin();
235 let mut line = String::new();
236 match stdin.lock().read_line(&mut line) {
237 Ok(0) => Ok(None),
238 Ok(_) => Ok(Some(line.trim_end_matches(['\r', '\n']).to_string())),
239 Err(e) => Err(e),
240 }
241 }
242}
243
244/// Run `account delete`. Returns `Ok(true)` when a row was actually
245/// removed, `Ok(false)` when the user id was unknown, and `Err(..)`
246/// when the caller skipped confirmation without `--yes`.
247pub async fn run_account_delete<S, P>(
248 args: &AccountDeleteArgs,
249 store: &S,
250 prompt: &mut P,
251) -> anyhow::Result<bool>
252where
253 S: solid_pod_rs_idp::UserStore + ?Sized,
254 P: Prompt,
255{
256 if !args.yes {
257 let banner = format!(
258 "About to delete user {user} and every associated pod + WebID profile.\n\
259 Type the user id to confirm: ",
260 user = args.user_id
261 );
262 let answer = prompt
263 .ask(&banner)?
264 .ok_or_else(|| anyhow::anyhow!("account delete aborted: stdin closed without --yes"))?;
265 if answer.trim() != args.user_id {
266 anyhow::bail!(
267 "account delete aborted: confirmation {answer:?} did not match {user:?}",
268 user = args.user_id
269 );
270 }
271 }
272 let deleted = store
273 .delete(&args.user_id)
274 .await
275 .map_err(|e| anyhow::anyhow!("user store delete: {e}"))?;
276 Ok(deleted)
277}
278
279/// Run `invite create`. Returns the minted invite *and* the final
280/// invite URL so the binary can print both in one step and tests can
281/// assert on the URL's shape.
282pub async fn run_invite_create<S>(
283 args: &InviteCreateArgs,
284 store: &S,
285) -> anyhow::Result<(solid_pod_rs_idp::Invite, String)>
286where
287 S: solid_pod_rs_idp::InviteStore + ?Sized,
288{
289 let expires_at = match args.expires_in.as_deref() {
290 Some(spec) => {
291 let dur = solid_pod_rs_idp::parse_invite_duration(spec)
292 .map_err(|e| anyhow::anyhow!("--expires-in {spec:?}: {e}"))?;
293 let chrono_dur = chrono::Duration::from_std(dur)
294 .map_err(|e| anyhow::anyhow!("--expires-in {spec:?} out of range: {e}"))?;
295 Some(chrono::Utc::now() + chrono_dur)
296 }
297 None => None,
298 };
299 let token = solid_pod_rs_idp::mint_invite_token();
300 let invite = solid_pod_rs_idp::Invite {
301 token: token.clone(),
302 max_uses: args.uses,
303 expires_at,
304 };
305 store
306 .insert(invite.clone())
307 .await
308 .map_err(|e| anyhow::anyhow!("invite store insert: {e}"))?;
309 let base = args.base_url.trim_end_matches('/');
310 let url = format!("{base}/invite?token={token}");
311 Ok((invite, url))
312}
313
314// ---------------------------------------------------------------------------
315// Binary-layer glue
316// ---------------------------------------------------------------------------
317
318/// Dispatch an [`OperatorCommand`] against real stores. The binary in
319/// `main.rs` calls this, tests skip it and drive the `run_*`
320/// functions directly.
321pub async fn dispatch(cmd: OperatorCommand) -> anyhow::Result<()> {
322 match cmd {
323 OperatorCommand::Quota(QuotaCommand::Reconcile(args)) => {
324 let outcomes = run_quota_reconcile(&args).await?;
325 for out in &outcomes {
326 println!(
327 "reconciled pod={} used_bytes={} limit_bytes={}",
328 out.pod, out.used_bytes, out.limit_bytes
329 );
330 }
331 if outcomes.is_empty() {
332 println!("no pods found under {}", args.root.display());
333 }
334 Ok(())
335 }
336 OperatorCommand::Account(AccountCommand::Delete(args)) => {
337 // The binary ships with the in-memory UserStore by default;
338 // operators wiring a persistent store plug their own
339 // dispatch in place of this one. The default keeps the
340 // subcommand callable in dev without a configured DB.
341 let store = solid_pod_rs_idp::InMemoryUserStore::new();
342 let mut prompt = StdinPrompt;
343 let deleted = run_account_delete(&args, &store, &mut prompt).await?;
344 if deleted {
345 println!("deleted user {}", args.user_id);
346 } else {
347 println!("no such user {}", args.user_id);
348 }
349 Ok(())
350 }
351 OperatorCommand::Invite(InviteCommand::Create(args)) => {
352 let store = solid_pod_rs_idp::InMemoryInviteStore::new();
353 let (invite, url) = run_invite_create(&args, &store).await?;
354 println!("token: {}", invite.token);
355 match invite.max_uses {
356 Some(n) => println!("max_uses: {n}"),
357 None => println!("max_uses: unlimited"),
358 }
359 match invite.expires_at {
360 Some(t) => println!("expires_at: {}", t.to_rfc3339()),
361 None => println!("expires_at: never"),
362 }
363 println!("url: {url}");
364 Ok(())
365 }
366 }
367}