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