steid

@jamesgill /

steid/src/cli/mod.rs
Rust7.5 KBCode·Blame·History·Raw
1//! The command line: `steid` serves; `steid <command>` does one operator task and
2//! exits.
3//!
4//! Subcommands exist so a machine with no browser — a control plane, a cron job, an
5//! owner locked out of their own instance — can drive Steid headlessly. Each command is
6//! a thin wrapper over an application use case; nothing here decides anything.
7//!
8//! No argument-parsing crate: three commands and a path do not justify one, and the
9//! usage text is the whole contract.
10//!
11//! The commands themselves live here too, one module each, rather than in `main.rs`
12//! where they were until 2026-09-10. They are the operator's recovery path — the least
13//! testable code in the tree while it sat in a binary with no tests — and each is now
14//! reachable from a test that runs it against a temp database and a temp data
15//! directory.
16
17mod backup;
18mod reset_password;
19mod restore;
20
21pub use backup::run_backup;
22pub use reset_password::print_reset_link;
23pub use restore::run_restore;
24
25use std::path::PathBuf;
26
27/// Where a backup builds itself: a scratch directory inside the data directory.
28///
29/// Inside it rather than in `/tmp` so the snapshot — a full copy of the database — lands
30/// on the volume that is already sized for the database, under the directory the service
31/// unit already grants write access to. The leading dot is what keeps it out of the
32/// archive being built in it: a handle is `[a-z0-9-]` and can never start with one, so
33/// nothing under a dot-directory is a repository and `pack` skips the lot.
34pub(crate) const BACKUP_WORKSPACE: &str = ".backup";
35
36/// What `steid` was asked to do.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Command {
39 /// Run the server. The default, and what a bare `steid` means.
40 Serve,
41 /// Write one consistent archive of the database and the data directory.
42 Backup { destination: PathBuf },
43 /// Restore an archive into an empty database and data directory.
44 Restore { archive: PathBuf },
45 /// Print a one-time password reset link for a user, for an owner who cannot
46 /// receive email.
47 ResetPassword { handle: String },
48}
49
50pub const USAGE: &str = "usage:
51 steid serve
52 steid backup <destination> write one archive of the database and data directory
53 steid restore <archive> restore an archive into an empty instance
54 steid reset-password <handle> print a one-time password reset link
55";
56
57impl Command {
58 /// Parses `args` as the process received them, program name first.
59 pub fn parse<I, S>(args: I) -> Result<Self, String>
60 where
61 I: IntoIterator<Item = S>,
62 S: Into<String>,
63 {
64 let args: Vec<String> = args.into_iter().map(Into::into).skip(1).collect();
65 match args
66 .iter()
67 .map(String::as_str)
68 .collect::<Vec<_>>()
69 .as_slice()
70 {
71 [] => Ok(Self::Serve),
72 ["backup", destination] => Ok(Self::Backup {
73 destination: PathBuf::from(destination),
74 }),
75 ["restore", archive] => Ok(Self::Restore {
76 archive: PathBuf::from(archive),
77 }),
78 ["reset-password", handle] => Ok(Self::ResetPassword {
79 handle: (*handle).to_owned(),
80 }),
81 ["-h" | "--help" | "help", ..] => Err(USAGE.to_owned()),
82 other => Err(format!("steid: unrecognised arguments {other:?}\n{USAGE}")),
83 }
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn a_bare_invocation_serves() {
93 assert_eq!(Command::parse(["steid"]), Ok(Command::Serve));
94 }
95
96 #[test]
97 fn each_command_takes_exactly_one_argument() {
98 assert_eq!(
99 Command::parse(["steid", "backup", "/tmp/out"]),
100 Ok(Command::Backup {
101 destination: PathBuf::from("/tmp/out")
102 })
103 );
104 assert_eq!(
105 Command::parse(["steid", "restore", "a.tar.gz"]),
106 Ok(Command::Restore {
107 archive: PathBuf::from("a.tar.gz")
108 })
109 );
110 assert_eq!(
111 Command::parse(["steid", "reset-password", "ada"]),
112 Ok(Command::ResetPassword {
113 handle: "ada".to_owned()
114 })
115 );
116 assert!(Command::parse(["steid", "backup"]).is_err());
117 assert!(Command::parse(["steid", "frobnicate"]).is_err());
118 }
119
120 #[test]
121 fn help_is_an_error_carrying_the_usage() {
122 let text = Command::parse(["steid", "--help"]).unwrap_err();
123 assert!(text.contains("steid backup <destination>"));
124 }
125}
126
127/// A whole instance in a temporary directory, for the command tests.
128///
129/// Real SQLite and a real data directory rather than the in-memory repositories the use
130/// cases are tested against: what these tests are for is the wiring between a command
131/// and its adapters — the path resolution, the workspace, the pool — and an in-memory
132/// store would test none of it.
133#[cfg(test)]
134pub(crate) mod fixture {
135 use sqlx::SqlitePool;
136 use tempfile::TempDir;
137
138 use crate::{
139 application::{AppConfig, OwnerSpec, claim_instance},
140 domain::SetupToken,
141 infrastructure::{
142 database,
143 password::Argon2Hasher,
144 repository::{SqliteMembershipRepo, SqliteOrgRepo, SqliteUserRepo},
145 },
146 };
147
148 /// A temporary instance: the directory it lives in, its configuration and its pool.
149 ///
150 /// The `TempDir` is held rather than returned separately because dropping it deletes
151 /// everything the other two point at.
152 pub(crate) struct Instance {
153 pub(crate) dir: TempDir,
154 pub(crate) config: AppConfig,
155 pub(crate) pool: SqlitePool,
156 }
157
158 /// A migrated database and an empty data directory, with nobody in it.
159 pub(crate) async fn empty() -> Instance {
160 let dir = tempfile::tempdir().expect("a temp dir");
161 let data_dir = dir.path().join("data");
162 std::fs::create_dir_all(&data_dir).expect("mkdir");
163
164 let database_url = format!("sqlite:{}?mode=rwc", dir.path().join("steid.db").display());
165 let pool = database::connect(&database_url)
166 .await
167 .expect("the database opens");
168
169 Instance {
170 config: AppConfig {
171 database_url,
172 data_dir,
173 insecure_cookies: false,
174 allow_private_webhooks: false,
175 trusted_proxy: false,
176 operator_token: None,
177 setup_token: None,
178 backup_marker: None,
179 smtp_url: None,
180 mail_from: None,
181 public_url: None,
182 },
183 pool,
184 dir,
185 }
186 }
187
188 /// [`empty`], then claimed by `handle` — so it has a user, an organisation and a
189 /// membership, which is what makes it a non-empty instance.
190 pub(crate) async fn claimed(handle: &str) -> Instance {
191 let instance = empty().await;
192 let token = SetupToken::generate();
193 let revealed = token.reveal().to_owned();
194
195 claim_instance(
196 &revealed,
197 &token,
198 &OwnerSpec {
199 handle: handle.to_owned(),
200 email: format!("{handle}@example.com"),
201 password: "a-long-enough-password".to_owned(),
202 },
203 &SqliteUserRepo::new(instance.pool.clone()),
204 &SqliteOrgRepo::new(instance.pool.clone()),
205 &SqliteMembershipRepo::new(instance.pool.clone()),
206 &Argon2Hasher::new(),
207 )
208 .await
209 .expect("the claim succeeds");
210
211 instance
212 }
213}