1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8use serde_json::json;
9use which::which;
10
11use crate::cli::{GuiPackDevArgs, GuiPackKind, GuiServeArgs};
12
13const DEFAULT_BIND: &str = "127.0.0.1:8080";
14const DEFAULT_DOMAIN: &str = "localhost:8080";
15
16#[derive(Debug, Deserialize)]
17pub struct GuiDevConfig {
18 pub tenant: String,
19 #[serde(default = "default_domain")]
20 pub domain: String,
21 #[serde(default)]
22 pub bind: Option<String>,
23 pub layout_pack: PathBuf,
24 #[serde(default)]
25 pub auth_pack: Option<PathBuf>,
26 #[serde(default)]
27 pub skin_pack: Option<PathBuf>,
28 #[serde(default)]
29 pub telemetry_pack: Option<PathBuf>,
30 #[serde(default)]
31 pub feature_packs: Vec<PathBuf>,
32 #[serde(default)]
33 pub env: HashMap<String, String>,
34 #[serde(default)]
35 pub worker_overrides: HashMap<String, String>,
36}
37
38fn default_domain() -> String {
39 DEFAULT_DOMAIN.to_string()
40}
41
42#[derive(Debug, Deserialize)]
43#[serde(tag = "kind")]
44enum GuiManifest {
45 #[serde(rename = "gui-layout")]
46 Layout { layout: LayoutSection },
47 #[serde(rename = "gui-feature")]
48 Feature { routes: Vec<FeatureRoute> },
49 #[serde(rename = "gui-auth")]
50 Auth { routes: Vec<AuthRoute> },
51 #[serde(other)]
52 Other,
53}
54
55#[derive(Debug, Deserialize)]
56struct LayoutSection {
57 entrypoint_html: String,
58 #[serde(default)]
59 #[allow(dead_code)]
60 slots: Vec<String>,
61}
62
63#[derive(Debug, Deserialize)]
64struct FeatureRoute {
65 path: String,
66 #[serde(default)]
67 authenticated: bool,
68}
69
70#[derive(Debug, Deserialize)]
71struct AuthRoute {
72 path: String,
73 #[serde(default)]
74 public: bool,
75}
76
77pub fn run_gui_command(cmd: crate::cli::GuiCommand) -> Result<()> {
78 match cmd {
79 crate::cli::GuiCommand::Serve(args) => run_gui_serve(&args),
80 crate::cli::GuiCommand::PackDev(args) => run_pack_dev(&args),
81 }
82}
83
84fn run_gui_serve(args: &GuiServeArgs) -> Result<()> {
85 let config_path = resolve_config_path(args.config.as_deref())?;
86 let config = load_config(&config_path)?;
87 validate_config(&config)?;
88
89 let bind = args
90 .bind
91 .as_deref()
92 .or(config.bind.as_deref())
93 .unwrap_or(DEFAULT_BIND);
94 let domain = args.domain.as_deref().unwrap_or(&config.domain);
95
96 println!(
97 "Starting greentic-gui for tenant {} on http://{} (bind {})",
98 config.tenant, domain, bind
99 );
100 summarize_routes(&config);
101
102 let mut command = if let Some(gui_bin) = args.gui_bin.as_ref() {
103 Command::new(gui_bin)
104 } else if let Ok(bin) = which("greentic-gui") {
105 Command::new(bin)
106 } else if args.no_cargo_fallback {
107 bail!("greentic-gui binary not found on PATH and cargo fallback disabled");
108 } else {
109 println!("greentic-gui not found on PATH; falling back to `cargo run -p greentic-gui`");
110 let mut cmd = Command::new("cargo");
111 cmd.args(["run", "-p", "greentic-gui", "--"]);
112 cmd
113 };
114
115 command
116 .arg("--config")
117 .arg(&config_path)
118 .arg("--bind")
119 .arg(bind)
120 .arg("--domain")
121 .arg(domain)
122 .stdin(Stdio::inherit())
123 .stdout(Stdio::inherit())
124 .stderr(Stdio::inherit());
125
126 let mut child = command.spawn().context("failed to launch greentic-gui")?;
127
128 if args.open_browser {
129 let _ = open_browser(&format!("http://{}", bind));
130 }
131
132 child.wait().context("greentic-gui exited abnormally")?;
133 Ok(())
134}
135
136fn summarize_routes(config: &GuiDevConfig) {
137 let mut routes = Vec::new();
138 if let Some(route) = extract_layout_route(&config.layout_pack) {
139 routes.push(route);
140 }
141 if let Some(path) = config.auth_pack.as_ref() {
142 routes.extend(extract_auth_routes(path));
143 }
144 for feature in &config.feature_packs {
145 routes.extend(extract_feature_routes(feature));
146 }
147 if routes.is_empty() {
148 println!("Routes: (none detected from manifests)");
149 } else {
150 println!("Routes:");
151 for route in routes {
152 println!(" - {}", route);
153 }
154 }
155}
156
157fn extract_layout_route(pack: &Path) -> Option<String> {
158 read_manifest(pack).and_then(|manifest| match manifest {
159 GuiManifest::Layout { layout } => {
160 Some(format!("/ (entrypoint {})", layout.entrypoint_html))
161 }
162 _ => None,
163 })
164}
165
166fn extract_auth_routes(pack: &Path) -> Vec<String> {
167 match read_manifest(pack) {
168 Some(GuiManifest::Auth { routes }) => routes
169 .into_iter()
170 .map(|route| {
171 let visibility = if route.public { "public" } else { "auth" };
172 format!("{} (auth: {})", route.path, visibility)
173 })
174 .collect(),
175 _ => Vec::new(),
176 }
177}
178
179fn extract_feature_routes(pack: &Path) -> Vec<String> {
180 match read_manifest(pack) {
181 Some(GuiManifest::Feature { routes }) => routes
182 .into_iter()
183 .map(|route| {
184 let visibility = if route.authenticated {
185 "auth"
186 } else {
187 "public"
188 };
189 format!("{} (feature: {})", route.path, visibility)
190 })
191 .collect(),
192 _ => Vec::new(),
193 }
194}
195
196fn read_manifest(pack_path: &Path) -> Option<GuiManifest> {
197 if !pack_path.is_dir() {
198 return None;
199 }
200 let manifest_path = pack_path.join("gui").join("manifest.json");
201 let data = fs::read_to_string(manifest_path).ok()?;
202 serde_json::from_str(&data).ok()
203}
204
205pub fn resolve_config_path(cli_override: Option<&Path>) -> Result<PathBuf> {
206 let mut searched = Vec::new();
207 if let Some(override_path) = cli_override {
208 if override_path.exists() {
209 return Ok(override_path.to_path_buf());
210 }
211 bail!(
212 "provided gui-dev config {} does not exist",
213 override_path.display()
214 );
215 }
216
217 let cwd = std::env::current_dir().context("unable to read current directory")?;
218 let candidates = [
219 cwd.join("gui-dev.yaml"),
220 cwd.join(".greentic").join("gui-dev.yaml"),
221 dirs::config_dir()
222 .unwrap_or_else(|| PathBuf::from("/nonexistent"))
223 .join("greentic-dev")
224 .join("gui-dev.yaml"),
225 ];
226 for candidate in candidates {
227 searched.push(candidate.clone());
228 if candidate.exists() {
229 return Ok(candidate);
230 }
231 }
232 bail!(
233 "no gui-dev.yaml found; looked in: {}",
234 searched
235 .into_iter()
236 .map(|p| p.display().to_string())
237 .collect::<Vec<_>>()
238 .join(", ")
239 )
240}
241
242fn load_config(path: &Path) -> Result<GuiDevConfig> {
243 let data = fs::read_to_string(path)
244 .with_context(|| format!("failed to read gui-dev config at {}", path.display()))?;
245 let mut config: GuiDevConfig = serde_yaml_bw::from_str(&data)
246 .with_context(|| format!("failed to parse gui-dev config at {}", path.display()))?;
247 if config.domain.is_empty() {
248 config.domain = DEFAULT_DOMAIN.to_string();
249 }
250 Ok(config)
251}
252
253fn validate_config(config: &GuiDevConfig) -> Result<()> {
254 ensure_path(&config.layout_pack, "layout_pack")?;
255 if let Some(path) = &config.auth_pack {
256 ensure_path(path, "auth_pack")?;
257 }
258 if let Some(path) = &config.skin_pack {
259 ensure_path(path, "skin_pack")?;
260 }
261 if let Some(path) = &config.telemetry_pack {
262 ensure_path(path, "telemetry_pack")?;
263 }
264 for (idx, path) in config.feature_packs.iter().enumerate() {
265 ensure_path(path, &format!("feature_packs[{}]", idx))?;
266 }
267 Ok(())
268}
269
270fn ensure_path(path: &Path, label: &str) -> Result<()> {
271 if !path.exists() {
272 bail!("{} path {} does not exist", label, path.display());
273 }
274 Ok(())
275}
276
277fn run_pack_dev(args: &GuiPackDevArgs) -> Result<()> {
278 if let Some(cmd) = args.build_cmd.as_ref()
279 && !args.no_build
280 {
281 run_build_cmd(cmd, &args.dir)?;
282 }
283
284 stage_pack(args)?;
285 Ok(())
286}
287
288fn run_build_cmd(cmd: &str, dir: &Path) -> Result<()> {
289 println!("Running build command: {}", cmd);
290 #[cfg(target_os = "windows")]
291 let mut command = Command::new("cmd");
292 #[cfg(target_os = "windows")]
293 command.args(["/C", cmd]);
294
295 #[cfg(not(target_os = "windows"))]
296 let mut command = Command::new("sh");
297 #[cfg(not(target_os = "windows"))]
298 command.args(["-c", cmd]);
299
300 command
301 .current_dir(dir)
302 .stdin(Stdio::null())
303 .stdout(Stdio::inherit())
304 .stderr(Stdio::inherit());
305
306 let status = command
307 .status()
308 .with_context(|| format!("failed to execute build command `{}`", cmd))?;
309 if !status.success() {
310 bail!("build command `{}` exited with {}", cmd, status);
311 }
312 Ok(())
313}
314
315fn stage_pack(args: &GuiPackDevArgs) -> Result<()> {
316 let assets_dir = args.output.join("gui").join("assets");
317 ensure_clean_dir(&assets_dir)?;
318 copy_dir_recursive(&args.dir, &assets_dir)?;
319
320 let manifest_path = args.output.join("gui").join("manifest.json");
321 if let Some(provided) = args.manifest.as_ref() {
322 fs::create_dir_all(
323 manifest_path
324 .parent()
325 .expect("manifest has a parent directory"),
326 )?;
327 fs::copy(provided, &manifest_path).with_context(|| {
328 format!(
329 "failed to copy manifest from {} to {}",
330 provided.display(),
331 manifest_path.display()
332 )
333 })?;
334 } else {
335 let manifest = generate_manifest(args)?;
336 fs::create_dir_all(manifest_path.parent().unwrap())?;
337 fs::write(&manifest_path, manifest)
338 .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
339 }
340
341 println!(
342 "Staged GUI dev pack at {} (assets from {})",
343 args.output.display(),
344 args.dir.display()
345 );
346 Ok(())
347}
348
349fn ensure_clean_dir(path: &Path) -> Result<()> {
350 if path.exists() {
351 let meta = fs::metadata(path)
352 .with_context(|| format!("failed to read existing path metadata {}", path.display()))?;
353 if meta.is_file() {
354 bail!("output path {} already exists as a file", path.display());
355 }
356 let mut entries =
358 fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))?;
359 if entries.next().is_some() {
360 bail!(
361 "output directory {} already exists and is not empty",
362 path.display()
363 );
364 }
365 return Ok(());
366 }
367 fs::create_dir_all(path)
368 .with_context(|| format!("failed to create output directory {}", path.display()))
369}
370
371fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
372 if !src.exists() {
373 bail!("source directory {} does not exist", src.display());
374 }
375 for entry in
376 fs::read_dir(src).with_context(|| format!("failed to read source {}", src.display()))?
377 {
378 let entry = entry?;
379 let file_type = entry.file_type()?;
380 let dest_path = dst.join(entry.file_name());
381 if file_type.is_dir() {
382 fs::create_dir_all(&dest_path)?;
383 copy_dir_recursive(&entry.path(), &dest_path)?;
384 } else if file_type.is_file() {
385 fs::create_dir_all(
386 dest_path
387 .parent()
388 .expect("destination file has a parent directory"),
389 )?;
390 fs::copy(entry.path(), &dest_path).with_context(|| {
391 format!(
392 "failed to copy {} to {}",
393 entry.path().display(),
394 dest_path.display()
395 )
396 })?;
397 }
398 }
399 Ok(())
400}
401
402fn generate_manifest(args: &GuiPackDevArgs) -> Result<String> {
403 let manifest = match args.kind {
404 GuiPackKind::Layout => json!({
405 "kind": "gui-layout",
406 "layout": {
407 "slots": ["header", "menu", "main", "footer"],
408 "entrypoint_html": format!("gui/assets/{}", args.entrypoint),
409 "spa": true,
410 "slot_selectors": {
411 "header": "#app-header",
412 "menu": "#app-menu",
413 "main": "#app-main",
414 "footer": "#app-footer"
415 }
416 }
417 }),
418 GuiPackKind::Feature => json!({
419 "kind": "gui-feature",
420 "routes": [{
421 "path": args.feature_route.as_deref().unwrap_or("/"),
422 "html": format!("gui/assets/{}", args.feature_html),
423 "authenticated": args.feature_authenticated,
424 }],
425 "digital_workers": [],
426 "fragments": []
427 }),
428 };
429 serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")
430}
431
432fn open_browser(url: &str) -> Result<()> {
433 #[cfg(target_os = "macos")]
434 let mut command = Command::new("open");
435 #[cfg(all(unix, not(target_os = "macos")))]
436 let mut command = Command::new("xdg-open");
437 #[cfg(target_os = "windows")]
438 let mut command = Command::new("cmd");
439
440 #[cfg(target_os = "windows")]
441 command.args(["/C", "start", url]);
442 #[cfg(not(target_os = "windows"))]
443 command.arg(url);
444
445 command
446 .stdin(Stdio::null())
447 .stdout(Stdio::null())
448 .stderr(Stdio::null());
449 let _ = command.status();
450 Ok(())
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use tempfile::TempDir;
457
458 #[test]
459 fn resolves_config_in_cwd_first() {
460 let temp = TempDir::new().unwrap();
461 let cwd = temp.path();
462 let primary = cwd.join("gui-dev.yaml");
463 fs::write(&primary, "tenant: test\nlayout_pack: ./layout").unwrap();
464
465 let _guard = CurrentDirGuard::new(cwd);
466 let path = resolve_config_path(None).unwrap().canonicalize().unwrap();
467 let primary = primary.canonicalize().unwrap();
468 assert_eq!(path, primary);
469 }
470
471 #[test]
472 fn stages_layout_manifest() {
473 let temp = TempDir::new().unwrap();
474 let src = temp.path().join("src");
475 let out = temp.path().join("out");
476 fs::create_dir_all(&src).unwrap();
477 fs::write(src.join("index.html"), "<html></html>").unwrap();
478
479 let args = GuiPackDevArgs {
480 dir: src.clone(),
481 output: out.clone(),
482 kind: GuiPackKind::Layout,
483 entrypoint: "index.html".to_string(),
484 manifest: None,
485 feature_route: None,
486 feature_html: "index.html".to_string(),
487 feature_authenticated: false,
488 build_cmd: None,
489 no_build: true,
490 };
491
492 stage_pack(&args).unwrap();
493 let manifest = fs::read_to_string(out.join("gui").join("manifest.json")).unwrap();
494 let value: serde_json::Value = serde_json::from_str(&manifest).unwrap();
495 assert_eq!(value["kind"], "gui-layout");
496 assert_eq!(value["layout"]["entrypoint_html"], "gui/assets/index.html");
497 assert!(out.join("gui").join("assets").join("index.html").exists());
498 }
499
500 struct CurrentDirGuard {
501 previous: PathBuf,
502 }
503
504 impl CurrentDirGuard {
505 fn new(path: &Path) -> Self {
506 let previous = std::env::current_dir().unwrap();
507 std::env::set_current_dir(path).unwrap();
508 CurrentDirGuard { previous }
509 }
510 }
511
512 impl Drop for CurrentDirGuard {
513 fn drop(&mut self) {
514 let _ = std::env::set_current_dir(&self.previous);
515 }
516 }
517}