Skip to main content

outlayer_cli/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5// ── Credentials ────────────────────────────────────────────────────────
6
7#[derive(Debug, Serialize, Deserialize)]
8pub struct Credentials {
9    pub account_id: String,
10    pub public_key: String,
11    /// None if stored in OS keychain
12    pub private_key: Option<String>,
13    pub contract_id: String,
14    /// "near_key" (default) or "wallet_key"
15    #[serde(default = "default_auth_type")]
16    pub auth_type: String,
17    /// Wallet API key for custody-based auth (wk_...)
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub wallet_key: Option<String>,
20}
21
22fn default_auth_type() -> String {
23    "near_key".to_string()
24}
25
26impl Credentials {
27    pub fn is_wallet_key(&self) -> bool {
28        self.auth_type == "wallet_key"
29    }
30}
31
32// ── Project Config (outlayer.toml) ─────────────────────────────────────
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct ProjectConfig {
36    pub project: ProjectSection,
37    pub build: Option<BuildSection>,
38    pub deploy: Option<DeploySection>,
39    pub run: Option<RunSection>,
40    pub network: Option<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct ProjectSection {
45    pub name: String,
46    pub owner: String,
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct BuildSection {
51    #[serde(default = "default_target")]
52    pub target: String,
53    #[serde(default = "default_source")]
54    pub source: String,
55}
56
57fn default_target() -> String {
58    "wasm32-wasip2".to_string()
59}
60fn default_source() -> String {
61    "github".to_string()
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65pub struct DeploySection {
66    pub repo: Option<String>,
67    pub wasm_path: Option<String>,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
71pub struct RunSection {
72    pub max_instructions: Option<u64>,
73    pub max_memory_mb: Option<u32>,
74    pub max_execution_seconds: Option<u32>,
75    pub secrets_profile: Option<String>,
76    pub payment_key_nonce: Option<u32>,
77}
78
79// ── Network Config ─────────────────────────────────────────────────────
80
81#[derive(Debug, Clone)]
82pub struct NetworkConfig {
83    pub network_id: String,
84    pub rpc_url: String,
85    pub contract_id: String,
86    #[allow(dead_code)]
87    pub wallet_url: String,
88    pub api_base_url: String,
89}
90
91impl NetworkConfig {
92    pub fn mainnet() -> Self {
93        Self {
94            network_id: "mainnet".to_string(),
95            rpc_url: "https://rpc.mainnet.near.org".to_string(),
96            contract_id: "outlayer.near".to_string(),
97            wallet_url: "https://app.mynearwallet.com".to_string(),
98            api_base_url: "https://api.outlayer.fastnear.com".to_string(),
99        }
100    }
101
102    pub fn testnet() -> Self {
103        Self {
104            network_id: "testnet".to_string(),
105            rpc_url: "https://test.rpc.fastnear.com".to_string(),
106            contract_id: "outlayer.testnet".to_string(),
107            wallet_url: "https://testnet.mynearwallet.com".to_string(),
108            api_base_url: "https://testnet-api.outlayer.fastnear.com".to_string(),
109        }
110    }
111}
112
113/// Resolve network from flag > env > project config > saved default > auto-detect > mainnet
114pub fn resolve_network(flag: Option<&str>, project: Option<&str>) -> Result<NetworkConfig> {
115    let network = flag
116        .or(project)
117        .map(|s| s.to_string())
118        .or_else(load_default_network)
119        .or_else(|| detect_logged_in_network())
120        .unwrap_or_else(|| "mainnet".to_string());
121
122    match network.as_str() {
123        "mainnet" => Ok(NetworkConfig::mainnet()),
124        "testnet" => Ok(NetworkConfig::testnet()),
125        other => anyhow::bail!("Unknown network: {other}. Use 'mainnet' or 'testnet'."),
126    }
127}
128
129pub fn save_default_network(network: &str) {
130    if let Ok(home) = outlayer_home() {
131        let _ = std::fs::create_dir_all(&home);
132        let _ = std::fs::write(home.join("default-network"), network);
133    }
134}
135
136fn load_default_network() -> Option<String> {
137    let home = outlayer_home().ok()?;
138    std::fs::read_to_string(home.join("default-network"))
139        .ok()
140        .map(|s| s.trim().to_string())
141        .filter(|s| !s.is_empty())
142}
143
144/// If no default is set, check which network has credentials
145fn detect_logged_in_network() -> Option<String> {
146    let home = outlayer_home().ok()?;
147    let has_mainnet = home.join("mainnet/credentials.json").exists();
148    let has_testnet = home.join("testnet/credentials.json").exists();
149    match (has_mainnet, has_testnet) {
150        (true, false) => Some("mainnet".to_string()),
151        (false, true) => Some("testnet".to_string()),
152        _ => None, // ambiguous or none — fall through to default
153    }
154}
155
156// ── Paths ──────────────────────────────────────────────────────────────
157
158fn outlayer_home() -> Result<PathBuf> {
159    if let Ok(home) = std::env::var("OUTLAYER_HOME") {
160        return Ok(PathBuf::from(home));
161    }
162    let home = dirs::home_dir().context("Cannot determine home directory")?;
163    Ok(home.join(".outlayer"))
164}
165
166fn credentials_path(network: &str) -> Result<PathBuf> {
167    let home = outlayer_home()?;
168    Ok(home.join(network).join("credentials.json"))
169}
170
171// ── Keyring ────────────────────────────────────────────────────────────
172
173const KEYRING_SERVICE: &str = "outlayer-cli";
174
175fn keyring_key(network: &str, account_id: &str) -> String {
176    format!("{network}:{account_id}")
177}
178
179pub fn save_private_key(network: &str, account_id: &str, key: &str) -> bool {
180    let entry = match keyring::Entry::new(KEYRING_SERVICE, &keyring_key(network, account_id)) {
181        Ok(e) => e,
182        Err(_) => return false,
183    };
184    if entry.set_password(key).is_err() {
185        return false;
186    }
187    // Verify we can read it back (some keychains report success but fail on read)
188    entry.get_password().is_ok()
189}
190
191pub fn load_private_key(network: &str, account_id: &str, creds: &Credentials) -> Result<String> {
192    // Try keychain first
193    if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &keyring_key(network, account_id)) {
194        if let Ok(key) = entry.get_password() {
195            return Ok(key);
196        }
197    }
198    // Fall back to file
199    creds
200        .private_key
201        .clone()
202        .context("Private key not found in credentials or keychain")
203}
204
205fn delete_private_key(network: &str, account_id: &str) {
206    if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &keyring_key(network, account_id)) {
207        let _ = entry.delete_credential();
208    }
209}
210
211// ── Credential Operations ──────────────────────────────────────────────
212
213pub fn load_credentials(network: &NetworkConfig) -> Result<Credentials> {
214    let path = credentials_path(&network.network_id)?;
215    let data = std::fs::read_to_string(&path)
216        .with_context(|| format!("Not logged in. Run: outlayer login --network {}", network.network_id))?;
217    serde_json::from_str(&data).context("Invalid credentials file")
218}
219
220pub fn save_credentials(network: &NetworkConfig, creds: &Credentials) -> Result<()> {
221    let path = credentials_path(&network.network_id)?;
222    if let Some(parent) = path.parent() {
223        std::fs::create_dir_all(parent)?;
224    }
225    let data = serde_json::to_string_pretty(creds)?;
226    std::fs::write(&path, data)?;
227    Ok(())
228}
229
230pub fn delete_credentials(network: &NetworkConfig) -> Result<()> {
231    let path = credentials_path(&network.network_id)?;
232    if path.exists() {
233        // Try to load account_id to clean up keyring
234        if let Ok(data) = std::fs::read_to_string(&path) {
235            if let Ok(creds) = serde_json::from_str::<Credentials>(&data) {
236                delete_private_key(&network.network_id, &creds.account_id);
237            }
238        }
239        std::fs::remove_file(&path)?;
240    }
241    Ok(())
242}
243
244// ── Project Config Operations ──────────────────────────────────────────
245
246pub fn load_project_config() -> Result<ProjectConfig> {
247    let path = std::env::current_dir()?.join("outlayer.toml");
248    let data = std::fs::read_to_string(&path)
249        .context("outlayer.toml not found. Run 'outlayer create <name>' first.")?;
250    toml::from_str(&data).context("Invalid outlayer.toml")
251}
252