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}