1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
//! Logic for the 'serve' command.
use crate::commands::print_banner;
use anyhow::Result;
use clap::ValueEnum;
use colored::Colorize;
/// Server operation mode. Mirrors the standalone web binary's `ServerMode`
/// (crates/oxo-flow-web/src/main.rs) so `oxo-flow serve` cannot start with a
/// misspelled mode: any value outside these three is rejected at parse time
/// instead of silently downgrading to a no-auth server (`build_router`
/// enables auth only for exact "team"/"hpc" matches).
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum ServeMode {
/// Personal workstation mode (loopback bind, no auth).
Personal,
/// Team server mode (bind any host, auth required).
Team,
/// HPC cluster mode (bind any host, auth + scheduler routes).
Hpc,
}
impl ServeMode {
pub fn as_str(&self) -> &'static str {
match self {
ServeMode::Personal => "personal",
ServeMode::Team => "team",
ServeMode::Hpc => "hpc",
}
}
}
pub async fn handle_serve(
mode: ServeMode,
host: String,
port: u16,
base_path: String,
open_browser: bool,
) -> Result<()> {
print_banner();
// Reuse, never retype: the router's mount-path contract lives in the
// web crate (`normalize_base_path`); the previous inline copy here was
// a hand-typed duplicate that had already drifted from it.
let base = oxo_flow_web::server::normalize_base_path(&base_path);
eprintln!(
"{} Starting oxo-flow web server in {} mode on {}:{}{}",
"Serve:".bold().cyan(),
mode.as_str(),
host,
port,
if base.is_empty() {
String::new()
} else {
format!(" (base: {base})")
}
);
// Desktop-app experience: open the interface in the default browser
// once the server is listening. Fire-and-forget — a missing opener
// (headless server) must not fail the serve command.
if open_browser {
let url = format!("http://{host}:{port}{base}");
tokio::spawn(async move {
// Small delay so the listener is up before the browser hits it.
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
if let Err(e) = open_url_in_browser(&url) {
eprintln!(
"{} Could not open a browser automatically: {e} — open {url} manually",
"Note:".yellow()
);
}
});
}
// Pass the NORMALIZED path — axum's nest() panics on a mount path
// without a leading slash (e.g. `--base-path oxoflow`), so the raw
// argument must never reach the router.
oxo_flow_web::start_server_with_mode(mode.as_str(), &host, port, &base).await?;
Ok(())
}
/// Open a URL in the platform's default browser (best-effort).
fn open_url_in_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(windows)]
{
std::process::Command::new("cmd")
.args(["/c", "start", "", url])
.spawn()
.map(|_| ())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"no browser opener on this platform",
))
}
}