Skip to main content

lean_ctx/cli/
cloud.rs

1use crate::cloud_client::ServerClient;
2use crate::models::ServerConnection;
3use crate::{config, core, git_context};
4use anyhow::{bail, Context, Result};
5use serde_json::json;
6use std::io::{self, IsTerminal, Write};
7
8fn removed_cloud_command(command: &str) -> ! {
9    eprintln!(
10        "`nebu-ctx {command}` is no longer available in the Rust client. Use `nebu-ctx cloud connect --endpoint <url> --token <token>` against your NebuCtx server."
11    );
12    std::process::exit(1);
13}
14
15pub fn cmd_login(_args: &[String]) {
16    removed_cloud_command("login");
17}
18
19pub fn cmd_forgot_password(_args: &[String]) {
20    removed_cloud_command("forgot-password");
21}
22
23pub fn cmd_register(_args: &[String]) {
24    removed_cloud_command("register");
25}
26
27pub fn cmd_sync() {
28    if let Err(error) = sync_current_checkout() {
29        eprintln!("{error}");
30        std::process::exit(1);
31    }
32}
33
34fn sync_current_checkout() -> Result<()> {
35    let client = load_or_prompt_cloud_client()?;
36
37    let cwd = std::env::current_dir().context("failed to read current directory")?;
38    let project_context = git_context::discover_project_context(&cwd);
39
40    let response = client
41        .resolve_project(&project_context)
42        .context("failed to sync with cloud")?;
43
44    let binding = &project_context.checkout_binding;
45    output_json(json!({
46        "synced": true,
47        "project_id": response.project.project_id,
48        "slug": response.project.slug,
49        "checkout_bound": response.checkout_bound,
50        "branch": binding.branch,
51        "commit": binding.last_commit,
52        "local_root": binding.local_root,
53        "endpoint": client.endpoint(),
54    }))
55}
56
57pub fn cmd_contribute() {
58    removed_cloud_command("contribute");
59}
60
61pub fn cmd_cloud(args: &[String]) {
62    let action = args.first().map(|value| value.as_str()).unwrap_or("help");
63
64    match action {
65        "connect" => {
66            if let Err(error) = connect_cloud(&args[1..]) {
67                eprintln!("{error}");
68                std::process::exit(1);
69            }
70        }
71        "bind" => {
72            if let Err(error) = bind_current_project() {
73                eprintln!("{error}");
74                std::process::exit(1);
75            }
76        }
77        "sync" => {
78            cmd_sync();
79        }
80        "disconnect" => {
81            if let Err(error) = disconnect_cloud() {
82                eprintln!("{error}");
83                std::process::exit(1);
84            }
85        }
86        "status" => {
87            if let Err(error) = show_cloud_status() {
88                eprintln!("{error}");
89                std::process::exit(1);
90            }
91        }
92        _ => {
93            println!("Usage: nebu-ctx cloud <command>");
94            println!("  connect     - Save and validate a cloud endpoint + token");
95            println!("  status      - Show cloud connection status");
96            println!("  bind        - Bind the current checkout to a canonical project");
97            println!("  sync        - Sync current checkout state (branch, commit) to the cloud");
98            println!("  disconnect  - Remove the saved cloud connection");
99        }
100    }
101}
102
103fn connect_cloud(command_args: &[String]) -> Result<()> {
104    if has_help_flag(command_args) {
105        println!("Usage: nebu-ctx cloud connect [--endpoint <url>] [--token <token>]");
106        return Ok(());
107    }
108
109    let saved_connection = config::load_connection().ok().flatten();
110    let endpoint = match option_value(command_args, &["--endpoint", "-e", "--url"]) {
111        Some(value) => value,
112        None => match saved_connection.as_ref() {
113            Some(connection) => connection.endpoint.clone(),
114            None => prompt_required_value("Cloud URL", None)?,
115        },
116    };
117    let token = match option_value(command_args, &["--token", "-t"]) {
118        Some(value) => value,
119        None => prompt_required_secret("Cloud token")?,
120    };
121
122    let (connection, client) = validate_and_save_connection(&endpoint, &token)?;
123    let health = client.health()?;
124    output_json(json!({
125        "connected": true,
126        "endpoint": connection.endpoint,
127        "health": health,
128    }))
129}
130
131fn show_cloud_status() -> Result<()> {
132    let client = load_or_prompt_cloud_client()?;
133    let health = client.health()?;
134    output_json(json!({
135        "saved": true,
136        "endpoint": client.endpoint(),
137        "health": health,
138    }))
139}
140
141fn bind_current_project() -> Result<()> {
142    let client = load_or_prompt_cloud_client()?;
143    let project_context = git_context::discover_project_context(
144        &std::env::current_dir().context("failed to read current directory")?,
145    );
146    output_json(serde_json::to_value(client.resolve_project(&project_context)?)?)
147}
148
149fn disconnect_cloud() -> Result<()> {
150    config::clear_connection()?;
151    output_json(json!({ "disconnected": true }))
152}
153
154fn load_or_prompt_cloud_client() -> Result<ServerClient> {
155    if let Ok(client) = ServerClient::load() {
156        return Ok(client);
157    }
158
159    if !io::stdin().is_terminal() {
160        bail!("No cloud connection saved. Run `nebu-ctx cloud connect --endpoint <url> --token <token>`." );
161    }
162
163    let endpoint = prompt_required_value("Cloud URL", None)?;
164    let token = prompt_required_secret("Cloud token")?;
165    let (_, client) = validate_and_save_connection(&endpoint, &token)?;
166    Ok(client)
167}
168
169fn validate_and_save_connection(endpoint: &str, token: &str) -> Result<(ServerConnection, ServerClient)> {
170    let connection = ServerConnection {
171        endpoint: config::normalize_server_endpoint(endpoint),
172        token: token.trim().to_string(),
173    };
174    let client = ServerClient::new(connection.clone());
175    client.health()?;
176    let saved_connection = config::save_connection(&connection.endpoint, &connection.token)?;
177    Ok((saved_connection, client))
178}
179
180fn prompt_required_value(label: &str, default_value: Option<&str>) -> Result<String> {
181    loop {
182        print!("{label}");
183        if let Some(default_value) = default_value {
184            print!(" [{default_value}]");
185        }
186        print!(": ");
187        io::stdout().flush().context("failed to flush prompt")?;
188
189        let mut input = String::new();
190        io::stdin()
191            .read_line(&mut input)
192            .context("failed to read terminal input")?;
193        let trimmed = input.trim();
194        if !trimmed.is_empty() {
195            return Ok(trimmed.to_string());
196        }
197
198        if let Some(default_value) = default_value {
199            return Ok(default_value.to_string());
200        }
201    }
202}
203
204fn prompt_required_secret(label: &str) -> Result<String> {
205    loop {
206        let value = rpassword::prompt_password(format!("{label}: "))
207            .context("failed to read token from terminal")?;
208        if !value.trim().is_empty() {
209            return Ok(value);
210        }
211    }
212}
213
214fn option_value(command_args: &[String], flags: &[&str]) -> Option<String> {
215    let mut index = 0;
216    while index < command_args.len() {
217        if flags.contains(&command_args[index].as_str()) {
218            return command_args.get(index + 1).cloned();
219        }
220
221        index += 1;
222    }
223
224    None
225}
226
227fn has_help_flag(command_args: &[String]) -> bool {
228    command_args
229        .iter()
230        .any(|argument| matches!(argument.as_str(), "--help" | "-h" | "help"))
231}
232
233fn output_json(value: serde_json::Value) -> Result<()> {
234    println!("{}", serde_json::to_string_pretty(&value)?);
235    Ok(())
236}
237
238pub fn cmd_gotchas(args: &[String]) {
239    let action = args.first().map(|value| value.as_str()).unwrap_or("list");
240    let project_root = std::env::current_dir()
241        .map(|path| path.to_string_lossy().to_string())
242        .unwrap_or_else(|_| ".".to_string());
243
244    match action {
245        "list" | "ls" => {
246            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
247            println!("{}", store.format_list());
248        }
249        "clear" => {
250            let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
251            let count = store.gotchas.len();
252            store.clear();
253            let _ = store.save(&project_root);
254            println!("Cleared {count} gotchas.");
255        }
256        "export" => {
257            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
258            match serde_json::to_string_pretty(&store.gotchas) {
259                Ok(json) => println!("{json}"),
260                Err(error) => eprintln!("Export failed: {error}"),
261            }
262        }
263        "stats" => {
264            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
265            println!("Bug Memory Stats:");
266            println!("  Active gotchas:      {}", store.gotchas.len());
267            println!("  Errors detected:     {}", store.stats.total_errors_detected);
268            println!("  Fixes correlated:    {}", store.stats.total_fixes_correlated);
269            println!("  Bugs prevented:      {}", store.stats.total_prevented);
270            println!("  Promoted to knowledge: {}", store.stats.gotchas_promoted);
271            println!("  Decayed/archived:    {}", store.stats.gotchas_decayed);
272            println!("  Session logs:        {}", store.error_log.len());
273        }
274        _ => {
275            println!("Usage: nebu-ctx gotchas [list|clear|export|stats]");
276        }
277    }
278}
279
280pub fn cmd_buddy(args: &[String]) {
281    let cfg = core::config::Config::load();
282    if !cfg.buddy_enabled {
283        println!("Buddy is disabled. Enable with: nebu-ctx config buddy_enabled true");
284        return;
285    }
286
287    let action = args.first().map(|value| value.as_str()).unwrap_or("show");
288    let buddy = core::buddy::BuddyState::compute();
289    let theme = core::theme::load_theme(&cfg.theme);
290
291    match action {
292        "show" | "status" | "stats" => {
293            println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
294        }
295        "ascii" => {
296            for line in &buddy.ascii_art {
297                println!("  {line}");
298            }
299        }
300        "json" => match serde_json::to_string_pretty(&buddy) {
301            Ok(json) => println!("{json}"),
302            Err(error) => eprintln!("JSON error: {error}"),
303        },
304        _ => {
305            println!("Usage: nebu-ctx buddy [show|stats|ascii|json]");
306        }
307    }
308}
309
310pub fn cmd_upgrade() {
311    println!("'upgrade' has been renamed to 'update'. Running 'nebu-ctx update' instead.\n");
312    core::updater::run(&[]);
313}