984 lines
32 KiB
Rust
984 lines
32 KiB
Rust
use dirs::home_dir;
|
|
use parking_lot::Mutex;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::collections::VecDeque;
|
|
use std::env;
|
|
use std::ffi::OsStr;
|
|
use std::fs;
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
|
|
|
fn log_line(message: &str) {
|
|
println!("[tauri-cli] {message}");
|
|
}
|
|
|
|
fn workspace_root() -> Option<PathBuf> {
|
|
std::env::current_dir().ok().and_then(|mut dir| {
|
|
for _ in 0..3 {
|
|
if let Some(parent) = dir.parent() {
|
|
dir = parent.to_path_buf();
|
|
}
|
|
}
|
|
Some(dir)
|
|
})
|
|
}
|
|
|
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
|
|
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
|
|
|
fn navigate_main(app: &AppHandle, url: &str) {
|
|
if let Some(win) = app.webview_windows().get("main") {
|
|
let mut display = url.to_string();
|
|
if let Some(hash_index) = display.find('#') {
|
|
display.replace_range(hash_index + 1.., "[REDACTED]");
|
|
}
|
|
log_line(&format!("navigating main to {display}"));
|
|
if let Ok(parsed) = Url::parse(url) {
|
|
let _ = win.navigate(parsed);
|
|
} else {
|
|
log_line("failed to parse URL for navigation");
|
|
}
|
|
} else {
|
|
log_line("main window not found for navigation");
|
|
}
|
|
}
|
|
|
|
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|
let prefix = format!("{name}=");
|
|
let cookie_kv = set_cookie.split(';').next()?.trim();
|
|
if !cookie_kv.starts_with(&prefix) {
|
|
return None;
|
|
}
|
|
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
|
if value.is_empty() {
|
|
return None;
|
|
}
|
|
Some(value.to_string())
|
|
}
|
|
|
|
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
|
let parsed = Url::parse(base_url)?;
|
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
|
|
|
// This is only used for local bootstrap; we assume plain HTTP.
|
|
let mut stream = TcpStream::connect((host, port))?;
|
|
|
|
let body = format!("{{\"token\":\"{}\"}}", token);
|
|
let request = format!(
|
|
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.as_bytes().len(),
|
|
body
|
|
);
|
|
|
|
stream.write_all(request.as_bytes())?;
|
|
stream.flush()?;
|
|
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response)?;
|
|
|
|
let (raw_headers, _rest) = response
|
|
.split_once("\r\n\r\n")
|
|
.or_else(|| response.split_once("\n\n"))
|
|
.unwrap_or((response.as_str(), ""));
|
|
|
|
let mut lines = raw_headers.lines();
|
|
let status_line = lines.next().unwrap_or("");
|
|
if !status_line.contains(" 200 ") {
|
|
return Ok(None);
|
|
}
|
|
|
|
for line in lines {
|
|
// handle case-insensitive header name
|
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
|
return Ok(Some(session_id));
|
|
}
|
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
|
return Ok(Some(session_id));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
|
let parsed = Url::parse(base_url)?;
|
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
|
|
|
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
|
.domain(domain)
|
|
.path("/")
|
|
.http_only(true)
|
|
.same_site(tauri::webview::cookie::SameSite::Lax)
|
|
.build();
|
|
|
|
if let Some(win) = app.webview_windows().get("main") {
|
|
win.set_cookie(cookie)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PreferencesConfig {
|
|
#[serde(rename = "listeningMode")]
|
|
listening_mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AppConfig {
|
|
preferences: Option<PreferencesConfig>,
|
|
}
|
|
|
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
|
let raw = env::var("CLI_CONFIG")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
|
|
|
let expanded = expand_home(&raw);
|
|
let lower = raw.trim().to_lowercase();
|
|
|
|
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
|
|
let base = expanded
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| expanded.clone());
|
|
return (expanded, base.join("config.json"));
|
|
}
|
|
|
|
if lower.ends_with(".json") {
|
|
let base = expanded
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| expanded.clone());
|
|
return (base.join("config.yaml"), expanded);
|
|
}
|
|
|
|
// Treat as directory.
|
|
(expanded.join("config.yaml"), expanded.join("config.json"))
|
|
}
|
|
|
|
fn expand_home(path: &str) -> PathBuf {
|
|
if path.starts_with("~/") {
|
|
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
|
|
return home.join(path.trim_start_matches("~/"));
|
|
}
|
|
}
|
|
PathBuf::from(path)
|
|
}
|
|
|
|
fn resolve_listening_mode() -> String {
|
|
let (yaml_path, json_path) = resolve_config_locations();
|
|
|
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
|
if let Some(mode) = config
|
|
.preferences
|
|
.as_ref()
|
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
|
{
|
|
if mode == "local" {
|
|
return "local".to_string();
|
|
}
|
|
if mode == "all" {
|
|
return "all".to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy fallback.
|
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
|
if let Some(mode) = config
|
|
.preferences
|
|
.as_ref()
|
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
|
{
|
|
if mode == "local" {
|
|
return "local".to_string();
|
|
}
|
|
if mode == "all" {
|
|
return "all".to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"local".to_string()
|
|
}
|
|
|
|
fn resolve_listening_host() -> String {
|
|
let mode = resolve_listening_mode();
|
|
if mode == "local" {
|
|
"127.0.0.1".to_string()
|
|
} else {
|
|
"0.0.0.0".to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CliState {
|
|
Starting,
|
|
Ready,
|
|
Error,
|
|
Stopped,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CliStatus {
|
|
pub state: CliState,
|
|
pub pid: Option<u32>,
|
|
pub port: Option<u16>,
|
|
pub url: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl Default for CliStatus {
|
|
fn default() -> Self {
|
|
Self {
|
|
state: CliState::Stopped,
|
|
pid: None,
|
|
port: None,
|
|
url: None,
|
|
error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CliProcessManager {
|
|
status: Arc<Mutex<CliStatus>>,
|
|
child: Arc<Mutex<Option<Child>>>,
|
|
ready: Arc<AtomicBool>,
|
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
|
}
|
|
|
|
impl CliProcessManager {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
|
child: Arc::new(Mutex::new(None)),
|
|
ready: Arc::new(AtomicBool::new(false)),
|
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
|
}
|
|
}
|
|
|
|
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
|
log_line(&format!("start requested (dev={dev})"));
|
|
self.stop()?;
|
|
self.ready.store(false, Ordering::SeqCst);
|
|
*self.bootstrap_token.lock() = None;
|
|
{
|
|
let mut status = self.status.lock();
|
|
status.state = CliState::Starting;
|
|
status.port = None;
|
|
status.url = None;
|
|
status.error = None;
|
|
status.pid = None;
|
|
}
|
|
Self::emit_status(&app, &self.status.lock());
|
|
|
|
let status_arc = self.status.clone();
|
|
let child_arc = self.child.clone();
|
|
let ready_flag = self.ready.clone();
|
|
let token_arc = self.bootstrap_token.clone();
|
|
thread::spawn(move || {
|
|
if let Err(err) = Self::spawn_cli(
|
|
app.clone(),
|
|
status_arc.clone(),
|
|
child_arc,
|
|
ready_flag,
|
|
token_arc,
|
|
dev,
|
|
) {
|
|
log_line(&format!("cli spawn failed: {err}"));
|
|
let mut locked = status_arc.lock();
|
|
locked.state = CliState::Error;
|
|
locked.error = Some(err.to_string());
|
|
let snapshot = locked.clone();
|
|
drop(locked);
|
|
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
|
|
let _ = app.emit("cli:status", snapshot);
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop(&self) -> anyhow::Result<()> {
|
|
let mut child_opt = self.child.lock();
|
|
if let Some(mut child) = child_opt.take() {
|
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let _ = child.kill();
|
|
}
|
|
|
|
let start = Instant::now();
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => break,
|
|
Ok(None) => {
|
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
|
log_line(&format!(
|
|
"stop timed out after {}s; sending SIGKILL pid={}",
|
|
CLI_STOP_GRACE_SECS,
|
|
child.id()
|
|
));
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let _ = child.kill();
|
|
}
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut status = self.status.lock();
|
|
status.state = CliState::Stopped;
|
|
status.pid = None;
|
|
status.port = None;
|
|
status.url = None;
|
|
status.error = None;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn status(&self) -> CliStatus {
|
|
self.status.lock().clone()
|
|
}
|
|
|
|
fn spawn_cli(
|
|
app: AppHandle,
|
|
status: Arc<Mutex<CliStatus>>,
|
|
child_holder: Arc<Mutex<Option<Child>>>,
|
|
ready: Arc<AtomicBool>,
|
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
|
dev: bool,
|
|
) -> anyhow::Result<()> {
|
|
log_line("resolving CLI entry");
|
|
let resolution = CliEntry::resolve(&app, dev)?;
|
|
let host = resolve_listening_host();
|
|
log_line(&format!(
|
|
"resolved CLI entry runner={:?} entry={} host={}",
|
|
resolution.runner, resolution.entry, host
|
|
));
|
|
let args = resolution.build_args(dev, &host);
|
|
log_line(&format!("CLI args: {:?}", args));
|
|
if dev {
|
|
log_line("development mode: will prefer tsx + source if present");
|
|
}
|
|
|
|
let cwd = workspace_root();
|
|
if let Some(ref c) = cwd {
|
|
log_line(&format!("using cwd={}", c.display()));
|
|
}
|
|
|
|
let command_info = if supports_user_shell() {
|
|
log_line("spawning via user shell");
|
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
|
} else {
|
|
log_line("spawning directly with node");
|
|
ShellCommandType::Direct(DirectCommand {
|
|
program: resolution.node_binary.clone(),
|
|
args: resolution.runner_args(&args),
|
|
})
|
|
};
|
|
|
|
if !supports_user_shell() {
|
|
if which::which(&resolution.node_binary).is_err() {
|
|
return Err(anyhow::anyhow!(
|
|
"Node binary not found. Make sure Node.js is installed."
|
|
));
|
|
}
|
|
}
|
|
|
|
let child = match &command_info {
|
|
ShellCommandType::UserShell(cmd) => {
|
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
|
let mut c = Command::new(&cmd.shell);
|
|
c.args(&cmd.args)
|
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if let Some(ref cwd) = cwd {
|
|
c.current_dir(cwd);
|
|
}
|
|
c.spawn()?
|
|
}
|
|
ShellCommandType::Direct(cmd) => {
|
|
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
|
let mut c = Command::new(&cmd.program);
|
|
c.args(&cmd.args)
|
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if let Some(ref cwd) = cwd {
|
|
c.current_dir(cwd);
|
|
}
|
|
c.spawn()?
|
|
}
|
|
};
|
|
|
|
let pid = child.id();
|
|
log_line(&format!("spawned pid={pid}"));
|
|
{
|
|
let mut locked = status.lock();
|
|
locked.pid = Some(pid);
|
|
}
|
|
Self::emit_status(&app, &status.lock());
|
|
|
|
{
|
|
let mut holder = child_holder.lock();
|
|
*holder = Some(child);
|
|
}
|
|
|
|
let child_clone = child_holder.clone();
|
|
let status_clone = status.clone();
|
|
let app_clone = app.clone();
|
|
let ready_clone = ready.clone();
|
|
let token_clone = bootstrap_token.clone();
|
|
|
|
thread::spawn(move || {
|
|
let stdout = child_clone
|
|
.lock()
|
|
.as_mut()
|
|
.and_then(|c| c.stdout.take())
|
|
.map(BufReader::new);
|
|
let stderr = child_clone
|
|
.lock()
|
|
.as_mut()
|
|
.and_then(|c| c.stderr.take())
|
|
.map(BufReader::new);
|
|
|
|
if let Some(reader) = stdout {
|
|
Self::process_stream(
|
|
reader,
|
|
"stdout",
|
|
&app_clone,
|
|
&status_clone,
|
|
&ready_clone,
|
|
&token_clone,
|
|
);
|
|
}
|
|
if let Some(reader) = stderr {
|
|
Self::process_stream(
|
|
reader,
|
|
"stderr",
|
|
&app_clone,
|
|
&status_clone,
|
|
&ready_clone,
|
|
&token_clone,
|
|
);
|
|
}
|
|
});
|
|
|
|
let app_clone = app.clone();
|
|
let status_clone = status.clone();
|
|
let ready_clone = ready.clone();
|
|
let child_holder_clone = child_holder.clone();
|
|
thread::spawn(move || {
|
|
let timeout = Duration::from_secs(60);
|
|
thread::sleep(timeout);
|
|
if ready_clone.load(Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
let mut locked = status_clone.lock();
|
|
locked.state = CliState::Error;
|
|
locked.error = Some("CLI did not start in time".to_string());
|
|
log_line("timeout waiting for CLI readiness");
|
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
|
let _ = child.kill();
|
|
}
|
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
|
Self::emit_status(&app_clone, &locked);
|
|
});
|
|
|
|
let status_clone = status.clone();
|
|
let app_clone = app.clone();
|
|
thread::spawn(move || {
|
|
// Do not hold the child mutex while waiting for process exit.
|
|
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
|
|
// same lock to send SIGTERM/SIGKILL when the user quits the app.
|
|
let code = loop {
|
|
let maybe_exited = {
|
|
let mut guard = child_holder.lock();
|
|
if guard.is_none() {
|
|
return;
|
|
}
|
|
match guard
|
|
.as_mut()
|
|
.and_then(|child| child.try_wait().ok().flatten())
|
|
{
|
|
Some(status) => {
|
|
// Drop the handle after the process exits so other callers
|
|
// don't attempt to stop/kill a finished process.
|
|
*guard = None;
|
|
Some(status)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
|
|
if let Some(status) = maybe_exited {
|
|
break Some(status);
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
};
|
|
|
|
let mut locked = status_clone.lock();
|
|
let failed = locked.state != CliState::Ready;
|
|
let err_msg = if failed {
|
|
Some(match code {
|
|
Some(status) => format!("CLI exited early: {status}"),
|
|
None => "CLI exited early".to_string(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if failed {
|
|
locked.state = CliState::Error;
|
|
if locked.error.is_none() {
|
|
locked.error = err_msg.clone();
|
|
}
|
|
log_line(&format!(
|
|
"cli process exited before ready: {:?}",
|
|
locked.error
|
|
));
|
|
let _ = app_clone.emit(
|
|
"cli:error",
|
|
json!({"message": locked.error.clone().unwrap_or_default()}),
|
|
);
|
|
} else {
|
|
locked.state = CliState::Stopped;
|
|
log_line("cli process stopped cleanly");
|
|
}
|
|
|
|
Self::emit_status(&app_clone, &locked);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_stream<R: BufRead>(
|
|
mut reader: R,
|
|
stream: &str,
|
|
app: &AppHandle,
|
|
status: &Arc<Mutex<CliStatus>>,
|
|
ready: &Arc<AtomicBool>,
|
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
|
) {
|
|
let mut buffer = String::new();
|
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
|
|
|
loop {
|
|
buffer.clear();
|
|
match reader.read_line(&mut buffer) {
|
|
Ok(0) => break,
|
|
Ok(_) => {
|
|
let line = buffer.trim_end();
|
|
if !line.is_empty() {
|
|
if line.starts_with(token_prefix) {
|
|
let token = line.trim_start_matches(token_prefix).trim();
|
|
if !token.is_empty() {
|
|
let mut guard = bootstrap_token.lock();
|
|
if guard.is_none() {
|
|
*guard = Some(token.to_string());
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
log_line(&format!("[cli][{}] {}", stream, line));
|
|
|
|
if ready.load(Ordering::SeqCst) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(url) = local_url_regex
|
|
.as_ref()
|
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
|
.map(|m| m.as_str().to_string())
|
|
{
|
|
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
|
continue;
|
|
}
|
|
|
|
if line.to_lowercase().contains("http server listening") {
|
|
if let Some(port) = http_regex
|
|
.as_ref()
|
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
|
{
|
|
Self::mark_ready(
|
|
app,
|
|
status,
|
|
ready,
|
|
bootstrap_token,
|
|
format!("http://localhost:{port}"),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
|
Self::mark_ready(
|
|
app,
|
|
status,
|
|
ready,
|
|
bootstrap_token,
|
|
format!("http://localhost:{}", port),
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mark_ready(
|
|
app: &AppHandle,
|
|
status: &Arc<Mutex<CliStatus>>,
|
|
ready: &Arc<AtomicBool>,
|
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
|
base_url: String,
|
|
) {
|
|
ready.store(true, Ordering::SeqCst);
|
|
let port = Url::parse(&base_url)
|
|
.ok()
|
|
.and_then(|u| u.port_or_known_default())
|
|
.map(|p| p as u16);
|
|
let mut locked = status.lock();
|
|
locked.port = port;
|
|
locked.url = Some(base_url.clone());
|
|
locked.state = CliState::Ready;
|
|
locked.error = None;
|
|
log_line(&format!("cli ready on {base_url}"));
|
|
|
|
let token = bootstrap_token.lock().take();
|
|
|
|
if let Some(token) = token {
|
|
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
|
// skip the exchange and let the user authenticate normally.
|
|
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
|
if scheme.as_deref() != Some("http") {
|
|
navigate_main(app, &base_url);
|
|
} else {
|
|
match exchange_bootstrap_token(&base_url, &token) {
|
|
Ok(Some(session_id)) => {
|
|
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
|
log_line(&format!("failed to set session cookie: {err}"));
|
|
navigate_main(app, &format!("{base_url}/login"));
|
|
} else {
|
|
navigate_main(app, &base_url);
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
log_line("bootstrap token exchange failed (invalid token)");
|
|
navigate_main(app, &format!("{base_url}/login"));
|
|
}
|
|
Err(err) => {
|
|
log_line(&format!("bootstrap token exchange failed: {err}"));
|
|
navigate_main(app, &format!("{base_url}/login"));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
navigate_main(app, &base_url);
|
|
}
|
|
let _ = app.emit("cli:ready", locked.clone());
|
|
Self::emit_status(app, &locked);
|
|
}
|
|
|
|
fn emit_status(app: &AppHandle, status: &CliStatus) {
|
|
let _ = app.emit("cli:status", status.clone());
|
|
}
|
|
}
|
|
|
|
fn supports_user_shell() -> bool {
|
|
cfg!(unix)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ShellCommand {
|
|
shell: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct DirectCommand {
|
|
program: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ShellCommandType {
|
|
UserShell(ShellCommand),
|
|
Direct(DirectCommand),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CliEntry {
|
|
entry: String,
|
|
runner: Runner,
|
|
runner_path: Option<String>,
|
|
node_binary: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Runner {
|
|
Node,
|
|
Tsx,
|
|
}
|
|
|
|
impl CliEntry {
|
|
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
|
|
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
|
|
|
if dev {
|
|
if let Some(tsx_path) = resolve_tsx(app) {
|
|
if let Some(entry) = resolve_dev_entry(app) {
|
|
return Ok(Self {
|
|
entry,
|
|
runner: Runner::Tsx,
|
|
runner_path: Some(tsx_path),
|
|
node_binary,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(entry) = resolve_dist_entry(app) {
|
|
return Ok(Self {
|
|
entry,
|
|
runner: Runner::Node,
|
|
runner_path: None,
|
|
node_binary,
|
|
});
|
|
}
|
|
|
|
Err(anyhow::anyhow!(
|
|
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
|
))
|
|
}
|
|
|
|
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
|
let mut args = vec![
|
|
"serve".to_string(),
|
|
"--host".to_string(),
|
|
host.to_string(),
|
|
"--generate-token".to_string(),
|
|
];
|
|
|
|
if dev {
|
|
// Dev: plain HTTP + Vite dev server proxy.
|
|
args.push("--https".to_string());
|
|
args.push("false".to_string());
|
|
args.push("--http".to_string());
|
|
args.push("true".to_string());
|
|
args.push("--ui-dev-server".to_string());
|
|
args.push("http://localhost:3000".to_string());
|
|
args.push("--log-level".to_string());
|
|
args.push("debug".to_string());
|
|
} else {
|
|
// Prod desktop: always keep loopback HTTP enabled.
|
|
args.push("--https".to_string());
|
|
args.push("true".to_string());
|
|
args.push("--http".to_string());
|
|
args.push("true".to_string());
|
|
}
|
|
args
|
|
}
|
|
|
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
|
let mut args = VecDeque::new();
|
|
if self.runner == Runner::Tsx {
|
|
if let Some(path) = &self.runner_path {
|
|
args.push_back(path.clone());
|
|
}
|
|
}
|
|
args.push_back(self.entry.clone());
|
|
for arg in cli_args {
|
|
args.push_back(arg.clone());
|
|
}
|
|
args.into_iter().collect()
|
|
}
|
|
}
|
|
|
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|
let candidates = vec![
|
|
std::env::current_dir()
|
|
.ok()
|
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
|
std::env::current_exe().ok().and_then(|ex| {
|
|
ex.parent()
|
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
|
}),
|
|
];
|
|
|
|
first_existing(candidates)
|
|
}
|
|
|
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
|
let candidates = vec![
|
|
std::env::current_dir()
|
|
.ok()
|
|
.map(|p| p.join("packages/server/src/index.ts")),
|
|
std::env::current_dir()
|
|
.ok()
|
|
.map(|p| p.join("../server/src/index.ts")),
|
|
];
|
|
|
|
first_existing(candidates)
|
|
}
|
|
|
|
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|
let base = workspace_root();
|
|
let mut candidates: Vec<Option<PathBuf>> = vec![
|
|
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
|
base.as_ref()
|
|
.map(|p| p.join("packages/server/dist/index.js")),
|
|
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
|
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
|
];
|
|
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if let Some(dir) = exe.parent() {
|
|
let resources = dir.join("../Resources");
|
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
|
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
|
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
|
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
|
candidates.push(Some(
|
|
resources.join("resources/server/dist/server/index.js"),
|
|
));
|
|
|
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
|
for root in linux_resource_roots {
|
|
candidates.push(Some(root.join("server/dist/bin.js")));
|
|
candidates.push(Some(root.join("server/dist/index.js")));
|
|
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
|
candidates.push(Some(root.join("server/dist/server/index.js")));
|
|
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
|
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
|
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
|
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
|
}
|
|
}
|
|
}
|
|
|
|
first_existing(candidates)
|
|
}
|
|
|
|
fn build_shell_command_string(
|
|
entry: &CliEntry,
|
|
cli_args: &[String],
|
|
) -> anyhow::Result<ShellCommand> {
|
|
let shell = default_shell();
|
|
let mut quoted: Vec<String> = Vec::new();
|
|
quoted.push(shell_escape(&entry.node_binary));
|
|
for arg in entry.runner_args(cli_args) {
|
|
quoted.push(shell_escape(&arg));
|
|
}
|
|
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
|
let args = build_shell_args(&shell, &command);
|
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
|
Ok(ShellCommand { shell, args })
|
|
}
|
|
|
|
fn default_shell() -> String {
|
|
if let Ok(shell) = std::env::var("SHELL") {
|
|
if !shell.trim().is_empty() {
|
|
return shell;
|
|
}
|
|
}
|
|
if cfg!(target_os = "macos") {
|
|
"/bin/zsh".to_string()
|
|
} else {
|
|
"/bin/bash".to_string()
|
|
}
|
|
}
|
|
|
|
fn shell_escape(input: &str) -> String {
|
|
if input.is_empty() {
|
|
"''".to_string()
|
|
} else if !input
|
|
.chars()
|
|
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
|
|
{
|
|
input.to_string()
|
|
} else {
|
|
let escaped = input.replace('\'', "'\\''");
|
|
format!("'{}'", escaped)
|
|
}
|
|
}
|
|
|
|
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|
let shell_name = std::path::Path::new(shell)
|
|
.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
|
|
if shell_name.contains("zsh") {
|
|
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
|
} else {
|
|
vec!["-l".into(), "-c".into(), command.into()]
|
|
}
|
|
}
|
|
|
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|
paths
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|p| p.exists())
|
|
.map(|p| normalize_path(p))
|
|
}
|
|
|
|
fn normalize_path(path: PathBuf) -> String {
|
|
if let Ok(clean) = path.canonicalize() {
|
|
clean.to_string_lossy().to_string()
|
|
} else {
|
|
path.to_string_lossy().to_string()
|
|
}
|
|
}
|