feat: link members to accounts and make add commands interactive
commit 85e43ea
feat: link members to accounts and make add commands interactive
An account repo can move, so a member’s link to it needs an identifier
that outlives the path: the genesis hash of the account’s first-ever
recorded document, mirroring the genesis-key idiom issues.rs already
uses. checks add, members add, and account create also lose their
hard requirement for CLI flags — any field left unset is now prompted
for at an interactive terminal via inquire, while a script or CI
invocation without a TTY still gets a clear error naming the missing
field instead of hanging.
feat: add account::genesis as a stable, path-independent account identity
feat: add Member.account to @-mention an account by its genesis hash
feat: add members add --account and print the genesis hash from account create
feat: prompt interactively for omitted checks add/members add/account create fields at a TTY
Assisted-by: Claude:claude-sonnet-5
crates/git-ents/src/account.rs
@@ -46,6 +46,21 @@
Ok(load(repo)?.is_some())
}
+/// The account's stable, path-independent identity: the content hash of the
+/// very first [`Account`] ever recorded on [`ACCOUNT_REF`] — fixed at
+/// creation, so later profile edits (`display_name`, `bio`) never change it
+/// and a member's `@`-mention of it survives the account repo moving. `None`
+/// when the repo is not (yet) an account repo. Mirrors the genesis-key idiom
+/// `issues::new_id` uses: an identifier derived from content, never a stored
+/// field.
+pub fn genesis(repo: &Path) -> Result<Option<String>, git_store::Error> {
+ git_store::Store::open(repo)?
+ .history::<Account>(ACCOUNT_REF)?
+ .last()
+ .map(|(_at, account)| git_store::content_hash(account))
+ .transpose()
+}
+
#[cfg(test)]
mod tests {
#![allow(
crates/git-ents/src/main.rs
@@ -9,6 +9,7 @@
//! [`git_ents::members`], and pushing them back.
mod debug_session;
+mod interactive;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -75,11 +76,12 @@
#[arg(default_value = "origin")]
remote: String,
},
- /// Authorize a key for a member on a remote and push the update.
+ /// Authorize a key for a member on a remote and push the update. Prompts
+ /// for any field left unset when run at an interactive terminal.
Add {
/// Member (username) to authorize the key under — its
/// `refs/meta/member/<username>` ref.
- username: String,
+ username: Option<String>,
/// Remote whose member refs to update.
#[arg(default_value = "origin")]
remote: String,
@@ -98,6 +100,10 @@
/// that never lapses on its own.
#[arg(long, value_name = "TIMESTAMP")]
valid_before: Option<String>,
+ /// Link this member to an account by its genesis hash (`git ents
+ /// account create` prints one).
+ #[arg(long, value_name = "GENESIS_HASH")]
+ account: Option<String>,
},
/// Remove a member, deleting its ref on a remote and pushing the update.
Remove {
@@ -143,9 +149,10 @@
enum AccountAction {
/// Create or update this repository's account identity and push it. The
/// presence of `refs/meta/account` is what marks the repo as an account.
+ /// Prompts for any field left unset when run at an interactive terminal.
Create {
/// The account username — by convention the `user/<username>` repo name.
- username: String,
+ username: Option<String>,
/// Remote whose `refs/meta/account` to update.
#[arg(default_value = "origin")]
remote: String,
@@ -153,8 +160,8 @@
#[arg(long)]
display_name: Option<String>,
/// Short free-text bio.
- #[arg(long, default_value = "")]
- bio: String,
+ #[arg(long)]
+ bio: Option<String>,
},
}
@@ -167,11 +174,12 @@
remote: String,
},
/// Add (or replace) a check on a remote's set and push the update.
+ /// Prompts for any field left unset when run at an interactive terminal.
Add {
/// Name to record the check under (`checks/<name>`).
- name: String,
+ name: Option<String>,
/// Command the check runs (e.g. `cargo fmt --check`).
- command: String,
+ command: Option<String>,
/// Remote whose `refs/meta/checks` to update.
#[arg(default_value = "origin")]
remote: String,
@@ -222,13 +230,15 @@
cert_authority,
valid_after,
valid_before,
+ account,
} => members_add(
- &username,
+ username,
&remote,
- key.as_deref(),
- cert_authority.as_deref(),
+ key,
+ cert_authority,
valid_after,
valid_before,
+ account,
),
Action::Remove { username, remote } => members_remove(&username, &remote),
Action::Revoke {
@@ -251,7 +261,7 @@
remote,
display_name,
bio,
- } => account_create(&username, &remote, display_name, bio),
+ } => account_create(username, &remote, display_name, bio),
}
}
@@ -262,7 +272,7 @@
name,
command,
remote,
- } => add_check(&name, &command, &remote),
+ } => add_check(name, command, &remote),
ChecksAction::Remove { name, remote } => remove::<Checks>(&name, &remote),
ChecksAction::Debug { remote } => checks_debug(&remote),
}
@@ -376,15 +386,18 @@
}
/// Add `name` running `command` to `remote`'s set, replacing any check already
-/// recorded under that name, and push the update.
-fn add_check(name: &str, command: &str, remote: &str) -> Result<(), String> {
+/// recorded under that name, and push the update. Prompts for either field
+/// left `None` when run at an interactive terminal.
+fn add_check(name: Option<String>, command: Option<String>, remote: &str) -> Result<(), String> {
+ let name = interactive::text_or(name, "Check name")?;
+ let command = interactive::text_or(command, "Command")?;
let repo = repo()?;
let expected = sync(remote, CHECKS_REF)?;
let mut checks = checks::load(&repo).map_err(|error| error.to_string())?;
checks.retain(|check| check.name != name);
checks.push(Check {
- name: name.to_owned(),
- command: command.to_owned(),
+ name: name.clone(),
+ command,
});
checks::store(&repo, &checks).map_err(|error| error.to_string())?;
push_signed(remote, CHECKS_REF, expected.as_deref())?;
@@ -629,16 +642,46 @@
Ok(())
}
+/// The `key`/`cert_authority` pair for [`members_add`]. Used as given when
+/// either is already set or the terminal is non-interactive, so
+/// `--key`/`--cert-authority` and scripted runs are unchanged; otherwise
+/// prompts for which kind of trust to add.
+fn resolve_trust(
+ key: Option<PathBuf>,
+ cert_authority: Option<PathBuf>,
+) -> Result<(Option<PathBuf>, Option<PathBuf>), String> {
+ if key.is_some() || cert_authority.is_some() || !interactive::available() {
+ return Ok((key, cert_authority));
+ }
+ let choice = interactive::select_or("Trust", &["Signing key", "Certificate authority"], 0)?;
+ if choice == 1 {
+ let path = interactive::text_or(None, "Certificate authority public key path")?;
+ Ok((None, Some(PathBuf::from(path))))
+ } else {
+ let path =
+ interactive::optional_text_or(None, "Signing key path (blank for user.signingkey)")?;
+ Ok((path.map(PathBuf::from), None))
+ }
+}
+
/// Authorize a key (or pin a CA) for the member `username` on `remote`, trusting
/// the member within the given validity window, and push the updated member ref.
fn members_add(
- username: &str,
+ username: Option<String>,
remote: &str,
- key: Option<&Path>,
- cert_authority: Option<&Path>,
+ key: Option<PathBuf>,
+ cert_authority: Option<PathBuf>,
valid_after: Option<String>,
valid_before: Option<String>,
+ account: Option<String>,
) -> Result<(), String> {
+ let username = interactive::text_or(username, "Username")?;
+ let (key, cert_authority) = resolve_trust(key, cert_authority)?;
+ let valid_after = interactive::optional_text_or(valid_after, "Valid after (blank for none)")?;
+ let valid_before =
+ interactive::optional_text_or(valid_before, "Valid before (blank for none)")?;
+ let account =
+ interactive::optional_text_or(account, "Link to account (genesis hash, blank to skip)")?;
if let Some(after) = &valid_after {
validate_timestamp(after)?;
}
@@ -646,22 +689,25 @@
validate_timestamp(before)?;
}
let repo = repo()?;
- let refname = member_ref(username);
+ let refname = member_ref(&username);
let expected = sync(remote, &refname)?;
- let mut member = members::load(&repo, username)
+ let mut member = members::load(&repo, &username)
.map_err(|error| error.to_string())?
- .unwrap_or_else(|| Member::with_keys(username.to_owned(), BTreeMap::new()));
+ .unwrap_or_else(|| Member::with_keys(username.clone(), BTreeMap::new()));
if valid_after.is_some() {
member.valid_after = valid_after;
}
if valid_before.is_some() {
member.valid_before = valid_before;
}
+ if account.is_some() {
+ member.account = account;
+ }
// Pinning a CA replaces the member's trust wholesale — a member is either
// leaf keys or a CA, never both.
if let Some(ca_path) = cert_authority {
- let ca = read_public_key(ca_path)?;
+ let ca = read_public_key(&ca_path)?;
member.trust = Trust::CertAuthority(ca);
members::store(&repo, &member).map_err(|error| error.to_string())?;
push_signed(remote, &refname, expected.as_deref())?;
@@ -669,7 +715,7 @@
return Ok(());
}
- let public_key = public_key(key)?;
+ let public_key = public_key(key.as_deref())?;
let fingerprint = fingerprint(&public_key)?;
let keys = match &mut member.trust {
Trust::Keys(keys) => keys,
@@ -714,24 +760,32 @@
/// Create or update this repository's account identity on `remote` and push it.
fn account_create(
- username: &str,
+ username: Option<String>,
remote: &str,
display_name: Option<String>,
- bio: String,
+ bio: Option<String>,
) -> Result<(), String> {
+ let username = interactive::text_or(username, "Username")?;
+ let display_name =
+ interactive::optional_text_or(display_name, "Display name (blank to use username)")?;
+ let bio = interactive::optional_text_or(bio, "Bio (blank to skip)")?.unwrap_or_default();
let repo = repo()?;
let expected = sync(remote, account::ACCOUNT_REF)?;
let existing = account::load(&repo).map_err(|error| error.to_string())?;
let account = Account {
- username: username.to_owned(),
- display_name: display_name.unwrap_or_else(|| username.to_owned()),
+ username: username.clone(),
+ display_name: display_name.unwrap_or_else(|| username.clone()),
bio,
// Preserve the original creation time when updating an existing account.
created_at: existing.map_or_else(now_seconds, |account| account.created_at),
};
account::store(&repo, &account).map_err(|error| error.to_string())?;
push_signed(remote, account::ACCOUNT_REF, expected.as_deref())?;
+ let genesis = account::genesis(&repo).map_err(|error| error.to_string())?;
println!("created account {username}");
+ if let Some(genesis) = genesis {
+ println!("genesis: {genesis} (pass to `members add --account` to link a member)");
+ }
Ok(())
}
crates/git-ents/src/members.rs
@@ -73,6 +73,12 @@
/// `loads_a_member_ref_with_no_provenance_entry`.
#[facet(default)]
pub provenance: Provenance,
+ /// The `@`-mentioned account this member is, by its stable
+ /// [`crate::account::genesis`] hash — `None` until an admin links one.
+ /// Plain `Option`, which `facet-git-tree` auto-defaults on an absent
+ /// entry, so a member ref written before this field existed keeps
+ /// loading unchanged.
+ pub account: Option<String>,
}
/// Whether a member was admin-registered or self-attested via web onboarding.
@@ -147,6 +153,7 @@
valid_before: None,
trust: Trust::Keys(keys),
provenance: Provenance::AdminRegistered,
+ account: None,
}
}
@@ -160,6 +167,7 @@
valid_before: None,
trust: Trust::CertAuthority(ca),
provenance: Provenance::AdminRegistered,
+ account: None,
}
}
@@ -173,6 +181,7 @@
valid_before: None,
trust: Trust::WebAuthn(keys),
provenance: Provenance::SelfAttestedWeb,
+ account: None,
}
}
crates/git-ents/src/interactive.rs
@@ -1,0 +1,61 @@
+//! Prompting for `add` commands left with unset fields.
+//!
+//! An omitted field is filled interactively when the terminal supports it,
+//! so `git ents checks add` alone walks a user through every field; a script
+//! or CI invocation without a TTY gets a clear error instead of a hang.
+
+use std::io::IsTerminal as _;
+
+/// Whether prompting is possible: both stdin and stdout are a terminal.
+#[must_use]
+pub fn available() -> bool {
+ std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
+}
+
+/// `existing`, or a required text prompt for `message` when interactive; an
+/// error naming `message` when not, so a script never hangs on a missing
+/// argument.
+pub fn text_or(existing: Option<String>, message: &str) -> Result<String, String> {
+ if let Some(value) = existing {
+ return Ok(value);
+ }
+ if !available() {
+ return Err(format!(
+ "{message} is required (not an interactive terminal)"
+ ));
+ }
+ inquire::Text::new(message)
+ .prompt()
+ .map_err(|error| error.to_string())
+}
+
+/// `existing`, or an optional text prompt for `message` when interactive —
+/// an empty reply is `None`. Non-interactive with no `existing` value stays
+/// `None` rather than erroring, since the field is optional.
+pub fn optional_text_or(existing: Option<String>, message: &str) -> Result<Option<String>, String> {
+ if existing.is_some() {
+ return Ok(existing);
+ }
+ if !available() {
+ return Ok(None);
+ }
+ let value = inquire::Text::new(message)
+ .prompt()
+ .map_err(|error| error.to_string())?;
+ Ok((!value.is_empty()).then_some(value))
+}
+
+/// A `Select` prompt among `options`, run only when interactive; `default`
+/// otherwise.
+pub fn select_or(message: &str, options: &[&str], default: usize) -> Result<usize, String> {
+ if !available() {
+ return Ok(default);
+ }
+ let choice = inquire::Select::new(message, options.to_vec())
+ .prompt()
+ .map_err(|error| error.to_string())?;
+ Ok(options
+ .iter()
+ .position(|option| *option == choice)
+ .unwrap_or(default))
+}