node-app-build 6.5.0

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))
    }

    /// Read just the `token` field from a session file at `path`.
    pub fn read_token(path: &Path) -> Result<String> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("read {}", path.display()))?;
        let v: serde_json::Value = serde_json::from_str(&text)
            .with_context(|| format!("parse {}", path.display()))?;
        v.get("token")
            .and_then(|t| t.as_str())
            .map(str::to_owned)
            .ok_or_else(|| anyhow::anyhow!("no 'token' field in {}", path.display()))
    }

    /// Load a session by (monorepo_path, instance), resolving the same dev_dir
    /// the dev orchestrator uses.  Returns the session or an error if not found.
    pub fn load_for_harness(monorepo_path: &Path, instance: &str) -> Result<Self> {
        let dev_dir =
            crate::commands::dev::host::monorepo::monorepo_dev_dir(monorepo_path, instance, None)?;
        Self::load(&dev_dir, instance)?
            .ok_or_else(|| anyhow::anyhow!(
                "no agent session for '{instance}' — did `harness up` onboarding run? \
                 (expected at {})",
                Self::file_path(&dev_dir, instance).display()
            ))
    }

    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}")
}