feat(gateway): hivectl gateway user management + fix htpasswdFile assertion

Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.

Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.

Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
atlas 2026-06-01 23:00:38 +02:00
commit 4bff450343
61 changed files with 1084 additions and 547 deletions

View file

@ -23,8 +23,8 @@ pub fn resolve(body: Option<&str>, file: Option<&str>) -> Result<Option<String>>
if path == "-" {
return Ok(Some(read_stdin().context("read stdin for --body-file -")?));
}
let s = std::fs::read_to_string(path)
.with_context(|| format!("read --body-file {path}"))?;
let s =
std::fs::read_to_string(path).with_context(|| format!("read --body-file {path}"))?;
return Ok(Some(s));
}
if !std::io::stdin().is_terminal() {

View file

@ -180,7 +180,12 @@ impl Client {
let form = reqwest::blocking::multipart::Form::new()
.file("attachment", file)
.with_context(|| format!("read {}", file.display()))?;
let resp = self.http.post(&url).multipart(form).send().context("POST")?;
let resp = self
.http
.post(&url)
.multipart(form)
.send()
.context("POST")?;
decode_json(resp, &format!("POST {url}"))
}
}

View file

@ -103,8 +103,7 @@ enum Verb {
fn main() -> Result<()> {
let cli = Cli::parse();
let client =
client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
match cli.verb {
Verb::View(a) => verbs::view::run(&client, a),
Verb::Issue(a) => verbs::issue::run(&client, a),

View file

@ -105,10 +105,7 @@ fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<
return Ok(Vec::new());
}
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
let total = issue
.get("comments")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let total = issue.get("comments").and_then(Value::as_u64).unwrap_or(0) as usize;
if total == 0 {
return Ok(Vec::new());
}

View file

@ -32,7 +32,10 @@ pub struct Args {
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
let diff = client.get_text(
&format!("/repos/{repo}/pulls/{}.diff", args.number),
"text/plain",
)?;
let out = if args.full {
diff
} else {
@ -218,7 +221,9 @@ mod tests {
assert!(is_autogenerated("flake.lock"));
assert!(is_autogenerated("a/flake.lock"));
assert!(is_autogenerated("hive-c0re/Cargo.lock"));
assert!(is_autogenerated("frontend/packages/dashboard/package-lock.json"));
assert!(is_autogenerated(
"frontend/packages/dashboard/package-lock.json"
));
assert!(!is_autogenerated("src/main.rs"));
assert!(!is_autogenerated("Cargo.toml"));
// Suffix-only files we deliberately don't match — keep
@ -346,10 +351,7 @@ index 1111..2222 100644
#[test]
fn parse_diff_git_path_picks_b_side() {
assert_eq!(
parse_diff_git_path("a/foo b/foo"),
Some("foo".to_owned())
);
assert_eq!(parse_diff_git_path("a/foo b/foo"), Some("foo".to_owned()));
assert_eq!(
parse_diff_git_path("a/old.txt b/new.txt"),
Some("new.txt".to_owned())

View file

@ -36,7 +36,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
match args.action.unwrap_or(Action::List) {
Action::List => {
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
let labels =
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
print_label_names(&labels);
}
Action::Add { labels } => {
@ -64,7 +65,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
);
}
}
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
let labels =
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
print_label_names(&labels);
}
}

View file

@ -167,9 +167,7 @@ fn print_row(item: &Value) {
// forge always populates `pull_request` but sets it to `null` for
// issues; we check non-null specifically rather than just-present
// (which would render every issue as a PR).
let is_pr = item
.get("pull_request")
.is_some_and(|v| !v.is_null());
let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null());
let kind = if is_pr { "PR" } else { " " };
println!("#{number:>4} {kind} [{author}] {title}");
}

View file

@ -41,8 +41,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
match args.action.unwrap_or(Action::List) {
Action::List => {
let v =
client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
let v = client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
let trimmed: Vec<Value> = v
.as_array()
.map(|a| {

View file

@ -222,7 +222,9 @@ To http://localhost:3000/hyperhive/hyperhive.git\n\
assert!(is_pr_hint_opener(
"remote: Create a new pull request for 'x':\n"
));
assert!(is_pr_hint_opener("remote: Visit the existing pull request:\n"));
assert!(is_pr_hint_opener(
"remote: Visit the existing pull request:\n"
));
assert!(!is_pr_hint_opener("remote: some other thing\n"));
assert!(!is_pr_hint_opener("To http://example.com\n"));
// Trailing-CRLF safety on windows-cloned forge clones.

View file

@ -29,7 +29,11 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
return Ok(());
}
if args.watch || args.ignore {
let (subscribed, ignored) = if args.ignore { (false, true) } else { (true, false) };
let (subscribed, ignored) = if args.ignore {
(false, true)
} else {
(true, false)
};
let resp = client.post_json(
&format!("/repos/{repo}/subscription"),
&json!({ "subscribed": subscribed, "ignored": ignored }),

View file

@ -170,7 +170,10 @@ fn format_event(ev: &Value) -> String {
}
}
"commit_ref" => {
let sha = ev.get("ref_commit_sha").and_then(Value::as_str).unwrap_or("");
let sha = ev
.get("ref_commit_sha")
.and_then(Value::as_str)
.unwrap_or("");
let short: String = sha.chars().take(7).collect();
if short.is_empty() {
"referenced from a commit".to_owned()
@ -217,7 +220,10 @@ mod tests {
"created_at": "2026-05-31T12:00:00Z",
"body": "looks good to me",
});
assert_eq!(format_event(&ev), "**iris @ 2026-05-31T12:00:00Z**: looks good to me");
assert_eq!(
format_event(&ev),
"**iris @ 2026-05-31T12:00:00Z**: looks good to me"
);
}
#[test]