use std::time::{Duration, Instant};
pub enum Decision {
Pending,
Approved(String),
Failed(String),
}
pub(crate) fn clamp_device_name(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "zc CLI".to_string();
}
trimmed.chars().take(128).collect()
}
pub(crate) fn device_name() -> String {
let name = hostname::get()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_default();
clamp_device_name(&name)
}
pub(crate) fn start_body(device_name: &str) -> String {
serde_json::json!({ "device_name": device_name }).to_string()
}
pub(crate) fn approved_message(poll_body: &str) -> &'static str {
let kind = serde_json::from_str::<serde_json::Value>(poll_body)
.ok()
.and_then(|v| v["token_type"].as_str().map(str::to_string));
match kind.as_deref() {
Some("device") => {
"✓ Signed in. Device key saved to ~/.zakuro/credentials (revoke it on the hub under Signed-in devices)."
}
_ => "✓ Signed in. Key saved to ~/.zakuro/credentials.",
}
}
pub fn decide(http_status: u16, body: &str) -> Decision {
match http_status {
428 => Decision::Pending,
200 => {
match serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v["api_key"].as_str().map(|s| s.to_string()))
{
Some(k) => Decision::Approved(k),
None => Decision::Failed("approved but no api_key in response".into()),
}
}
410 => Decision::Failed("code expired — run `zc init` again".into()),
403 => Decision::Failed("authorization was denied".into()),
404 => Decision::Failed("session not found".into()),
s => Decision::Failed(format!("unexpected status {s}")),
}
}
pub fn print_connect_guidance() {
eprintln!();
eprintln!("To connect, grab your API key from the dashboard (Profile → API Keys), then:");
eprintln!();
eprintln!(" # pick your environment");
eprintln!(
" export ZAKURO_API_URL={} # production",
crate::credentials::PROD_API_URL
);
eprintln!(
" export ZAKURO_API_URL={} # staging",
crate::credentials::STAGING_API_URL
);
eprintln!();
eprintln!(" export ZAKURO_API_KEY=<your key>");
eprintln!(" zc connect # join the mesh (add --docker to use Docker)");
}
#[derive(Debug, PartialEq)]
pub(crate) enum Plan {
ConnectMesh,
ReportOnly,
SignIn,
}
pub(crate) fn plan(has_key: bool, force: bool, no_connect: bool) -> Plan {
if !has_key || force {
return Plan::SignIn;
}
if no_connect {
return Plan::ReportOnly;
}
Plan::ConnectMesh
}
#[derive(Debug, PartialEq)]
pub(crate) enum PostSignIn {
ConnectMesh,
SkipMesh,
}
pub(crate) fn post_sign_in(no_connect: bool) -> PostSignIn {
if no_connect {
PostSignIn::SkipMesh
} else {
PostSignIn::ConnectMesh
}
}
#[derive(Debug, Default, PartialEq)]
pub(crate) struct LoginArgs {
pub help: bool,
pub staging: bool,
pub force: bool,
pub no_connect: bool,
}
impl LoginArgs {
pub(crate) fn parse(args: &[String]) -> LoginArgs {
let mut parsed = LoginArgs::default();
for arg in args {
match arg.as_str() {
"--help" | "-h" => parsed.help = true,
"--staging" => parsed.staging = true,
"--force" => parsed.force = true,
"--no-connect" => parsed.no_connect = true,
_ => {}
}
}
parsed
}
}
const LOGIN_HELP: &str = "\
Usage: zc login [--staging] [--force] [--no-connect]
Sign in to Zakuro via browser device-code auth, then connect to the mesh.
`zc login` is an alias of `zc init`.
Options:
--staging Use the staging dashboard (same as ZAKURO_ENV=staging).
--force Re-authenticate even if already signed in.
--no-connect Skip connecting to the mesh after signing in.
-h, --help Print this help and exit.
URL precedence: ZAKURO_API_URL > ZAKURO_ENV=staging > production.
";
pub(crate) fn print_help() {
print!("{LOGIN_HELP}");
}
fn connect_mesh() -> bool {
match crate::vpn::ensure(crate::vpn::connector::Preference::Auto) {
Ok(crate::vpn::MeshAccess::Host) => {
println!("✓ Mesh reachable from this host. Try `zc me`.");
true
}
Ok(crate::vpn::MeshAccess::Proxy(p)) => {
println!("✓ Mesh reachable via VPN container proxy ({p}). Try `zc me`.");
true
}
Err(e) => {
eprintln!("✗ VPN setup failed: {e}");
eprintln!(" Retry with `zc connect`.");
false
}
}
}
pub fn run() -> i32 {
let args = LoginArgs::parse(&std::env::args().skip(1).collect::<Vec<_>>());
if args.help {
print_help();
return 0;
}
crate::credentials::load_into_env();
let api_url = if args.staging && std::env::var("ZAKURO_API_URL").is_err() {
crate::credentials::STAGING_API_URL.to_string()
} else {
crate::credentials::default_api_url()
};
let has_key = std::env::var("ZAKURO_API_KEY")
.map(|k| !k.trim().is_empty())
.unwrap_or(false);
let force = args.force;
let no_connect = args.no_connect;
match plan(has_key, force, no_connect) {
Plan::ReportOnly => {
eprintln!("Already signed in (→ {api_url}).");
eprintln!(" Connect with: zc connect");
eprintln!(" Re-auth with: zc login --force");
return 0;
}
Plan::ConnectMesh => {
eprintln!("Already signed in (→ {api_url}).");
eprintln!(" Connecting to the zakuro mesh…");
connect_mesh();
return 0;
}
Plan::SignIn => {}
}
let start_url = format!("{}/api/auth/cli/start", api_url.trim_end_matches('/'));
let start_send = ureq::post(&start_url)
.config()
.http_status_as_error(false)
.build()
.header("Content-Type", "application/json")
.send(start_body(&device_name()).as_str());
let start_resp = match start_send {
Ok(r) if r.status().as_u16() == 200 => r,
Ok(r) if r.status().as_u16() == 404 => {
eprintln!(
"Browser sign-in isn't available on {api_url} (device-code endpoint not found)."
);
print_connect_guidance();
return if has_key { 0 } else { 1 };
}
Ok(r) => {
eprintln!(
"Sign-in could not start on {api_url}: HTTP {}",
r.status().as_u16()
);
print_connect_guidance();
return 1;
}
Err(e) => {
eprintln!("Could not reach {api_url}: {e}");
print_connect_guidance();
return 1;
}
};
let start_body = match start_resp.into_body().read_to_string() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to read start response: {e}");
return 1;
}
};
let start: serde_json::Value = match serde_json::from_str(&start_body) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse start response: {e}");
return 1;
}
};
let device_code = start["device_code"].as_str().unwrap_or("").to_string();
let user_code = start["user_code"].as_str().unwrap_or("");
let verification_uri = start["verification_uri"].as_str().unwrap_or("");
let interval = start["interval"].as_u64().unwrap_or(5);
let expires_in = start["expires_in"].as_u64().unwrap_or(300);
println!("\n To sign in, open: {verification_uri}");
println!(" and confirm the code: {user_code}\n");
let _ = open_browser(verification_uri);
let poll_url = format!("{}/api/auth/cli/poll", api_url.trim_end_matches('/'));
let poll_start = Instant::now();
loop {
std::thread::sleep(Duration::from_secs(interval));
if poll_start.elapsed().as_secs() >= expires_in {
eprintln!("\nCode expired — run `zc init` again");
return 1;
}
let payload = serde_json::to_string(&serde_json::json!({"device_code": &device_code}))
.unwrap_or_default();
let (status, resp_body) = match ureq::post(&poll_url)
.config()
.http_status_as_error(false)
.build()
.header("Content-Type", "application/json")
.send(payload.as_str())
{
Ok(r) => {
let s = r.status().as_u16();
let b = r.into_body().read_to_string().unwrap_or_default();
(s, b)
}
Err(_) => continue, };
match decide(status, &resp_body) {
Decision::Pending => {
print!(".");
use std::io::Write;
let _ = std::io::stdout().flush();
}
Decision::Approved(key) => {
if let Err(e) = crate::credentials::save(&key, Some(&api_url)) {
eprintln!("\nsigned in but could not write credentials: {e}");
return 1;
}
std::env::set_var("ZAKURO_API_KEY", &key);
std::env::set_var("ZAKURO_API_URL", &api_url);
println!("\n{}", approved_message(&resp_body));
match post_sign_in(no_connect) {
PostSignIn::SkipMesh => {
println!(
" Signed in. Skipping the mesh (--no-connect); connect later with zc connect."
);
return 0;
}
PostSignIn::ConnectMesh => {
println!(" Connecting to the zakuro mesh…");
return if connect_mesh() { 0 } else { 1 };
}
}
}
Decision::Failed(msg) => {
eprintln!("\n{msg}");
return 1;
}
}
}
}
pub fn open_browser(url: &str) -> std::io::Result<()> {
let cmd = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
std::process::Command::new(cmd)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
}
#[cfg(test)]
mod tests {
#[test]
fn decision_maps_statuses() {
assert!(matches!(super::decide(428, ""), super::Decision::Pending));
assert!(matches!(
super::decide(200, "{\"api_key\":\"zk_1_x\"}"),
super::Decision::Approved(ref k) if k == "zk_1_x"
));
assert!(matches!(super::decide(410, ""), super::Decision::Failed(_)));
}
#[test]
fn already_signed_in_connects_the_mesh() {
assert_eq!(super::plan(true, false, false), super::Plan::ConnectMesh);
}
#[test]
fn no_key_signs_in() {
assert_eq!(super::plan(false, false, false), super::Plan::SignIn);
}
#[test]
fn force_signs_in_again() {
assert_eq!(super::plan(true, true, false), super::Plan::SignIn);
assert_eq!(super::plan(true, true, true), super::Plan::SignIn);
}
#[test]
fn no_connect_keeps_the_old_report_only_behaviour() {
assert_eq!(super::plan(true, false, true), super::Plan::ReportOnly);
}
#[test]
fn blank_key_is_not_signed_in() {
assert_eq!(super::plan(false, false, true), super::Plan::SignIn);
}
#[test]
fn start_sends_this_macs_device_name() {
let body: serde_json::Value =
serde_json::from_str(&super::start_body("jeans-mbp")).unwrap();
assert_eq!(body, serde_json::json!({ "device_name": "jeans-mbp" }));
let name = super::device_name();
assert!(!name.is_empty() && name.chars().count() <= 128, "{name}");
}
#[test]
fn approval_says_when_the_hub_minted_a_device_key() {
assert!(
super::approved_message(r#"{"api_key":"zk_1_x","token_type":"device"}"#)
.contains("Device key")
);
assert_eq!(
super::approved_message(r#"{"api_key":"x","token_type":"session","expires_in":43200}"#),
"✓ Signed in. Key saved to ~/.zakuro/credentials."
);
assert_eq!(
super::approved_message("not json"),
"✓ Signed in. Key saved to ~/.zakuro/credentials."
);
}
#[test]
fn device_name_clamp_truncates_multibyte_chars_not_bytes() {
let long_multibyte: String = "é".repeat(200);
let clamped = super::clamp_device_name(&long_multibyte);
assert_eq!(clamped.chars().count(), 128);
}
#[test]
fn device_name_clamp_defaults_empty_or_blank_to_zc_cli() {
assert_eq!(super::clamp_device_name(""), "zc CLI");
assert_eq!(super::clamp_device_name(" "), "zc CLI");
}
#[test]
fn device_name_clamp_passes_short_names_through() {
assert_eq!(super::clamp_device_name("jeans-mbp"), "jeans-mbp");
}
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn login_args_help_long_flag() {
let a = super::LoginArgs::parse(&args(&["--help"]));
assert!(a.help);
}
#[test]
fn login_args_help_short_flag() {
let a = super::LoginArgs::parse(&args(&["-h"]));
assert!(a.help);
}
#[test]
fn login_args_force_and_no_connect() {
let a = super::LoginArgs::parse(&args(&["--force", "--no-connect"]));
assert!(a.force);
assert!(a.no_connect);
assert!(!a.help);
assert!(!a.staging);
}
#[test]
fn login_args_plain_sign_in_has_no_flags() {
let a = super::LoginArgs::parse(&args(&[]));
assert!(!a.help && !a.force && !a.no_connect && !a.staging);
}
#[test]
fn login_args_staging_flag() {
let a = super::LoginArgs::parse(&args(&["--staging"]));
assert!(a.staging);
}
#[test]
fn post_sign_in_no_connect_skips_the_mesh() {
assert_eq!(super::post_sign_in(true), super::PostSignIn::SkipMesh);
}
#[test]
fn post_sign_in_default_connects_the_mesh() {
assert_eq!(super::post_sign_in(false), super::PostSignIn::ConnectMesh);
}
}