Skip to main content

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