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::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}