vibestats 2.4.4

CLI that syncs Claude Code and Codex session activity to a private GitHub repo and renders a profile heatmap + analytics dashboard.
use crate::paths::checkpoint_path;

/// Entry point called from `main.rs` for the `vibestats auth` command.
///
/// Orchestrates: gh auth token → config.save() → gh secret set → checkpoint.clear_auth_error()
///
/// NEVER calls `std::process::exit` — `main.rs` handles exit.
pub fn run() {
    // Step 1 — obtain fresh token via `gh auth token`
    let new_token = match std::process::Command::new("gh")
        .args(["auth", "token"])
        .output()
    {
        Err(e) => {
            println!("vibestats: auth failed — could not run 'gh': {e}");
            println!("Ensure 'gh' CLI is installed and accessible in PATH.");
            return;
        }
        Ok(out) if !out.status.success() => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            println!(
                "vibestats: auth failed — 'gh auth token' returned non-zero: {}",
                stderr.trim()
            );
            println!("Run 'gh auth login' first, then retry 'vibestats auth'.");
            return;
        }
        Ok(out) => {
            let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if token.is_empty() {
                println!("vibestats: auth failed — 'gh auth token' returned empty token.");
                println!("Run 'gh auth login' first, then retry 'vibestats auth'.");
                return;
            }
            token
        }
    };

    // Step 2 — update config.toml with new token (Config::save enforces 600 perms)
    let mut config = match crate::config::Config::load() {
        Ok(c) => c,
        Err(e) => {
            println!("vibestats: auth failed — could not load config: {e}");
            return;
        }
    };
    config.oauth_token = new_token;
    if let Err(e) = config.save() {
        println!("vibestats: auth failed — could not save config: {e}");
        return;
    }

    // Step 3 — update VIBESTATS_TOKEN Actions secret (non-fatal if it fails).
    //
    // SECURITY: `gh secret set` has no `--body-file` flag; omitting `-b/--body`
    // makes it read the value from stdin instead, so the token never appears
    // in this process's argv (and therefore never shows up in `ps` /
    // `/proc/<pid>/cmdline`). NFR6.
    let secret_result = (|| -> std::io::Result<std::process::Output> {
        use std::io::Write;
        let mut child = std::process::Command::new("gh")
            .args([
                "secret",
                "set",
                "VIBESTATS_TOKEN",
                "--repo",
                &config.vibestats_data_repo,
            ])
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()?;
        // stdin is piped — convert None to io::Error so the closure can use ?
        // without resorting to expect() or unwrap() in non-test code.
        child
            .stdin
            .as_mut()
            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stdin not piped"))?
            .write_all(config.oauth_token.as_bytes())?;
        // Drop stdin to close the pipe and signal EOF to the child process.
        // Without this, `gh secret set` would block waiting for more input,
        // and wait_with_output() would deadlock.
        drop(child.stdin.take());
        child.wait_with_output()
    })();

    match secret_result {
        Err(e) => {
            println!(
                "vibestats: token saved locally but could not update VIBESTATS_TOKEN secret: {e}"
            );
            println!("Run manually (feeds token via stdin to avoid leaking it in argv):");
            println!(
                "  gh auth token | gh secret set VIBESTATS_TOKEN --repo {}",
                config.vibestats_data_repo
            );
            // Continue — local token is updated; don't abort checkpoint clear
        }
        Ok(out) if !out.status.success() => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            println!("vibestats: token saved locally but 'gh secret set' failed: {stderr}");
            println!("Run manually (feeds token via stdin to avoid leaking it in argv):");
            println!(
                "  gh auth token | gh secret set VIBESTATS_TOKEN --repo {}",
                config.vibestats_data_repo
            );
            // Continue — local token is updated
        }
        Ok(_) => {} // success
    }

    // Step 4 — clear auth_error in checkpoint.toml (non-fatal if it fails)
    if let Some(cp_path) = checkpoint_path() {
        let mut cp = crate::checkpoint::Checkpoint::load(&cp_path);
        cp.clear_auth_error();
        if let Err(e) = cp.save(&cp_path) {
            // Non-fatal — auth is refreshed; log but do not abort
            println!("vibestats: auth complete (note: could not clear auth_error flag: {e})");
            return;
        }
    }
    println!("vibestats: auth complete");
}

#[cfg(test)]
mod tests {
    #[test]
    fn nonexistent_gh_binary_causes_error() {
        // Verify that invoking a non-existent binary path produces an Err from .output()
        let result = std::process::Command::new("/nonexistent/gh")
            .args(["auth", "token"])
            .output();
        assert!(
            result.is_err(),
            "Expected Err for non-existent binary, got Ok"
        );
    }
}