Skip to main content

greentic_dev/
gui_dev.rs

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        // Allow reusing existing directory; do not delete but ensure it is empty to avoid stale files.
357        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}