node-app-build 5.25.1

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `<dev_dir>/<instance>-agent-session.json` persistence.
//!
//! Schema is the agent-CLI mirror of the e2e harness `AuthSession` struct
//! (`tests/e2e/src/harness/auth.ts:35`). An AI agent reading this file
//! gets everything it needs to drive authed endpoints (`Authorization:
//! Bearer <token>`) and to re-derive the keypair if it needs to sign a
//! follow-up challenge.
//!
//! Persisted with `0600` perms on unix and written atomically (tmp + rename)
//! so a crash mid-write doesn't yield a half-readable file.

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AgentSession {
    pub instance: String,
    pub base_url: String,
    pub node_id: String,
    pub public_key: String,
    pub secret_key_hex: String,
    pub mnemonic: String,
    pub token: String,
    pub refresh_token: String,
    pub onboarded_at: DateTime<Utc>,
    pub last_login_at: DateTime<Utc>,
}

impl AgentSession {
    pub fn file_path(dev_dir: &Path, instance: &str) -> PathBuf {
        let name = if instance.is_empty() {
            "default-agent-session.json".to_string()
        } else {
            format!("{instance}-agent-session.json")
        };
        dev_dir.join(name)
    }

    pub fn load(dev_dir: &Path, instance: &str) -> Result<Option<Self>> {
        let path = Self::file_path(dev_dir, instance);
        if !path.exists() {
            return Ok(None);
        }
        let text = std::fs::read_to_string(&path)
            .with_context(|| format!("read {}", path.display()))?;
        let session: Self = serde_json::from_str(&text)
            .with_context(|| format!("parse {}", path.display()))?;
        Ok(Some(session))
    }

    pub fn save(&self, dev_dir: &Path) -> Result<PathBuf> {
        std::fs::create_dir_all(dev_dir)
            .with_context(|| format!("mkdir -p {}", dev_dir.display()))?;
        let final_path = Self::file_path(dev_dir, &self.instance);
        let tmp_path = final_path.with_extension("json.tmp");

        let text = serde_json::to_string_pretty(self).context("serialize agent session")?;
        std::fs::write(&tmp_path, text)
            .with_context(|| format!("write {}", tmp_path.display()))?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = std::fs::Permissions::from_mode(0o600);
            std::fs::set_permissions(&tmp_path, perms)
                .with_context(|| format!("chmod 0600 {}", tmp_path.display()))?;
        }

        std::fs::rename(&tmp_path, &final_path).with_context(|| {
            format!("rename {}{}", tmp_path.display(), final_path.display())
        })?;
        Ok(final_path)
    }
}

/// Return the first 12 characters of a JWT, padded with `…` so logs never
/// disclose the full token. Mirrors the convention used by other
/// auth-aware dev tooling in this repo.
pub fn redact_token(token: &str) -> String {
    let prefix: String = token.chars().take(12).collect();
    format!("{prefix}")
}