use crate::commands::{make_client, packument_full_cache_dir, resolve_version, split_name_spec};
use clap::Args;
use miette::miette;
use serde_json::Value;
pub const AFTER_LONG_HELP: &str = "\
Examples:
$ aube bugs
$ aube bugs react
$ aube issues react react-dom
";
#[derive(Debug, Args)]
pub struct BugsArgs {
pub packages: Vec<String>,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
}
pub async fn run(args: BugsArgs) -> miette::Result<()> {
args.network.install_overrides();
let urls = if args.packages.is_empty() {
vec![current_project_url()?]
} else {
registry_urls(&args.packages).await?
};
for url in urls {
println!("{url}");
open_url(&url);
}
Ok(())
}
fn current_project_url() -> miette::Result<String> {
let cwd = crate::dirs::project_root_or_cwd()?;
let manifest_path = cwd.join("package.json");
let manifest = aube_manifest::PackageJson::from_path_cached(&manifest_path)
.map_err(|e| miette!("failed to read {}: {e}", manifest_path.display()))?;
bug_url_from_map(&manifest.extra).ok_or_else(|| {
miette!(
"{} has no `bugs.url` or usable `repository` field",
manifest_path.display()
)
})
}
async fn registry_urls(packages: &[String]) -> miette::Result<Vec<String>> {
let cwd = crate::dirs::project_root_or_cwd().unwrap_or_else(|_| std::path::PathBuf::from("."));
let client = make_client(&cwd);
let mut out = Vec::with_capacity(packages.len());
for package in packages {
let (name, version_spec) = split_name_spec(package);
let packument = client
.fetch_packument_full_cached(name, &packument_full_cache_dir())
.await
.map_err(|e| match e {
aube_registry::Error::NotFound(n) => miette!("package not found: {n}"),
other => miette!("failed to fetch {name}: {other}"),
})?;
let version = resolve_version(&packument, version_spec).ok_or_else(|| {
miette!(
"no matching version for {name}@{}",
version_spec.unwrap_or("latest")
)
})?;
let version_meta = packument
.get("versions")
.and_then(|v| v.get(&version))
.ok_or_else(|| miette!("version {version} not present in packument for {name}"))?;
let url = bug_url_from_value(version_meta)
.or_else(|| bug_url_from_value(&packument))
.ok_or_else(|| miette!("{name}@{version} has no bugs or repository URL"))?;
out.push(url);
}
Ok(out)
}
fn bug_url_from_map(extra: &std::collections::BTreeMap<String, Value>) -> Option<String> {
bugs_url_from_extra(extra).or_else(|| repository_issues_url_from_extra(extra))
}
fn bug_url_from_value(value: &Value) -> Option<String> {
bugs_url(value).or_else(|| repository_issues_url(value))
}
fn bugs_url_from_extra(extra: &std::collections::BTreeMap<String, Value>) -> Option<String> {
match extra.get("bugs")? {
Value::String(s) => clean_url(s),
Value::Object(map) => map.get("url").and_then(Value::as_str).and_then(clean_url),
_ => None,
}
}
fn bugs_url(value: &Value) -> Option<String> {
match value.get("bugs")? {
Value::String(s) => clean_url(s),
Value::Object(map) => map.get("url").and_then(Value::as_str).and_then(clean_url),
_ => None,
}
}
fn repository_issues_url(value: &Value) -> Option<String> {
let repo = match value.get("repository")? {
Value::String(s) => s,
Value::Object(map) => map.get("url")?.as_str()?,
_ => return None,
};
issues_url_for_repo(repo)
}
fn repository_issues_url_from_extra(
extra: &std::collections::BTreeMap<String, Value>,
) -> Option<String> {
let repo = match extra.get("repository")? {
Value::String(s) => s.as_str(),
Value::Object(map) => map.get("url")?.as_str()?,
_ => return None,
};
issues_url_for_repo(repo)
}
fn issues_url_for_repo(repo: &str) -> Option<String> {
let mut url = normalize_repository_url(repo)?;
if is_complete_issues_url(&url) {
return Some(url);
}
if url.ends_with('/') {
url.push_str("issues");
} else {
url.push_str("/issues");
}
Some(url)
}
fn is_complete_issues_url(url: &str) -> bool {
let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
else {
return false;
};
let Some((_, path)) = rest.split_once('/') else {
return false;
};
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
segments
.iter()
.position(|segment| *segment == "issues")
.is_some_and(|idx| idx >= 2)
}
fn clean_url(raw: &str) -> Option<String> {
let raw = raw.trim();
if is_safe_browser_url(raw) {
Some(raw.to_string())
} else {
None
}
}
fn normalize_repository_url(raw: &str) -> Option<String> {
let raw = raw.trim().strip_prefix("git+").unwrap_or(raw.trim());
if raw.is_empty() {
return None;
}
let raw = raw.split_once('#').map_or(raw, |(base, _)| base);
let mut url = if let Some(rest) = raw.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
format!("https://{host}/{path}")
} else if let Some(rest) = raw
.strip_prefix("ssh://git@")
.or_else(|| raw.strip_prefix("git://"))
{
let (host, path) = rest.split_once('/')?;
format!("https://{host}/{path}")
} else if raw.starts_with("http://") || raw.starts_with("https://") {
raw.to_string()
} else if let Some(path) = raw.strip_prefix("github:") {
format!("https://github.com/{}", path.trim_matches('/'))
} else if let Some(path) = raw.strip_prefix("gitlab:") {
format!("https://gitlab.com/{}", path.trim_matches('/'))
} else if let Some(path) = raw.strip_prefix("bitbucket:") {
format!("https://bitbucket.org/{}", path.trim_matches('/'))
} else if raw.split('/').count() == 2 && !raw.contains(':') {
format!("https://github.com/{}", raw.trim_matches('/'))
} else {
return clean_url(raw);
};
if let Some(stripped) = url.strip_suffix(".git") {
url = stripped.to_string();
}
clean_url(&url)
}
fn open_url(url: &str) {
if std::env::var_os("AUBE_NO_OPEN").is_some() {
return;
}
if !is_safe_browser_url(url) {
tracing::debug!("refusing to open unsafe URL: {url:?}");
return;
}
let result = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else if cfg!(target_os = "windows") {
let escaped = url.replace('%', "%%");
std::process::Command::new("cmd")
.args(["/C", "start", "", &escaped])
.status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
if let Err(e) = result {
tracing::debug!("failed to open {url}: {e}");
}
}
fn is_safe_browser_url(url: &str) -> bool {
let rest = match url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
{
Some(r) => r,
None => return false,
};
if rest.is_empty() || rest.len() > 2048 {
return false;
}
rest.chars().all(|c| {
matches!(c,
'a'..='z' | 'A'..='Z' | '0'..='9'
| '-' | '_' | '.' | '~'
| ':' | '/' | '?' | '#' | '[' | ']' | '@'
| '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ','
| ';' | '=' | '%'
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bug_url_prefers_bugs_url() {
let value = serde_json::json!({
"bugs": { "url": "https://github.com/acme/pkg/issues" },
"repository": "github:other/pkg"
});
assert_eq!(
bug_url_from_value(&value).as_deref(),
Some("https://github.com/acme/pkg/issues")
);
}
#[test]
fn bug_url_accepts_string_bugs_field() {
let value = serde_json::json!({
"bugs": "https://bugs.example.com/pkg"
});
assert_eq!(
bug_url_from_value(&value).as_deref(),
Some("https://bugs.example.com/pkg")
);
}
#[test]
fn bug_url_falls_back_to_repository_issues() {
for (repo, expected) in [
("github:acme/pkg.git", "https://github.com/acme/pkg/issues"),
("acme/pkg", "https://github.com/acme/pkg/issues"),
(
"https://github.com/acme/issues",
"https://github.com/acme/issues/issues",
),
(
"https://github.com/acme/pkg/issues",
"https://github.com/acme/pkg/issues",
),
(
"https://github.com/acme/pkg/issues/123",
"https://github.com/acme/pkg/issues/123",
),
(
"git+https://github.com/acme/pkg.git#main",
"https://github.com/acme/pkg/issues",
),
(
"git@gitlab.com:acme/pkg.git",
"https://gitlab.com/acme/pkg/issues",
),
(
"git+ssh://git@github.com/acme/pkg.git",
"https://github.com/acme/pkg/issues",
),
(
"git://github.com/acme/pkg.git",
"https://github.com/acme/pkg/issues",
),
] {
let value = serde_json::json!({ "repository": repo });
assert_eq!(bug_url_from_value(&value).as_deref(), Some(expected));
}
}
#[test]
fn bug_url_rejects_email_only_bugs_field() {
let value = serde_json::json!({
"bugs": { "email": "bugs@example.com" }
});
assert_eq!(bug_url_from_value(&value), None);
}
#[test]
fn bug_url_rejects_unsafe_browser_urls() {
for url in [
"javascript:alert(1)",
"https://example.com/bug|calc.exe",
"https://example.com/bug^calc.exe",
"https://example.com/bug with space",
] {
let value = serde_json::json!({ "bugs": { "url": url } });
assert_eq!(bug_url_from_value(&value), None, "{url} should be rejected");
}
}
}