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:
parent
25d2951d1e
commit
4bff450343
61 changed files with 1084 additions and 547 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue