use crate::join::read_pubkey;
use crate::{config, valid_slug, CHANGELOG_MD};
use anyhow::{anyhow, Result};
pub(crate) fn cmd_keygen(role: Option<String>, print_publish_hint: bool) -> Result<()> {
let role = match role {
Some(r) => r,
None => config::repo_root()
.ok()
.and_then(|r| config::resolve_role(None, &r).ok())
.ok_or_else(|| anyhow!("no role — pass --role <id>, or run inside your hub clone"))?,
};
if !valid_slug(&role) {
return Err(anyhow!(
"invalid role id '{role}' — a role is lowercase letters/digits/'-' (same rule as `join`)"
));
}
let keydir = config::home()?.join(".confer").join("keys");
let keypath = keydir.join(&role);
if keypath.exists() {
return Err(anyhow!(
"a signing key already exists for '{role}' at {} — the identity IS the key, so confer \
will not overwrite it. If this identity is truly dead, remove the key by hand first.",
keypath.display()
));
}
std::fs::create_dir_all(&keydir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&keydir, std::fs::Permissions::from_mode(0o700));
}
let out = std::process::Command::new("ssh-keygen")
.args(["-t", "ed25519", "-C", &format!("{role}@confer"), "-N", ""])
.arg("-f")
.arg(&keypath)
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| anyhow!("could not run ssh-keygen: {e}"))?;
if !out.status.success() {
return Err(anyhow!(
"ssh-keygen failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&keypath, std::fs::Permissions::from_mode(0o600)) {
eprintln!(
"warning: could not set 0600 on {} ({e}) — tighten it by hand",
keypath.display()
);
}
}
println!("minted an ed25519 signing key for '{role}':");
println!(" private: {}", keypath.display());
println!(" public: {}.pub", keypath.display());
if let Ok(pk) = read_pubkey(&keypath) {
println!(" {pk}");
}
if print_publish_hint {
println!();
println!("publish it (from your hub clone) to get a verifiable, keyed identity:");
println!(
" confer join --role {role} --signing-key {}",
keypath.display()
);
println!(
"then your messages sign + verify, and `confer adopt-clone` (managed home) will work."
);
}
Ok(())
}
fn which(cmd: &str) -> bool {
std::process::Command::new("which")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn changelog_sections() -> Vec<(String, String)> {
let mut sections: Vec<(String, String)> = Vec::new();
let mut cur: Option<(String, String)> = None;
for line in CHANGELOG_MD.lines() {
if let Some(h) = line.strip_prefix("## ") {
if let Some(prev) = cur.take() {
sections.push(prev);
}
cur = Some((h.trim().to_string(), format!("{line}\n")));
} else if let Some((_, body)) = cur.as_mut() {
body.push_str(line);
body.push('\n');
}
}
if let Some(prev) = cur.take() {
sections.push(prev);
}
sections
}
fn parse_semver_prefix(s: &str) -> Option<(u64, u64, u64)> {
let core = s.trim().split_whitespace().next()?;
let mut it = core.split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
let patch = it.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
pub(crate) fn cmd_changelog(since: Option<String>, all: bool) -> Result<()> {
let sections = changelog_sections();
if sections.is_empty() {
println!("(no changelog embedded in this build)");
return Ok(());
}
let selected: Vec<&(String, String)> = if all {
sections.iter().collect()
} else if let Some(since) = since.as_deref() {
let floor = parse_semver_prefix(since);
sections
.iter()
.filter(|(v, _)| match (parse_semver_prefix(v), floor) {
(Some(vv), Some(f)) => vv > f,
_ => true,
})
.collect()
} else {
sections.iter().take(1).collect()
};
if selected.is_empty() {
let base = since.as_deref().unwrap_or("");
println!("confer {} — no changelog entries newer than {base}.", env!("CARGO_PKG_VERSION"));
return Ok(());
}
for (i, (_, body)) in selected.iter().enumerate() {
if i > 0 {
println!();
}
print!("{}", body.trim_end());
println!();
}
Ok(())
}
pub(crate) fn cmd_update(check_only: bool) -> Result<()> {
use axoupdater::AxoUpdater;
let mut updater = AxoUpdater::new_for("confer-cli");
if updater.load_receipt().is_err() {
return delegate_to_package_manager();
}
if let Ok(tok) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
if !tok.is_empty() {
updater.set_github_token(&tok);
}
}
if check_only {
if updater.is_update_needed_sync()? {
println!("a newer confer is available — run `confer update`.");
} else {
println!("confer is up to date.");
}
return Ok(());
}
let _guard = match config::try_update_lock() {
Some(g) => g,
None => {
println!("another confer on this machine is updating the shared binary — skipping.");
println!("once it finishes, pick up the new build:");
print_post_update_steps();
return Ok(());
}
};
if updater.run_sync()?.is_some() {
println!("confer updated to the latest release.");
print_post_update_steps();
} else {
println!("confer is already up to date.");
}
Ok(())
}
fn print_post_update_steps() {
println!("\nnext — pick up the new build (both are needed):");
println!(" 1. re-arm your watch so it runs the new binary: /confer-watch (or `confer watch --replace`)");
println!(" 2. re-sync your skills from the new binary: confer install-skill");
println!(" 3. see what changed (may ask something of you): confer changelog");
}
fn delegate_to_package_manager() -> Result<()> {
let exe = std::env::current_exe().unwrap_or_default();
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
let p = exe.to_string_lossy();
if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/usr/local/opt/") {
println!("confer was installed via Homebrew — `confer update` won't replace it.");
println!("update with: brew upgrade confer");
} else if p.contains("/.cargo/bin/")
|| std::env::var("CARGO_HOME")
.map(|c| !c.is_empty() && p.contains(&c))
.unwrap_or(false)
{
println!("confer was installed via cargo — `confer update` won't replace it.");
if which("cargo-binstall") {
println!("update with: cargo binstall confer-cli --force (prebuilt; fast)");
println!(" or: cargo install confer-cli --force (from source)");
} else {
println!("update with: cargo install confer-cli --force");
println!(" (tip: `cargo binstall confer-cli --force` is much faster, if you install cargo-binstall)");
}
} else {
println!(
"confer has no dist install receipt and isn't in a recognized package-manager path,"
);
println!("so `confer update` can't safely replace it. Reinstall via the shell installer");
println!("(curl … | sh) for self-update, or update through your package manager.");
}
print_post_update_steps();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changelog_parses_into_newest_first_sections() {
let sections = changelog_sections();
assert!(!sections.is_empty(), "the embedded CHANGELOG should parse into sections");
let top = parse_semver_prefix(§ions[0].0);
if let Some(top) = top {
for (v, _) in §ions[1..] {
if let Some(vv) = parse_semver_prefix(v) {
assert!(top >= vv, "sections must be newest-first ({top:?} vs {vv:?})");
}
}
}
for (heading, body) in §ions {
assert!(body.starts_with("## "), "section body must start with its heading");
assert!(!body.contains("# Changelog\n"), "the file title must not leak into a section");
assert!(!heading.is_empty());
}
}
#[test]
fn semver_prefix_orders_versions_and_ignores_non_numeric() {
assert!(parse_semver_prefix("0.6.8") > parse_semver_prefix("0.6.7"));
assert!(parse_semver_prefix("0.7.0") > parse_semver_prefix("0.6.99"));
assert!(parse_semver_prefix("1.0.0") > parse_semver_prefix("0.99.99"));
assert_eq!(parse_semver_prefix("0.6.8 (abc123)"), Some((0, 6, 8)));
assert_eq!(parse_semver_prefix("0.6"), Some((0, 6, 0)));
assert_eq!(parse_semver_prefix("Unreleased"), None);
}
}