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