expand: auth token/refresh/setup-git, protect, hook, --debug, man pages
* `fj auth token` prints the stored token for scripting. * `fj auth refresh` re-verifies (or replaces) the stored token. * `fj auth setup-git` installs a git credential helper that delegates password lookup to `fj auth token`. * New top-level `fj protect` group: list / view / set / delete branch protection rules. * New top-level `fj hook` group: webhook CRUD + test delivery. * Global `--debug` (`FJ_DEBUG`) flag dumps every HTTP request fj makes to stderr (method, URL, query, body preview, status). * `fj man -o ./man` generates `clap_mangen` pages for fj and every subcommand. Useful for downstream packaging. * Fixed: several subcommand groups (hook, label, search, key, workflow secrets/variables) were showing the flattened RepoFlag's doc string in their --help summary line. Added explicit doc comments per variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de49c33921
commit
35d88bb370
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -278,6 +278,16 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_mangen"
|
||||||
|
version = "0.2.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"roff",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|
@ -430,6 +440,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
"clap_mangen",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
"directories",
|
"directories",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -1294,6 +1305,12 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roff"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ anyhow = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
|
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
|
||||||
clap_complete = "4.5"
|
clap_complete = "4.5"
|
||||||
|
clap_mangen = "0.2"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "io-std", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "io-std", "signal"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "gzip", "brotli", "multipart"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "gzip", "brotli", "multipart"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
|
||||||
58
src/api/hook.rs
Normal file
58
src/api/hook.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Hook {
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub events: Vec<String>,
|
||||||
|
pub active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(client: &Client, owner: &str, name: &str) -> Result<Vec<Hook>> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/hooks");
|
||||||
|
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateHook<'a> {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: &'a str,
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
pub events: Vec<&'a str>,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
client: &Client,
|
||||||
|
owner: &str,
|
||||||
|
name: &str,
|
||||||
|
body: &CreateHook<'_>,
|
||||||
|
) -> Result<Hook> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/hooks");
|
||||||
|
client.json(Method::POST, &path, &[], Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(client: &Client, owner: &str, name: &str, id: u64) -> Result<()> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/hooks/{id}");
|
||||||
|
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||||
|
res.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test(client: &Client, owner: &str, name: &str, id: u64) -> Result<()> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/hooks/{id}/tests");
|
||||||
|
let res = client.request(Method::POST, &path, &[], None).await?;
|
||||||
|
res.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
pub mod hook;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod label;
|
pub mod label;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod org;
|
pub mod org;
|
||||||
|
pub mod protect;
|
||||||
pub mod pull;
|
pub mod pull;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
|
|
|
||||||
69
src/api/protect.rs
Normal file
69
src/api/protect.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BranchProtection {
|
||||||
|
pub branch_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rule_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_push: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_push_whitelist: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub push_whitelist_usernames: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub require_signed_commits: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub block_on_outdated_branch: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub required_approvals: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(client: &Client, owner: &str, name: &str) -> Result<Vec<BranchProtection>> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/branch_protections");
|
||||||
|
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
client: &Client,
|
||||||
|
owner: &str,
|
||||||
|
name: &str,
|
||||||
|
branch: &str,
|
||||||
|
) -> Result<BranchProtection> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/branch_protections/{branch}");
|
||||||
|
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CreateBranchProtection<'a> {
|
||||||
|
pub branch_name: &'a str,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_push: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub require_signed_commits: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub block_on_outdated_branch: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub required_approvals: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
client: &Client,
|
||||||
|
owner: &str,
|
||||||
|
name: &str,
|
||||||
|
body: &CreateBranchProtection<'_>,
|
||||||
|
) -> Result<BranchProtection> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/branch_protections");
|
||||||
|
client.json(Method::POST, &path, &[], Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(client: &Client, owner: &str, name: &str, branch: &str) -> Result<()> {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{name}/branch_protections/{branch}");
|
||||||
|
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||||
|
res.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ pub struct AliasCmd {
|
||||||
pub enum AliasSub {
|
pub enum AliasSub {
|
||||||
/// List configured aliases.
|
/// List configured aliases.
|
||||||
List,
|
List,
|
||||||
/// Set an alias: `fj alias set co "pr checkout"`.
|
/// Set an alias (e.g. `fj alias set co "pr checkout"`).
|
||||||
Set(SetArgs),
|
Set(SetArgs),
|
||||||
/// Delete an alias.
|
/// Delete an alias.
|
||||||
Delete(DeleteArgs),
|
Delete(DeleteArgs),
|
||||||
|
|
|
||||||
130
src/cli/auth.rs
130
src/cli/auth.rs
|
|
@ -26,6 +26,39 @@ pub enum AuthSub {
|
||||||
List,
|
List,
|
||||||
/// Set the default host for commands that omit `--host`.
|
/// Set the default host for commands that omit `--host`.
|
||||||
Switch(SwitchArgs),
|
Switch(SwitchArgs),
|
||||||
|
/// Print the stored token for a host (for scripting).
|
||||||
|
Token(TokenArgs),
|
||||||
|
/// Re-verify the stored token. Replaces it if a new one is given.
|
||||||
|
Refresh(RefreshArgs),
|
||||||
|
/// Install a git credential helper that uses fj's stored token.
|
||||||
|
SetupGit(SetupGitArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct TokenArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct RefreshArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// Replace the stored token. If omitted, fj just re-verifies the existing one.
|
||||||
|
#[arg(long)]
|
||||||
|
pub token: Option<String>,
|
||||||
|
/// Read replacement token from stdin.
|
||||||
|
#[arg(long, conflicts_with = "token")]
|
||||||
|
pub with_token: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct SetupGitArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// Print the recommended git config commands without applying them.
|
||||||
|
#[arg(long)]
|
||||||
|
pub dry_run: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
|
|
@ -71,9 +104,106 @@ pub async fn run(cmd: AuthCmd) -> Result<()> {
|
||||||
AuthSub::Logout(args) => logout(args),
|
AuthSub::Logout(args) => logout(args),
|
||||||
AuthSub::List => list(),
|
AuthSub::List => list(),
|
||||||
AuthSub::Switch(args) => switch(args),
|
AuthSub::Switch(args) => switch(args),
|
||||||
|
AuthSub::Token(args) => token(args),
|
||||||
|
AuthSub::Refresh(args) => refresh(args).await,
|
||||||
|
AuthSub::SetupGit(args) => setup_git(args),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn token(args: TokenArgs) -> Result<()> {
|
||||||
|
let hosts = Hosts::load()?;
|
||||||
|
let host = hosts.resolve_host(args.host.as_deref())?.to_string();
|
||||||
|
let token = token_store::load_token(&host)?
|
||||||
|
.ok_or_else(|| anyhow!("no token stored for {host}; run `fj auth login`"))?;
|
||||||
|
println!("{token}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(args: RefreshArgs) -> Result<()> {
|
||||||
|
let hosts = Hosts::load()?;
|
||||||
|
let host = hosts.resolve_host(args.host.as_deref())?.to_string();
|
||||||
|
let cfg = hosts
|
||||||
|
.hosts
|
||||||
|
.get(&host)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("host '{host}' not configured"))?;
|
||||||
|
|
||||||
|
let new_token = if let Some(t) = args.token {
|
||||||
|
Some(t)
|
||||||
|
} else if args.with_token {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buf = String::new();
|
||||||
|
std::io::stdin().read_to_string(&mut buf)?;
|
||||||
|
Some(buf.trim().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = match &new_token {
|
||||||
|
Some(t) => t.clone(),
|
||||||
|
None => token_store::load_token(&host)?
|
||||||
|
.ok_or_else(|| anyhow!("no token stored; pass --token to set one"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let probe = build_probe_client(&host, &cfg, &token)?;
|
||||||
|
let me = api::user::current(&probe)
|
||||||
|
.await
|
||||||
|
.context("verifying token against /api/v1/user")?;
|
||||||
|
|
||||||
|
if new_token.is_some() {
|
||||||
|
token_store::store_token(&host, &token)?;
|
||||||
|
println!("✓ Replaced token for {host} (verified as {})", me.login);
|
||||||
|
} else {
|
||||||
|
println!("✓ Token for {host} is still valid (user: {})", me.login);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_git(args: SetupGitArgs) -> Result<()> {
|
||||||
|
let hosts = Hosts::load()?;
|
||||||
|
let host = hosts.resolve_host(args.host.as_deref())?.to_string();
|
||||||
|
let cfg = hosts
|
||||||
|
.hosts
|
||||||
|
.get(&host)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("host '{host}' not configured"))?;
|
||||||
|
// Use git's credential.helper indirection: fj prints the token on demand.
|
||||||
|
// We register an `https://<host>` scoped helper that invokes
|
||||||
|
// `fj auth git-credential` (a synthetic subcommand exposed via stdin).
|
||||||
|
let helper = format!("!fj auth token --host {host} | sed 's/^/password=/'");
|
||||||
|
let cmd_set_helper = format!("git config --global credential.https://{host}.helper '{helper}'");
|
||||||
|
let cmd_set_user = match cfg.user.as_deref() {
|
||||||
|
Some(u) => format!("git config --global credential.https://{host}.username {u}"),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if args.dry_run {
|
||||||
|
println!("# Run these commands to install the fj git credential helper:");
|
||||||
|
println!("{cmd_set_helper}");
|
||||||
|
if !cmd_set_user.is_empty() {
|
||||||
|
println!("{cmd_set_user}");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply.
|
||||||
|
let parts: Vec<String> = vec![cmd_set_helper.clone(), cmd_set_user.clone()]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
for c in parts {
|
||||||
|
let status = std::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&c)
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!("`{c}` failed with status {status}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("✓ Installed git credential helper for {host}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn login(args: LoginArgs) -> Result<()> {
|
async fn login(args: LoginArgs) -> Result<()> {
|
||||||
let hostname = match args.host.clone() {
|
let hostname = match args.host.clone() {
|
||||||
Some(h) => h,
|
Some(h) => h,
|
||||||
|
|
|
||||||
148
src/cli/hook.rs
Normal file
148
src/cli/hook.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
//! `fj hook` — repository webhooks.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::api::hook::{self, CreateHook};
|
||||||
|
use crate::cli::context::{resolve_repo, RepoFlag};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct HookCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: HookSub,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum HookSub {
|
||||||
|
/// List webhooks configured on a repo.
|
||||||
|
List(ListArgs),
|
||||||
|
/// Create a new webhook pointing at a URL.
|
||||||
|
Create(CreateArgs),
|
||||||
|
/// Delete a webhook by id.
|
||||||
|
Delete(DeleteArgs),
|
||||||
|
/// Send a test delivery to an existing webhook.
|
||||||
|
Test(DeleteArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ListArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub r: RepoFlag,
|
||||||
|
#[arg(long)]
|
||||||
|
pub json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct CreateArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub r: RepoFlag,
|
||||||
|
/// Webhook URL.
|
||||||
|
pub url: String,
|
||||||
|
/// Hook type. Defaults to `gitea` (Gitea-compatible JSON payload).
|
||||||
|
#[arg(long, default_value = "gitea")]
|
||||||
|
pub r#type: String,
|
||||||
|
/// Events to subscribe to (comma-separated). Defaults to `push`.
|
||||||
|
#[arg(long, default_value = "push")]
|
||||||
|
pub events: String,
|
||||||
|
/// Set Content-Type for the request.
|
||||||
|
#[arg(long, default_value = "json")]
|
||||||
|
pub content_type: String,
|
||||||
|
/// HMAC secret used to sign webhook deliveries.
|
||||||
|
#[arg(long)]
|
||||||
|
pub secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct DeleteArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub r: RepoFlag,
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(cmd: HookCmd, host: Option<&str>) -> Result<()> {
|
||||||
|
match cmd.command {
|
||||||
|
HookSub::List(args) => list(args, host).await,
|
||||||
|
HookSub::Create(args) => create(args, host).await,
|
||||||
|
HookSub::Delete(args) => delete(args, host).await,
|
||||||
|
HookSub::Test(args) => test(args, host).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
let items = hook::list(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||||
|
if args.json {
|
||||||
|
return output::print_json(&serde_json::to_value(&items)?);
|
||||||
|
}
|
||||||
|
if items.is_empty() {
|
||||||
|
println!("(no webhooks)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rows: Vec<Vec<String>> = items
|
||||||
|
.iter()
|
||||||
|
.map(|h| {
|
||||||
|
let url = h
|
||||||
|
.config
|
||||||
|
.get("url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
vec![
|
||||||
|
h.id.to_string(),
|
||||||
|
h.type_.clone(),
|
||||||
|
url,
|
||||||
|
h.events.join(","),
|
||||||
|
h.active.to_string(),
|
||||||
|
output::dim(&output::relative_time(h.updated_at)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
output::render_table(&["ID", "TYPE", "URL", "EVENTS", "ACTIVE", "UPDATED"], &rows)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
let events: Vec<&str> = args
|
||||||
|
.events
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
let mut config = serde_json::Map::new();
|
||||||
|
config.insert("url".into(), serde_json::Value::String(args.url.clone()));
|
||||||
|
config.insert(
|
||||||
|
"content_type".into(),
|
||||||
|
serde_json::Value::String(args.content_type),
|
||||||
|
);
|
||||||
|
if let Some(s) = &args.secret {
|
||||||
|
config.insert("secret".into(), serde_json::Value::String(s.clone()));
|
||||||
|
}
|
||||||
|
let body = CreateHook {
|
||||||
|
type_: &args.r#type,
|
||||||
|
config: serde_json::Value::Object(config),
|
||||||
|
events,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
let h = hook::create(&ctx.client, &ctx.owner, &ctx.name, &body).await?;
|
||||||
|
println!("✓ Created webhook #{} → {}", h.id, args.url);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
hook::delete(&ctx.client, &ctx.owner, &ctx.name, args.id).await?;
|
||||||
|
println!("✓ Deleted webhook #{}", args.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test(args: DeleteArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
hook::test(&ctx.client, &ctx.owner, &ctx.name, args.id).await?;
|
||||||
|
println!("✓ Triggered test delivery for webhook #{}", args.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -34,8 +34,11 @@ pub struct SshKeyCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum SshKeySub {
|
pub enum SshKeySub {
|
||||||
|
/// List SSH keys on your account.
|
||||||
List(ListArgs),
|
List(ListArgs),
|
||||||
|
/// Add an SSH key.
|
||||||
Add(AddArgs),
|
Add(AddArgs),
|
||||||
|
/// Remove an SSH key by id.
|
||||||
Delete(DeleteArgs),
|
Delete(DeleteArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,8 +50,11 @@ pub struct GpgKeyCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum GpgKeySub {
|
pub enum GpgKeySub {
|
||||||
|
/// List GPG keys on your account.
|
||||||
List(ListArgs),
|
List(ListArgs),
|
||||||
|
/// Add a GPG key.
|
||||||
Add(GpgAddArgs),
|
Add(GpgAddArgs),
|
||||||
|
/// Remove a GPG key by id.
|
||||||
Delete(DeleteArgs),
|
Delete(DeleteArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@ pub struct LabelCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum LabelSub {
|
pub enum LabelSub {
|
||||||
|
/// List labels in a repo.
|
||||||
List(ListArgs),
|
List(ListArgs),
|
||||||
|
/// Create a new label.
|
||||||
Create(CreateArgs),
|
Create(CreateArgs),
|
||||||
|
/// Edit a label's name, color, or description.
|
||||||
Edit(EditArgs),
|
Edit(EditArgs),
|
||||||
|
/// Delete a label.
|
||||||
Delete(DeleteArgs),
|
Delete(DeleteArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ pub mod context;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod extension;
|
pub mod extension;
|
||||||
pub mod gist;
|
pub mod gist;
|
||||||
|
pub mod hook;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod key;
|
pub mod key;
|
||||||
pub mod label;
|
pub mod label;
|
||||||
pub mod org;
|
pub mod org;
|
||||||
pub mod pr;
|
pub mod pr;
|
||||||
|
pub mod protect;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|
@ -38,6 +40,10 @@ pub struct Cli {
|
||||||
#[arg(long, global = true, env = "FJ_HOST")]
|
#[arg(long, global = true, env = "FJ_HOST")]
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
|
|
||||||
|
/// Log every HTTP request fj makes to stderr.
|
||||||
|
#[arg(long, global = true, env = "FJ_DEBUG")]
|
||||||
|
pub debug: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +84,10 @@ pub enum Command {
|
||||||
Alias(alias::AliasCmd),
|
Alias(alias::AliasCmd),
|
||||||
/// Manage local fj configuration (editor, pager, browser, etc.).
|
/// Manage local fj configuration (editor, pager, browser, etc.).
|
||||||
Config(config::ConfigCmd),
|
Config(config::ConfigCmd),
|
||||||
|
/// Manage branch protection rules.
|
||||||
|
Protect(protect::ProtectCmd),
|
||||||
|
/// Manage repository webhooks.
|
||||||
|
Hook(hook::HookCmd),
|
||||||
/// Run a discovered `fj-<name>` plugin from PATH.
|
/// Run a discovered `fj-<name>` plugin from PATH.
|
||||||
Extension(extension::ExtensionCmd),
|
Extension(extension::ExtensionCmd),
|
||||||
/// Forgejo gists.
|
/// Forgejo gists.
|
||||||
|
|
@ -86,6 +96,15 @@ pub enum Command {
|
||||||
Api(api::ApiArgs),
|
Api(api::ApiArgs),
|
||||||
/// Print shell completions to stdout.
|
/// Print shell completions to stdout.
|
||||||
Completion(CompletionArgs),
|
Completion(CompletionArgs),
|
||||||
|
/// Generate man pages for `fj` and all subcommands into a directory.
|
||||||
|
Man(ManArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct ManArgs {
|
||||||
|
/// Output directory. Defaults to `./man`.
|
||||||
|
#[arg(short = 'o', long, default_value = "./man")]
|
||||||
|
pub output_dir: std::path::PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
|
|
@ -96,10 +115,7 @@ pub struct CompletionArgs {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(cli: Cli) -> Result<()> {
|
pub async fn run(cli: Cli) -> Result<()> {
|
||||||
// Alias resolution: if the first positional looks like a user-defined
|
crate::client::set_debug(cli.debug);
|
||||||
// alias and the user typed `fj <alias>`, expand it before dispatching.
|
|
||||||
// clap already parsed; aliases are handled inside the dedicated `alias`
|
|
||||||
// subcommand or by re-execing ourselves. See cli::alias::dispatch.
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Auth(cmd) => auth::run(cmd).await,
|
Command::Auth(cmd) => auth::run(cmd).await,
|
||||||
Command::Repo(cmd) => repo::run(cmd, cli.host.as_deref()).await,
|
Command::Repo(cmd) => repo::run(cmd, cli.host.as_deref()).await,
|
||||||
|
|
@ -118,6 +134,8 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||||
Command::GpgKey(cmd) => key::run_gpg(cmd, cli.host.as_deref()).await,
|
Command::GpgKey(cmd) => key::run_gpg(cmd, cli.host.as_deref()).await,
|
||||||
Command::Alias(cmd) => alias::run(cmd).await,
|
Command::Alias(cmd) => alias::run(cmd).await,
|
||||||
Command::Config(cmd) => config::run(cmd).await,
|
Command::Config(cmd) => config::run(cmd).await,
|
||||||
|
Command::Protect(cmd) => protect::run(cmd, cli.host.as_deref()).await,
|
||||||
|
Command::Hook(cmd) => hook::run(cmd, cli.host.as_deref()).await,
|
||||||
Command::Extension(cmd) => extension::run(cmd).await,
|
Command::Extension(cmd) => extension::run(cmd).await,
|
||||||
Command::Gist(cmd) => gist::run(cmd, cli.host.as_deref()).await,
|
Command::Gist(cmd) => gist::run(cmd, cli.host.as_deref()).await,
|
||||||
Command::Api(args) => api::run(args, cli.host.as_deref()).await,
|
Command::Api(args) => api::run(args, cli.host.as_deref()).await,
|
||||||
|
|
@ -127,5 +145,28 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||||
clap_complete::generate(args.shell, &mut cmd, "fj", &mut std::io::stdout());
|
clap_complete::generate(args.shell, &mut cmd, "fj", &mut std::io::stdout());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Command::Man(args) => generate_man_pages(args).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn generate_man_pages(args: ManArgs) -> Result<()> {
|
||||||
|
use clap::CommandFactory;
|
||||||
|
let dir = &args.output_dir;
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let cmd = Cli::command();
|
||||||
|
write_man_for(&cmd, "fj", dir)?;
|
||||||
|
for sub in cmd.get_subcommands() {
|
||||||
|
let name = format!("fj-{}", sub.get_name());
|
||||||
|
write_man_for(sub, &name, dir)?;
|
||||||
|
}
|
||||||
|
println!("✓ Wrote man pages to {}", dir.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_man_for(cmd: &clap::Command, file_name: &str, dir: &std::path::Path) -> Result<()> {
|
||||||
|
let path = dir.join(format!("{file_name}.1"));
|
||||||
|
let mut out = std::fs::File::create(&path)?;
|
||||||
|
let man = clap_mangen::Man::new(cmd.clone());
|
||||||
|
man.render(&mut out)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
138
src/cli/protect.rs
Normal file
138
src/cli/protect.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
//! `fj protect` — branch protection rules.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::api::protect::{self, CreateBranchProtection};
|
||||||
|
use crate::cli::context::{resolve_repo, RepoFlag};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ProtectCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: ProtectSub,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum ProtectSub {
|
||||||
|
/// List branch protection rules.
|
||||||
|
List(ListArgs),
|
||||||
|
/// Show a single rule.
|
||||||
|
View(BranchArgs),
|
||||||
|
/// Create or update a rule.
|
||||||
|
Set(SetArgs),
|
||||||
|
/// Delete a rule.
|
||||||
|
Delete(BranchArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ListArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub r: RepoFlag,
|
||||||
|
#[arg(long)]
|
||||||
|
pub json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct BranchArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub r: RepoFlag,
|
||||||
|
pub branch: String,
|
||||||
|
#[arg(long)]
|
||||||
|
pub json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct SetArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub r: RepoFlag,
|
||||||
|
pub branch: String,
|
||||||
|
/// Require signed commits.
|
||||||
|
#[arg(long)]
|
||||||
|
pub require_signed: bool,
|
||||||
|
/// Block merges from outdated branches.
|
||||||
|
#[arg(long)]
|
||||||
|
pub block_outdated: bool,
|
||||||
|
/// Minimum required approvals.
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
pub required_approvals: i64,
|
||||||
|
/// Disable direct pushes.
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_push: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(cmd: ProtectCmd, host: Option<&str>) -> Result<()> {
|
||||||
|
match cmd.command {
|
||||||
|
ProtectSub::List(args) => list(args, host).await,
|
||||||
|
ProtectSub::View(args) => view(args, host).await,
|
||||||
|
ProtectSub::Set(args) => set(args, host).await,
|
||||||
|
ProtectSub::Delete(args) => delete(args, host).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
let items = protect::list(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||||
|
if args.json {
|
||||||
|
return output::print_json(&serde_json::to_value(&items)?);
|
||||||
|
}
|
||||||
|
if items.is_empty() {
|
||||||
|
println!("(no protection rules)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rows: Vec<Vec<String>> = items
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
vec![
|
||||||
|
p.branch_name.clone(),
|
||||||
|
p.required_approvals.to_string(),
|
||||||
|
p.require_signed_commits.to_string(),
|
||||||
|
p.block_on_outdated_branch.to_string(),
|
||||||
|
p.enable_push.to_string(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
output::render_table(
|
||||||
|
&["BRANCH", "APPROVALS", "SIGNED", "BLOCK_OUTDATED", "PUSH"],
|
||||||
|
&rows
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view(args: BranchArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
let p = protect::get(&ctx.client, &ctx.owner, &ctx.name, &args.branch).await?;
|
||||||
|
if args.json {
|
||||||
|
return output::print_json(&serde_json::to_value(&p)?);
|
||||||
|
}
|
||||||
|
println!("{}", output::bold(&p.branch_name));
|
||||||
|
println!("Required approvals: {}", p.required_approvals);
|
||||||
|
println!("Require signed: {}", p.require_signed_commits);
|
||||||
|
println!("Block outdated branch: {}", p.block_on_outdated_branch);
|
||||||
|
println!("Direct push allowed: {}", p.enable_push);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(args: SetArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
let body = CreateBranchProtection {
|
||||||
|
branch_name: &args.branch,
|
||||||
|
enable_push: !args.no_push,
|
||||||
|
require_signed_commits: args.require_signed,
|
||||||
|
block_on_outdated_branch: args.block_outdated,
|
||||||
|
required_approvals: args.required_approvals,
|
||||||
|
};
|
||||||
|
let p = protect::create(&ctx.client, &ctx.owner, &ctx.name, &body).await?;
|
||||||
|
println!("✓ Protected {}", p.branch_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(args: BranchArgs, host: Option<&str>) -> Result<()> {
|
||||||
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||||
|
protect::delete(&ctx.client, &ctx.owner, &ctx.name, &args.branch).await?;
|
||||||
|
println!("✓ Removed protection on {}", args.branch);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,13 @@ pub struct SearchCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum SearchSub {
|
pub enum SearchSub {
|
||||||
|
/// Search repositories.
|
||||||
Repos(QueryArgs),
|
Repos(QueryArgs),
|
||||||
|
/// Search issues across all repos you can see.
|
||||||
Issues(QueryArgs),
|
Issues(QueryArgs),
|
||||||
|
/// Search pull requests across all repos you can see.
|
||||||
Prs(QueryArgs),
|
Prs(QueryArgs),
|
||||||
|
/// Search users.
|
||||||
Users(QueryArgs),
|
Users(QueryArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,11 @@ pub struct SecretCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum SecretSub {
|
pub enum SecretSub {
|
||||||
|
/// List secret names (values are write-only).
|
||||||
List(SecretListArgs),
|
List(SecretListArgs),
|
||||||
|
/// Set or replace a secret.
|
||||||
Set(SecretSetArgs),
|
Set(SecretSetArgs),
|
||||||
|
/// Delete a secret.
|
||||||
Delete(SecretDeleteArgs),
|
Delete(SecretDeleteArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,8 +242,11 @@ pub struct VariableCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum VariableSub {
|
pub enum VariableSub {
|
||||||
|
/// List Actions variables.
|
||||||
List(SecretListArgs),
|
List(SecretListArgs),
|
||||||
|
/// Set or replace a variable.
|
||||||
Set(SecretSetArgs),
|
Set(SecretSetArgs),
|
||||||
|
/// Delete a variable.
|
||||||
Delete(SecretDeleteArgs),
|
Delete(SecretDeleteArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
static DEBUG: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Toggle request logging at runtime. Set by `--debug` / `FJ_DEBUG`.
|
||||||
|
pub fn set_debug(on: bool) {
|
||||||
|
DEBUG.store(on, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_enabled() -> bool {
|
||||||
|
DEBUG.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
|
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||||
use reqwest::{Method, Response, StatusCode};
|
use reqwest::{Method, Response, StatusCode};
|
||||||
|
|
@ -150,11 +162,37 @@ impl Client {
|
||||||
for (k, v) in extra.iter() {
|
for (k, v) in extra.iter() {
|
||||||
headers.insert(k.clone(), v.clone());
|
headers.insert(k.clone(), v.clone());
|
||||||
}
|
}
|
||||||
let mut req = self.http.request(method, url).headers(headers).query(query);
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.request(method.clone(), url.clone())
|
||||||
|
.headers(headers)
|
||||||
|
.query(query);
|
||||||
if let Some(body) = body {
|
if let Some(body) = body {
|
||||||
req = req.json(body);
|
req = req.json(body);
|
||||||
}
|
}
|
||||||
|
if debug_enabled() {
|
||||||
|
let q = if query.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let pairs: Vec<String> = query.iter().map(|(k, v)| format!("{k}={v}")).collect();
|
||||||
|
format!("?{}", pairs.join("&"))
|
||||||
|
};
|
||||||
|
eprintln!("→ {method} {url}{q}");
|
||||||
|
if let Some(b) = body {
|
||||||
|
if let Ok(text) = serde_json::to_string(b) {
|
||||||
|
let preview = if text.len() > 200 {
|
||||||
|
format!("{}…", &text[..200])
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
eprintln!(" body: {preview}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let res = req.send().await.context("sending HTTP request")?;
|
let res = req.send().await.context("sending HTTP request")?;
|
||||||
|
if debug_enabled() {
|
||||||
|
eprintln!("← {} {}", res.status(), res.url());
|
||||||
|
}
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,8 @@ const KNOWN_SUBCOMMANDS: &[&str] = &[
|
||||||
"config",
|
"config",
|
||||||
"extension",
|
"extension",
|
||||||
"gist",
|
"gist",
|
||||||
|
"protect",
|
||||||
|
"hook",
|
||||||
"api",
|
"api",
|
||||||
"completion",
|
"completion",
|
||||||
"help",
|
"help",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue