Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +110 −9
| @@ -44,7 +44,7 @@ version = "1.1.5" | |||
| 44 | 44 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | 45 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 46 | 46 | dependencies = [ | |
| 47 | - | "windows-sys", | |
| 47 | + | "windows-sys 0.61.2", | |
| 48 | 48 | ] | |
| 49 | 49 | ||
| 50 | 50 | [[package]] | |
| @@ -55,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |||
| 55 | 55 | dependencies = [ | |
| 56 | 56 | "anstyle", | |
| 57 | 57 | "once_cell_polyfill", | |
| 58 | - | "windows-sys", | |
| 58 | + | "windows-sys 0.61.2", | |
| 59 | 59 | ] | |
| 60 | 60 | ||
| 61 | 61 | [[package]] | |
| @@ -263,9 +263,16 @@ name = "cli" | |||
| 263 | 263 | version = "0.1.0" | |
| 264 | 264 | dependencies = [ | |
| 265 | 265 | "anyhow", | |
| 266 | + | "auth", | |
| 266 | 267 | "clap", | |
| 267 | 268 | "config", | |
| 269 | + | "model", | |
| 270 | + | "rpassword", | |
| 271 | + | "serde", | |
| 268 | 272 | "serde_json", | |
| 273 | + | "store", | |
| 274 | + | "tempfile", | |
| 275 | + | "tokio", | |
| 269 | 276 | ] | |
| 270 | 277 | ||
| 271 | 278 | [[package]] | |
| @@ -437,7 +444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 437 | 444 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 438 | 445 | dependencies = [ | |
| 439 | 446 | "libc", | |
| 440 | - | "windows-sys", | |
| 447 | + | "windows-sys 0.61.2", | |
| 441 | 448 | ] | |
| 442 | 449 | ||
| 443 | 450 | [[package]] | |
| @@ -447,7 +454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 447 | 454 | checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" | |
| 448 | 455 | dependencies = [ | |
| 449 | 456 | "cfg-if", | |
| 450 | - | "windows-sys", | |
| 457 | + | "windows-sys 0.61.2", | |
| 451 | 458 | ] | |
| 452 | 459 | ||
| 453 | 460 | [[package]] | |
| @@ -933,7 +940,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" | |||
| 933 | 940 | dependencies = [ | |
| 934 | 941 | "libc", | |
| 935 | 942 | "wasi", | |
| 936 | - | "windows-sys", | |
| 943 | + | "windows-sys 0.61.2", | |
| 937 | 944 | ] | |
| 938 | 945 | ||
| 939 | 946 | [[package]] | |
| @@ -1171,6 +1178,27 @@ dependencies = [ | |||
| 1171 | 1178 | ] | |
| 1172 | 1179 | ||
| 1173 | 1180 | [[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]] | |
| 1174 | 1202 | name = "rustix" | |
| 1175 | 1203 | version = "1.1.4" | |
| 1176 | 1204 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1180,7 +1208,7 @@ dependencies = [ | |||
| 1180 | 1208 | "errno", | |
| 1181 | 1209 | "libc", | |
| 1182 | 1210 | "linux-raw-sys", | |
| 1183 | - | "windows-sys", | |
| 1211 | + | "windows-sys 0.61.2", | |
| 1184 | 1212 | ] | |
| 1185 | 1213 | ||
| 1186 | 1214 | [[package]] | |
| @@ -1308,7 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1308 | 1336 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 1309 | 1337 | dependencies = [ | |
| 1310 | 1338 | "libc", | |
| 1311 | - | "windows-sys", | |
| 1339 | + | "windows-sys 0.61.2", | |
| 1312 | 1340 | ] | |
| 1313 | 1341 | ||
| 1314 | 1342 | [[package]] | |
| @@ -1578,7 +1606,7 @@ dependencies = [ | |||
| 1578 | 1606 | "getrandom 0.4.3", | |
| 1579 | 1607 | "once_cell", | |
| 1580 | 1608 | "rustix", | |
| 1581 | - | "windows-sys", | |
| 1609 | + | "windows-sys 0.61.2", | |
| 1582 | 1610 | ] | |
| 1583 | 1611 | ||
| 1584 | 1612 | [[package]] | |
| @@ -1638,7 +1666,7 @@ dependencies = [ | |||
| 1638 | 1666 | "pin-project-lite", | |
| 1639 | 1667 | "socket2", | |
| 1640 | 1668 | "tokio-macros", | |
| 1641 | - | "windows-sys", | |
| 1669 | + | "windows-sys 0.61.2", | |
| 1642 | 1670 | ] | |
| 1643 | 1671 | ||
| 1644 | 1672 | [[package]] | |
| @@ -1912,6 +1940,15 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |||
| 1912 | 1940 | ||
| 1913 | 1941 | [[package]] | |
| 1914 | 1942 | 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" | |
| 1915 | 1952 | version = "0.61.2" | |
| 1916 | 1953 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1917 | 1954 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| @@ -1920,6 +1957,70 @@ dependencies = [ | |||
| 1920 | 1957 | ] | |
| 1921 | 1958 | ||
| 1922 | 1959 | [[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]] | |
| 1923 | 2024 | name = "winnow" | |
| 1924 | 2025 | version = "0.7.15" | |
| 1925 | 2026 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
crates/cli/Cargo.toml +9 −0
| @@ -11,9 +11,18 @@ publish.workspace = true | |||
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | 13 | config = { workspace = true } | |
| 14 | + | store = { workspace = true } | |
| 15 | + | auth = { workspace = true } | |
| 16 | + | model = { workspace = true } | |
| 14 | 17 | anyhow = { workspace = true } | |
| 15 | 18 | clap = { workspace = true } | |
| 19 | + | serde = { workspace = true } | |
| 16 | 20 | serde_json = { workspace = true } | |
| 21 | + | tokio = { workspace = true } | |
| 22 | + | rpassword = { workspace = true } | |
| 23 | + | ||
| 24 | + | [dev-dependencies] | |
| 25 | + | tempfile = { workspace = true } | |
| 17 | 26 | ||
| 18 | 27 | [lints] | |
| 19 | 28 | 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 @@ | |||
| 10 | 10 | //! phases; today it covers only `config check` and `config show`. | |
| 11 | 11 | ||
| 12 | 12 | mod config_cmd; | |
| 13 | + | mod context; | |
| 14 | + | mod prompt; | |
| 15 | + | mod user_cmd; | |
| 13 | 16 | ||
| 14 | 17 | use std::path::PathBuf; | |
| 15 | 18 | use std::process::ExitCode; | |
| @@ -46,6 +49,12 @@ enum Command { | |||
| 46 | 49 | #[command(subcommand)] | |
| 47 | 50 | command: config_cmd::ConfigCommand, | |
| 48 | 51 | }, | |
| 52 | + | /// Manage user accounts. | |
| 53 | + | User { | |
| 54 | + | /// The user action to perform. | |
| 55 | + | #[command(subcommand)] | |
| 56 | + | command: user_cmd::UserCommand, | |
| 57 | + | }, | |
| 49 | 58 | } | |
| 50 | 59 | ||
| 51 | 60 | /// Parse arguments and dispatch to the requested subcommand. | |
| @@ -69,5 +78,6 @@ pub fn run() -> ExitCode { | |||
| 69 | 78 | fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> { | |
| 70 | 79 | match &cli.command { | |
| 71 | 80 | 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), | |
| 72 | 82 | } | |
| 73 | 83 | } | |
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 | |||
| 206 | 206 | makes the exhaustive matrix test possible — and keeps the collaborator lookup at | |
| 207 | 207 | the call site (the store), where it belongs. The five ordered rules are unchanged | |
| 208 | 208 | 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. | |