fabrica

hanna/fabrica

feat(cli): user account management commands

3bc5ed9 · hanna committed on 2026-07-25

Add the `fabrica user` group — add, passwd, list, disable, enable, del —
operating directly on the store with no running server. Highlights:

- `user passwd` sets a password from `--pass`, an interactive no-echo prompt
  (confirmed twice on a terminal), or a single piped stdin line for scripting.
  This is the SMTP-disabled path for activating an account.
- `user add` hashes `--pass` when given; without it the account is created
  inactive with guidance to run `user passwd` (email invites arrive in phase 9).
- `user del` refuses a repo owner unless `--purge`, and confirms on a TTY or
  requires `--yes` when piped.
- `--json user list` exposes only whether a password is set, never the hash.

Store-backed commands self-migrate on connect so a fresh install's first
`user add` bootstraps the database. End-to-end tests cover each command against
a temp SQLite database; decisions.md records the pull-forward and interim
`user add` behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +825 −9UnifiedSplit
Cargo.lock +110 −9
@@ -44,7 +44,7 @@ version = "1.1.5"
4444 source = "registry+https://github.com/rust-lang/crates.io-index"
4545 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
4646 dependencies = [
47- "windows-sys",
47+ "windows-sys 0.61.2",
4848 ]
4949
5050 [[package]]
@@ -55,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
5555 dependencies = [
5656 "anstyle",
5757 "once_cell_polyfill",
58- "windows-sys",
58+ "windows-sys 0.61.2",
5959 ]
6060
6161 [[package]]
@@ -263,9 +263,16 @@ name = "cli"
263263 version = "0.1.0"
264264 dependencies = [
265265 "anyhow",
266+ "auth",
266267 "clap",
267268 "config",
269+ "model",
270+ "rpassword",
271+ "serde",
268272 "serde_json",
273+ "store",
274+ "tempfile",
275+ "tokio",
269276 ]
270277
271278 [[package]]
@@ -437,7 +444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
437444 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
438445 dependencies = [
439446 "libc",
440- "windows-sys",
447+ "windows-sys 0.61.2",
441448 ]
442449
443450 [[package]]
@@ -447,7 +454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
447454 checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
448455 dependencies = [
449456 "cfg-if",
450- "windows-sys",
457+ "windows-sys 0.61.2",
451458 ]
452459
453460 [[package]]
@@ -933,7 +940,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
933940 dependencies = [
934941 "libc",
935942 "wasi",
936- "windows-sys",
943+ "windows-sys 0.61.2",
937944 ]
938945
939946 [[package]]
@@ -1171,6 +1178,27 @@ dependencies = [
11711178 ]
11721179
11731180 [[package]]
1181+name = "rpassword"
1182+version = "7.5.4"
1183+source = "registry+https://github.com/rust-lang/crates.io-index"
1184+checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
1185+dependencies = [
1186+ "libc",
1187+ "rtoolbox",
1188+ "windows-sys 0.61.2",
1189+]
1190+
1191+[[package]]
1192+name = "rtoolbox"
1193+version = "0.0.5"
1194+source = "registry+https://github.com/rust-lang/crates.io-index"
1195+checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
1196+dependencies = [
1197+ "libc",
1198+ "windows-sys 0.59.0",
1199+]
1200+
1201+[[package]]
11741202 name = "rustix"
11751203 version = "1.1.4"
11761204 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1180,7 +1208,7 @@ dependencies = [
11801208 "errno",
11811209 "libc",
11821210 "linux-raw-sys",
1183- "windows-sys",
1211+ "windows-sys 0.61.2",
11841212 ]
11851213
11861214 [[package]]
@@ -1308,7 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13081336 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
13091337 dependencies = [
13101338 "libc",
1311- "windows-sys",
1339+ "windows-sys 0.61.2",
13121340 ]
13131341
13141342 [[package]]
@@ -1578,7 +1606,7 @@ dependencies = [
15781606 "getrandom 0.4.3",
15791607 "once_cell",
15801608 "rustix",
1581- "windows-sys",
1609+ "windows-sys 0.61.2",
15821610 ]
15831611
15841612 [[package]]
@@ -1638,7 +1666,7 @@ dependencies = [
16381666 "pin-project-lite",
16391667 "socket2",
16401668 "tokio-macros",
1641- "windows-sys",
1669+ "windows-sys 0.61.2",
16421670 ]
16431671
16441672 [[package]]
@@ -1912,6 +1940,15 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
19121940
19131941 [[package]]
19141942 name = "windows-sys"
1943+version = "0.59.0"
1944+source = "registry+https://github.com/rust-lang/crates.io-index"
1945+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1946+dependencies = [
1947+ "windows-targets",
1948+]
1949+
1950+[[package]]
1951+name = "windows-sys"
19151952 version = "0.61.2"
19161953 source = "registry+https://github.com/rust-lang/crates.io-index"
19171954 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -1920,6 +1957,70 @@ dependencies = [
19201957 ]
19211958
19221959 [[package]]
1960+name = "windows-targets"
1961+version = "0.52.6"
1962+source = "registry+https://github.com/rust-lang/crates.io-index"
1963+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1964+dependencies = [
1965+ "windows_aarch64_gnullvm",
1966+ "windows_aarch64_msvc",
1967+ "windows_i686_gnu",
1968+ "windows_i686_gnullvm",
1969+ "windows_i686_msvc",
1970+ "windows_x86_64_gnu",
1971+ "windows_x86_64_gnullvm",
1972+ "windows_x86_64_msvc",
1973+]
1974+
1975+[[package]]
1976+name = "windows_aarch64_gnullvm"
1977+version = "0.52.6"
1978+source = "registry+https://github.com/rust-lang/crates.io-index"
1979+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1980+
1981+[[package]]
1982+name = "windows_aarch64_msvc"
1983+version = "0.52.6"
1984+source = "registry+https://github.com/rust-lang/crates.io-index"
1985+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1986+
1987+[[package]]
1988+name = "windows_i686_gnu"
1989+version = "0.52.6"
1990+source = "registry+https://github.com/rust-lang/crates.io-index"
1991+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1992+
1993+[[package]]
1994+name = "windows_i686_gnullvm"
1995+version = "0.52.6"
1996+source = "registry+https://github.com/rust-lang/crates.io-index"
1997+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1998+
1999+[[package]]
2000+name = "windows_i686_msvc"
2001+version = "0.52.6"
2002+source = "registry+https://github.com/rust-lang/crates.io-index"
2003+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2004+
2005+[[package]]
2006+name = "windows_x86_64_gnu"
2007+version = "0.52.6"
2008+source = "registry+https://github.com/rust-lang/crates.io-index"
2009+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2010+
2011+[[package]]
2012+name = "windows_x86_64_gnullvm"
2013+version = "0.52.6"
2014+source = "registry+https://github.com/rust-lang/crates.io-index"
2015+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2016+
2017+[[package]]
2018+name = "windows_x86_64_msvc"
2019+version = "0.52.6"
2020+source = "registry+https://github.com/rust-lang/crates.io-index"
2021+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2022+
2023+[[package]]
19232024 name = "winnow"
19242025 version = "0.7.15"
19252026 source = "registry+https://github.com/rust-lang/crates.io-index"
crates/cli/Cargo.toml +9 −0
@@ -11,9 +11,18 @@ publish.workspace = true
1111
1212 [dependencies]
1313 config = { workspace = true }
14+store = { workspace = true }
15+auth = { workspace = true }
16+model = { workspace = true }
1417 anyhow = { workspace = true }
1518 clap = { workspace = true }
19+serde = { workspace = true }
1620 serde_json = { workspace = true }
21+tokio = { workspace = true }
22+rpassword = { workspace = true }
23+
24+[dev-dependencies]
25+tempfile = { workspace = true }
1726
1827 [lints]
1928 workspace = true
crates/cli/src/context.rs +61 −0
@@ -0,0 +1,61 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Shared plumbing for store-backed subcommands.
6+//!
7+//! Every CLI mutation operates directly on the database — there is no IPC with a
8+//! running `serve`. These helpers turn the loaded configuration into an open
9+//! [`Store`] and give the (otherwise synchronous) command handlers a place to
10+//! block on async work.
11+
12+use std::future::Future;
13+use std::path::Path;
14+
15+use config::Config;
16+use store::Store;
17+
18+/// Load configuration, open the store, and apply any pending migrations.
19+///
20+/// Migrations run on every store-backed command so a fresh install is usable
21+/// immediately: the first `fabrica user add` creates the database file, brings
22+/// the schema up to date, and inserts the row in one step. Migrations are
23+/// idempotent, so this is a cheap no-op once the schema is current.
24+///
25+/// Configuration warnings are printed to stderr, matching `config check`/`show`.
26+///
27+/// # Errors
28+///
29+/// Returns an error if the configuration is invalid, the database cannot be
30+/// opened, or migrations fail.
31+pub(crate) async fn open_store(config_path: Option<&Path>) -> anyhow::Result<(Config, Store)> {
32+ let loaded = config::load(config_path)?;
33+ for warning in &loaded.warnings {
34+ eprintln!("warning: {warning}");
35+ }
36+ let store = Store::connect(
37+ &loaded.config.database.url,
38+ loaded.config.database.max_connections,
39+ )
40+ .await?;
41+ store.migrate().await?;
42+ Ok((loaded.config, store))
43+}
44+
45+/// Run an async command body to completion on a fresh current-thread runtime.
46+///
47+/// The CLI is a short-lived, single-shot process, so a current-thread runtime is
48+/// the right size — no worker pool to spin up and tear down per invocation.
49+///
50+/// # Errors
51+///
52+/// Propagates any error from building the runtime or from `fut` itself.
53+pub(crate) fn block_on<F, T>(fut: F) -> anyhow::Result<T>
54+where
55+ F: Future<Output = anyhow::Result<T>>,
56+{
57+ let runtime = tokio::runtime::Builder::new_current_thread()
58+ .enable_all()
59+ .build()?;
60+ runtime.block_on(fut)
61+}
crates/cli/src/lib.rs +10 −0
@@ -10,6 +10,9 @@
1010 //! phases; today it covers only `config check` and `config show`.
1111
1212 mod config_cmd;
13+mod context;
14+mod prompt;
15+mod user_cmd;
1316
1417 use std::path::PathBuf;
1518 use std::process::ExitCode;
@@ -46,6 +49,12 @@ enum Command {
4649 #[command(subcommand)]
4750 command: config_cmd::ConfigCommand,
4851 },
52+ /// Manage user accounts.
53+ User {
54+ /// The user action to perform.
55+ #[command(subcommand)]
56+ command: user_cmd::UserCommand,
57+ },
4958 }
5059
5160 /// Parse arguments and dispatch to the requested subcommand.
@@ -69,5 +78,6 @@ pub fn run() -> ExitCode {
6978 fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> {
7079 match &cli.command {
7180 Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json),
81+ Command::User { command } => user_cmd::run(command, cli.config.as_deref(), cli.json),
7282 }
7383 }
crates/cli/src/prompt.rs +78 −0
@@ -0,0 +1,78 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Interactive terminal prompts: non-echoing password entry and destructive-
6+//! action confirmation.
7+//!
8+//! Both degrade deliberately when stdin is not a terminal (a pipe, a systemd
9+//! unit, CI). A password can still be supplied on a single stdin line for
10+//! scripting; a confirmation, by contrast, refuses rather than guess, so a
11+//! non-interactive destructive command must pass `--yes` explicitly.
12+
13+use std::io::{IsTerminal, Write};
14+
15+/// Resolve a password to use, in three ways depending on how the command was
16+/// invoked:
17+///
18+/// * `explicit` (from `--pass`) is used verbatim when present;
19+/// * otherwise, on a terminal, the user is prompted twice with no echo and the
20+/// two entries must match;
21+/// * otherwise (piped stdin) a single line is read, so
22+/// `printf '%s' pw | fabrica user passwd alice` works.
23+///
24+/// An empty password is rejected in every case.
25+///
26+/// # Errors
27+///
28+/// Returns an error if the prompts cannot be read, the two interactive entries
29+/// differ, or the resolved password is empty.
30+pub(crate) fn resolve_password(explicit: Option<String>) -> anyhow::Result<String> {
31+ let password = match explicit {
32+ Some(pass) => pass,
33+ None if std::io::stdin().is_terminal() => {
34+ let first = rpassword::prompt_password("New password: ")?;
35+ let second = rpassword::prompt_password("Confirm password: ")?;
36+ if first != second {
37+ anyhow::bail!("passwords did not match");
38+ }
39+ first
40+ }
41+ None => {
42+ let mut line = String::new();
43+ std::io::stdin().read_line(&mut line)?;
44+ line.trim_end_matches(['\r', '\n']).to_string()
45+ }
46+ };
47+ if password.is_empty() {
48+ anyhow::bail!("password must not be empty");
49+ }
50+ Ok(password)
51+}
52+
53+/// Ask the user to confirm a destructive action.
54+///
55+/// `assume_yes` (from `--yes`) short-circuits to `true`. Otherwise a terminal is
56+/// prompted `[y/N]` and only `y`/`yes` (any case) confirms. When stdin is not a
57+/// terminal and `--yes` was not given, the action is refused rather than assumed.
58+///
59+/// # Errors
60+///
61+/// Returns an error if stdin is not a terminal and `--yes` was not passed, or if
62+/// the prompt cannot be read.
63+pub(crate) fn confirm(question: &str, assume_yes: bool) -> anyhow::Result<bool> {
64+ if assume_yes {
65+ return Ok(true);
66+ }
67+ if !std::io::stdin().is_terminal() {
68+ anyhow::bail!(
69+ "refusing to proceed without confirmation; re-run with --yes for non-interactive use"
70+ );
71+ }
72+ eprint!("{question} [y/N] ");
73+ std::io::stderr().flush()?;
74+ let mut line = String::new();
75+ std::io::stdin().read_line(&mut line)?;
76+ let answer = line.trim().to_ascii_lowercase();
77+ Ok(answer == "y" || answer == "yes")
78+}
crates/cli/src/user_cmd.rs +521 −0
@@ -0,0 +1,521 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica user` — account administration, operating directly on the store.
6+
7+use std::path::Path;
8+use std::process::ExitCode;
9+
10+use clap::Subcommand;
11+use config::Config;
12+use model::User;
13+use store::{NewUser, Store};
14+
15+use crate::context::{block_on, open_store};
16+use crate::prompt::{confirm, resolve_password};
17+
18+/// Actions under `fabrica user`.
19+#[derive(Debug, Subcommand)]
20+pub(crate) enum UserCommand {
21+ /// Create a user. Without `--pass` the account is created with no password
22+ /// and cannot log in until one is set with `fabrica user passwd`.
23+ Add {
24+ /// Login name (validated: see the name rules).
25+ name: String,
26+ /// Primary email address.
27+ email: String,
28+ /// Set this password now instead of leaving the account inactive. Omit
29+ /// the value to be prompted; omit the flag entirely to create an
30+ /// inactive account.
31+ #[arg(long)]
32+ pass: Option<String>,
33+ /// Grant site-administrator privileges.
34+ #[arg(long)]
35+ admin: bool,
36+ /// Human-friendly display name.
37+ #[arg(long = "display-name")]
38+ display_name: Option<String>,
39+ },
40+ /// Set a user's password. With no `--pass`, prompts twice on a terminal, or
41+ /// reads a single line from stdin when piped. Rotating a password logs the
42+ /// user out of every existing session.
43+ Passwd {
44+ /// The user whose password to set.
45+ name: String,
46+ /// The new password. Omit to be prompted (or to read it from stdin).
47+ #[arg(long)]
48+ pass: Option<String>,
49+ },
50+ /// List all users.
51+ List,
52+ /// Disable a user: they can no longer log in, and existing sessions are
53+ /// dropped.
54+ Disable {
55+ /// The user to disable.
56+ name: String,
57+ },
58+ /// Re-enable a previously disabled user.
59+ Enable {
60+ /// The user to enable.
61+ name: String,
62+ },
63+ /// Delete a user. Refuses if the user still owns repositories unless
64+ /// `--purge` is given.
65+ Del {
66+ /// The user to delete.
67+ name: String,
68+ /// Also delete every repository the user owns.
69+ #[arg(long)]
70+ purge: bool,
71+ /// Skip the confirmation prompt (required when stdin is not a terminal).
72+ #[arg(long)]
73+ yes: bool,
74+ },
75+}
76+
77+/// Dispatch a `user` subcommand, blocking on the async store work.
78+pub(crate) fn run(
79+ command: &UserCommand,
80+ config_path: Option<&Path>,
81+ json: bool,
82+) -> anyhow::Result<ExitCode> {
83+ match command {
84+ UserCommand::Add {
85+ name,
86+ email,
87+ pass,
88+ admin,
89+ display_name,
90+ } => block_on(add(
91+ config_path,
92+ name,
93+ email,
94+ pass.clone(),
95+ *admin,
96+ display_name.clone(),
97+ )),
98+ UserCommand::Passwd { name, pass } => block_on(passwd(config_path, name, pass.clone())),
99+ UserCommand::List => block_on(list(config_path, json)),
100+ UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),
101+ UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),
102+ UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),
103+ }
104+}
105+
106+/// Hash a plaintext password with the instance's configured Argon2id cost.
107+fn hash_password(config: &Config, password: &str) -> anyhow::Result<String> {
108+ let params = auth::HashParams {
109+ m_cost: config.auth.argon2_m_cost,
110+ t_cost: config.auth.argon2_t_cost,
111+ p_cost: config.auth.argon2_p_cost,
112+ };
113+ Ok(auth::hash_password(password, params)?)
114+}
115+
116+/// Look a user up by name, turning "not found" into a clean error.
117+async fn require_user(store: &Store, name: &str) -> anyhow::Result<User> {
118+ store
119+ .user_by_username(name)
120+ .await?
121+ .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
122+}
123+
124+async fn add(
125+ config_path: Option<&Path>,
126+ name: &str,
127+ email: &str,
128+ pass: Option<String>,
129+ admin: bool,
130+ display_name: Option<String>,
131+) -> anyhow::Result<ExitCode> {
132+ let (config, store) = open_store(config_path).await?;
133+
134+ // A password is set eagerly only when `--pass` is present. `--pass` with no
135+ // value prompts; the flag being absent leaves the account inactive.
136+ let password_hash = match pass {
137+ Some(explicit) => {
138+ let password = resolve_password(Some(explicit))?;
139+ Some(hash_password(&config, &password)?)
140+ }
141+ None => None,
142+ };
143+ let has_password = password_hash.is_some();
144+
145+ let user = store
146+ .create_user(NewUser {
147+ username: name.to_string(),
148+ email: email.to_string(),
149+ display_name,
150+ password_hash,
151+ is_admin: admin,
152+ must_change_password: false,
153+ })
154+ .await?;
155+
156+ println!(
157+ "created user {} <{}>{}",
158+ user.username,
159+ user.email,
160+ if user.is_admin { " (admin)" } else { "" }
161+ );
162+ if !has_password {
163+ // Email invitations are not implemented yet (phase 9), so there is no
164+ // out-of-band way to activate the account — point the operator at the
165+ // direct path. This is the SMTP-disabled fallback made explicit.
166+ eprintln!(
167+ "note: no password set; this account cannot log in yet. \
168+ Set one with: fabrica user passwd {}",
169+ user.username
170+ );
171+ }
172+ Ok(ExitCode::SUCCESS)
173+}
174+
175+async fn passwd(
176+ config_path: Option<&Path>,
177+ name: &str,
178+ pass: Option<String>,
179+) -> anyhow::Result<ExitCode> {
180+ let (config, store) = open_store(config_path).await?;
181+ let user = require_user(&store, name).await?;
182+ let password = resolve_password(pass)?;
183+ let hash = hash_password(&config, &password)?;
184+ store.set_password(&user.id, Some(&hash)).await?;
185+ println!("password updated for {}", user.username);
186+ Ok(ExitCode::SUCCESS)
187+}
188+
189+async fn list(config_path: Option<&Path>, json: bool) -> anyhow::Result<ExitCode> {
190+ let (_config, store) = open_store(config_path).await?;
191+ let users = store.list_users().await?;
192+
193+ if json {
194+ let view: Vec<_> = users.iter().map(user_json).collect();
195+ println!("{}", serde_json::to_string_pretty(&view)?);
196+ return Ok(ExitCode::SUCCESS);
197+ }
198+
199+ if users.is_empty() {
200+ println!("no users");
201+ return Ok(ExitCode::SUCCESS);
202+ }
203+
204+ let width = users
205+ .iter()
206+ .map(|u| u.username.len())
207+ .max()
208+ .unwrap_or(0)
209+ .max("USERNAME".len());
210+ println!("{:<width$} {:<28} FLAGS", "USERNAME", "EMAIL");
211+ for user in &users {
212+ println!(
213+ "{:<width$} {:<28} {}",
214+ user.username,
215+ user.email,
216+ flags(user).join(",")
217+ );
218+ }
219+ Ok(ExitCode::SUCCESS)
220+}
221+
222+async fn set_disabled(
223+ config_path: Option<&Path>,
224+ name: &str,
225+ disabled: bool,
226+) -> anyhow::Result<ExitCode> {
227+ let (_config, store) = open_store(config_path).await?;
228+ let user = require_user(&store, name).await?;
229+ store.set_disabled(&user.id, disabled).await?;
230+ println!(
231+ "{} user {}",
232+ if disabled { "disabled" } else { "enabled" },
233+ user.username
234+ );
235+ Ok(ExitCode::SUCCESS)
236+}
237+
238+async fn del(
239+ config_path: Option<&Path>,
240+ name: &str,
241+ purge: bool,
242+ yes: bool,
243+) -> anyhow::Result<ExitCode> {
244+ let (_config, store) = open_store(config_path).await?;
245+ let user = require_user(&store, name).await?;
246+ let repos = store.repos_by_owner(&user.id).await?;
247+
248+ if !repos.is_empty() && !purge {
249+ eprintln!(
250+ "{} still owns {} repositor{}:",
251+ user.username,
252+ repos.len(),
253+ plural(repos.len())
254+ );
255+ for repo in &repos {
256+ eprintln!(" {}", repo.path);
257+ }
258+ anyhow::bail!(
259+ "refusing to delete a user who owns repositories; pass --purge to remove them"
260+ );
261+ }
262+
263+ let question = if repos.is_empty() {
264+ format!("Delete user {}?", user.username)
265+ } else {
266+ format!(
267+ "Delete user {} and {} owned repositor{}?",
268+ user.username,
269+ repos.len(),
270+ plural(repos.len())
271+ )
272+ };
273+ if !confirm(&question, yes)? {
274+ println!("aborted");
275+ return Ok(ExitCode::SUCCESS);
276+ }
277+
278+ // Cascades to the user's repos, keys, tokens, and sessions in the database.
279+ // On-disk bare repositories are not yet created by `repo add`, so there is
280+ // nothing to remove from the filesystem here; that cleanup lands with the
281+ // git-storage phases.
282+ store.delete_user(&user.id).await?;
283+ println!("deleted user {}", user.username);
284+ Ok(ExitCode::SUCCESS)
285+}
286+
287+/// The single-character-free flag labels shown by `user list`.
288+fn flags(user: &User) -> Vec<&'static str> {
289+ let mut flags = Vec::new();
290+ if user.is_admin {
291+ flags.push("admin");
292+ }
293+ if user.disabled_at.is_some() {
294+ flags.push("disabled");
295+ }
296+ if user.password_hash.is_none() {
297+ flags.push("no-password");
298+ }
299+ if flags.is_empty() {
300+ flags.push("-");
301+ }
302+ flags
303+}
304+
305+/// Render a user for `--json`, deliberately exposing only whether a password is
306+/// set (never the hash).
307+fn user_json(user: &User) -> serde_json::Value {
308+ serde_json::json!({
309+ "id": user.id,
310+ "username": user.username,
311+ "email": user.email,
312+ "display_name": user.display_name,
313+ "is_admin": user.is_admin,
314+ "has_password": user.password_hash.is_some(),
315+ "must_change_password": user.must_change_password,
316+ "disabled": user.disabled_at.is_some(),
317+ "created_at": user.created_at,
318+ "updated_at": user.updated_at,
319+ })
320+}
321+
322+/// The plural suffix `y`/`ies` helper for "repositor{y,ies}".
323+fn plural(n: usize) -> &'static str {
324+ if n == 1 { "y" } else { "ies" }
325+}
326+
327+#[cfg(test)]
328+mod tests {
329+ #![allow(clippy::unwrap_used, clippy::expect_used)]
330+
331+ use std::path::PathBuf;
332+
333+ use store::NewRepo;
334+ use tempfile::TempDir;
335+
336+ use super::*;
337+
338+ /// A temp directory holding a SQLite database and a config file that points
339+ /// at it, so the handlers can run end-to-end with no external services.
340+ struct Env {
341+ _dir: TempDir,
342+ config_path: PathBuf,
343+ db_url: String,
344+ }
345+
346+ fn env() -> Env {
347+ let dir = tempfile::tempdir().unwrap();
348+ let repos = dir.path().join("repos");
349+ let db = dir.path().join("fabrica.db");
350+ let db_url = format!("sqlite://{}", db.display());
351+ let config_path = dir.path().join("fabrica.toml");
352+ let toml = format!(
353+ "[storage]\ndata_dir = {:?}\nrepo_dir = {:?}\n\n[database]\nurl = {:?}\n",
354+ dir.path().display(),
355+ repos.display(),
356+ db_url,
357+ );
358+ std::fs::write(&config_path, toml).unwrap();
359+ Env {
360+ _dir: dir,
361+ config_path,
362+ db_url,
363+ }
364+ }
365+
366+ impl Env {
367+ /// The `--config` argument the handlers expect: `Some(path)`. The
368+ /// `Option` is intrinsic to the handler signature, not incidental.
369+ #[allow(clippy::unnecessary_wraps)]
370+ fn path(&self) -> Option<&Path> {
371+ Some(self.config_path.as_path())
372+ }
373+
374+ async fn store(&self) -> Store {
375+ Store::connect(&self.db_url, 1).await.unwrap()
376+ }
377+ }
378+
379+ #[test]
380+ fn clap_command_tree_is_valid() {
381+ use clap::CommandFactory;
382+ crate::Cli::command().debug_assert();
383+ }
384+
385+ #[test]
386+ fn add_with_password_creates_a_usable_admin() {
387+ let env = env();
388+ let code = block_on(add(
389+ env.path(),
390+ "alice",
391+ "alice@example.com",
392+ Some("s3cr3t".to_string()),
393+ true,
394+ Some("Alice".to_string()),
395+ ))
396+ .unwrap();
397+ assert_eq!(code, ExitCode::SUCCESS);
398+
399+ let created = block_on(async {
400+ let user = require_user(&env.store().await, "alice").await?;
401+ anyhow::Ok(user)
402+ })
403+ .unwrap();
404+ assert!(created.is_admin);
405+ assert_eq!(created.display_name.as_deref(), Some("Alice"));
406+ let hash = created.password_hash.expect("password should be set");
407+ assert!(auth::verify_password("s3cr3t", Some(&hash)));
408+ }
409+
410+ #[test]
411+ fn add_without_password_is_inactive_then_passwd_activates() {
412+ let env = env();
413+ block_on(add(env.path(), "bob", "bob@example.com", None, false, None)).unwrap();
414+
415+ let before =
416+ block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap();
417+ assert!(before.password_hash.is_none(), "no password until set");
418+
419+ // The SMTP-disabled fallback: set the password directly.
420+ block_on(passwd(env.path(), "bob", Some("hunter2".to_string()))).unwrap();
421+ let after =
422+ block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap();
423+ let hash = after.password_hash.expect("password now set");
424+ assert!(auth::verify_password("hunter2", Some(&hash)));
425+ }
426+
427+ #[test]
428+ fn disable_then_enable_toggles_the_flag() {
429+ let env = env();
430+ block_on(add(
431+ env.path(),
432+ "carol",
433+ "carol@example.com",
434+ Some("pw".to_string()),
435+ false,
436+ None,
437+ ))
438+ .unwrap();
439+
440+ block_on(set_disabled(env.path(), "carol", true)).unwrap();
441+ let disabled =
442+ block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) })
443+ .unwrap();
444+ assert!(disabled.disabled_at.is_some());
445+
446+ block_on(set_disabled(env.path(), "carol", false)).unwrap();
447+ let enabled =
448+ block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) })
449+ .unwrap();
450+ assert!(enabled.disabled_at.is_none());
451+ }
452+
453+ #[test]
454+ fn del_refuses_repo_owner_without_purge_then_purges() {
455+ let env = env();
456+ block_on(add(
457+ env.path(),
458+ "dave",
459+ "dave@example.com",
460+ Some("pw".to_string()),
461+ false,
462+ None,
463+ ))
464+ .unwrap();
465+
466+ // Give Dave a repo directly through the store.
467+ let dave_id = block_on(async {
468+ let store = env.store().await;
469+ let dave = require_user(&store, "dave").await?;
470+ store
471+ .create_repo(NewRepo {
472+ owner_id: dave.id.clone(),
473+ group_id: None,
474+ name: "proj".to_string(),
475+ path: "proj".to_string(),
476+ description: None,
477+ is_private: true,
478+ default_branch: "main".to_string(),
479+ })
480+ .await?;
481+ anyhow::Ok(dave.id)
482+ })
483+ .unwrap();
484+
485+ // Without --purge the delete is refused and the user survives.
486+ let refused = block_on(del(env.path(), "dave", false, true));
487+ assert!(refused.is_err(), "owning a repo should block deletion");
488+ assert!(
489+ block_on(async { anyhow::Ok(env.store().await.user_by_id(&dave_id).await?) })
490+ .unwrap()
491+ .is_some()
492+ );
493+
494+ // With --purge (and --yes to skip the prompt) the user and repo go.
495+ block_on(del(env.path(), "dave", true, true)).unwrap();
496+ block_on(async {
497+ let store = env.store().await;
498+ assert!(store.user_by_id(&dave_id).await?.is_none());
499+ assert!(store.repos_by_owner(&dave_id).await?.is_empty());
500+ anyhow::Ok(())
501+ })
502+ .unwrap();
503+ }
504+
505+ #[test]
506+ fn passwd_on_missing_user_errors() {
507+ let env = env();
508+ // Create the database (via one successful command) first.
509+ block_on(add(
510+ env.path(),
511+ "eve",
512+ "eve@example.com",
513+ Some("pw".to_string()),
514+ false,
515+ None,
516+ ))
517+ .unwrap();
518+ let err = block_on(passwd(env.path(), "nobody", Some("x".to_string()))).unwrap_err();
519+ assert!(err.to_string().contains("no such user"), "got {err}");
520+ }
521+}
docs/decisions.md +36 −0
@@ -206,3 +206,39 @@ explicit parameters keeps `access` a pure function of its inputs — which is wh
206206 makes the exhaustive matrix test possible — and keeps the collaborator lookup at
207207 the call site (the store), where it belongs. The five ordered rules are unchanged
208208 from the spec.
209+
210+## 2026-07-24 — `user` CLI pulled forward; store-backed commands self-migrate
211+
212+**Decision:** The `fabrica user` command group (`add`, `passwd`, `list`,
213+`disable`, `enable`, `del`) is implemented now, alongside phase 4, rather than
214+waiting for the phase-8 CLI milestone. Every store-backed command applies pending
215+migrations on connect (idempotent), so a fresh install's first `user add`
216+bootstraps the database in one step.
217+
218+**Alternatives:** Keep strictly to phase order and ship the `user` commands only
219+in phase 8; require a separate `fabrica migrate` before any `user` command works.
220+
221+**Rationale:** The requested capability — set a user's password from the CLI when
222+SMTP is unavailable — is realized by `user passwd`, which needs both `auth`
223+(hashing) and `store` (persistence) but nothing from the intervening phases
224+(git/highlight). Building it now delivers that capability immediately and keeps
225+the auth crate exercised by a real caller. Self-migrating on connect is what makes
226+`user add` usable before `serve` has ever run; `serve --no-migrate` remains the
227+opt-out for the server path.
228+
229+## 2026-07-24 — Interim `user add` without `--pass` creates an inactive account
230+
231+**Decision:** Until the mail/invite flow ships (phase 9), `fabrica user add`
232+without `--pass` creates the user with a `NULL` password (unable to log in) and
233+prints guidance to run `fabrica user passwd`. CLI-set passwords clear
234+`must_change_password` (the operator chose a known credential).
235+
236+**Alternatives:** Block `user add` until invites exist; always require `--pass`.
237+
238+**Rationale:** The spec's `user add` mints an emailed activation link, which
239+depends on `crates/mail` (a later phase). The inactive-account-plus-guidance
240+behaviour is a faithful interim: no account is silently left in a login-capable
241+state without a credential, and the operator has an obvious next step. When mail
242+lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays
243+for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case
244+that motivated it.