Skip to main content

packc/cli/gui/
loveable_convert.rs

1#![forbid(unsafe_code)]
2
3use std::collections::BTreeSet;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, anyhow};
10use clap::{ArgAction, Parser, ValueEnum};
11use regex::Regex;
12use semver::Version;
13use serde::Serialize;
14use serde_json::json;
15use tempfile::TempDir;
16use tracing::info;
17use walkdir::WalkDir;
18
19use crate::build;
20
21#[derive(Debug, Clone, ValueEnum)]
22pub enum GuiPackKind {
23    Layout,
24    Auth,
25    Feature,
26    Skin,
27    Telemetry,
28}
29
30#[derive(Debug, Clone, Parser)]
31pub struct Args {
32    /// Logical GUI pack kind (maps to gui-<kind> in gui/manifest.json)
33    #[arg(long = "pack-kind", value_enum)]
34    pub pack_kind: GuiPackKind,
35
36    /// Pack id to embed in pack.yaml
37    #[arg(long = "id")]
38    pub pack_id: String,
39
40    /// Pack version (semver)
41    #[arg(long = "version")]
42    pub version: String,
43
44    /// Pack manifest kind (application|provider|infrastructure|library)
45    #[arg(long = "pack-manifest-kind", default_value = "application")]
46    pub pack_manifest_kind: String,
47
48    /// Pack publisher string (defaults to "greentic.gui")
49    #[arg(long = "publisher", default_value = "greentic.gui")]
50    pub publisher: String,
51
52    /// Optional display name for the GUI pack
53    #[arg(long)]
54    pub name: Option<String>,
55
56    /// Source repo URL to clone (mutually exclusive with --dir/--assets-dir)
57    #[arg(long = "repo-url", conflicts_with_all = ["dir", "assets_dir"])]
58    pub repo_url: Option<String>,
59
60    /// Branch to checkout (only used with --repo-url)
61    #[arg(long, requires = "repo_url", default_value = "main")]
62    pub branch: String,
63
64    /// Local source directory (mutually exclusive with --repo-url/--assets-dir)
65    #[arg(long, conflicts_with_all = ["repo_url", "assets_dir"])]
66    pub dir: Option<PathBuf>,
67
68    /// Prebuilt assets directory (skips install/build)
69    #[arg(long = "assets-dir", conflicts_with_all = ["repo_url", "dir"])]
70    pub assets_dir: Option<PathBuf>,
71
72    /// Subdirectory to build within the repo (monorepo support)
73    #[arg(long = "package-dir")]
74    pub package_dir: Option<PathBuf>,
75
76    /// Override install command (default: auto-detect pnpm/yarn/npm)
77    #[arg(long = "install-cmd")]
78    pub install_cmd: Option<String>,
79
80    /// Override build command (default: npm run build)
81    #[arg(long = "build-cmd")]
82    pub build_cmd: Option<String>,
83
84    /// Build output directory override
85    #[arg(long = "build-dir")]
86    pub build_dir: Option<PathBuf>,
87
88    /// Treat as SPA or MPA (override heuristic)
89    #[arg(long = "spa")]
90    pub spa: Option<bool>,
91
92    /// Route overrides, can repeat (path:html)
93    #[arg(long = "route", action = ArgAction::Append)]
94    pub routes: Vec<String>,
95
96    /// Routes convenience alias (comma-separated path:html entries)
97    #[arg(long = "routes", value_name = "ROUTES")]
98    pub routes_flat: Option<String>,
99
100    /// Output .gtpack path
101    #[arg(long = "out", alias = "output", value_name = "FILE")]
102    pub out: PathBuf,
103}
104
105struct ConvertOptions {
106    pack_kind: GuiPackKind,
107    pack_id: String,
108    version: Version,
109    pack_manifest_kind: String,
110    publisher: String,
111    name: Option<String>,
112    source: Source,
113    package_dir: Option<PathBuf>,
114    install_cmd: Option<String>,
115    build_cmd: Option<String>,
116    build_dir: Option<PathBuf>,
117    spa: Option<bool>,
118    routes: Vec<RouteOverride>,
119    out: PathBuf,
120}
121
122#[derive(Debug, Clone)]
123enum Source {
124    Repo { url: String, branch: String },
125    Dir(PathBuf),
126    AssetsDir(PathBuf),
127}
128
129#[derive(Debug, Clone)]
130struct RouteOverride {
131    path: String,
132    html: PathBuf,
133}
134
135#[derive(Debug, Serialize)]
136struct Summary {
137    pack_id: String,
138    version: String,
139    pack_kind: String,
140    gui_kind: String,
141    out: String,
142    routes: Vec<String>,
143    assets_copied: usize,
144}
145
146pub async fn handle(
147    args: Args,
148    json_out: bool,
149    runtime: &crate::runtime::RuntimeContext,
150) -> Result<()> {
151    let opts = ConvertOptions::try_from(args)?;
152    let staging = TempDir::new().context("failed to create staging dir")?;
153    let staging_root = staging.path();
154    let pack_root = staging_root
155        .canonicalize()
156        .context("failed to canonicalize staging dir")?;
157
158    let mut _clone_guard: Option<TempDir> = None;
159    let source_root = match &opts.source {
160        Source::Repo { url, branch } => {
161            runtime.require_online("git clone (packc gui loveable-convert --repo-url)")?;
162            let (temp, repo_dir) = clone_repo(url, branch)?;
163            let path = repo_dir
164                .canonicalize()
165                .context("failed to canonicalize cloned repo")?;
166            _clone_guard = Some(temp);
167            path
168        }
169        Source::Dir(p) => p.canonicalize().context("failed to canonicalize --dir")?,
170        Source::AssetsDir(p) => p
171            .canonicalize()
172            .context("failed to canonicalize --assets-dir")?,
173    };
174
175    let build_root = opts
176        .package_dir
177        .as_ref()
178        .map(|p| source_root.join(p))
179        .unwrap_or_else(|| source_root.clone());
180
181    let assets_dir = match opts.source {
182        Source::AssetsDir(_) => build_root,
183        _ => {
184            runtime.require_online("install/build GUI assets")?;
185            build_assets(&build_root, &opts)?
186        }
187    };
188
189    let assets_dir = assets_dir
190        .canonicalize()
191        .with_context(|| format!("failed to canonicalize assets dir {}", assets_dir.display()))?;
192
193    let staging_assets = staging_root.join("gui").join("assets");
194    let copied = copy_assets(&assets_dir, &staging_assets)?;
195
196    let gui_manifest = build_gui_manifest(&opts, &staging_assets)?;
197    write_gui_manifest(&pack_root.join("gui").join("manifest.json"), &gui_manifest)?;
198
199    write_pack_manifest(&opts, &pack_root, copied)?;
200
201    let build_opts = build::BuildOptions {
202        pack_dir: pack_root.clone(),
203        component_out: None,
204        manifest_out: pack_root.join("dist").join("manifest.cbor"),
205        sbom_out: None,
206        gtpack_out: Some(opts.out.clone()),
207        lock_path: pack_root.join("pack.lock.cbor"),
208        bundle: build::BundleMode::Cache,
209        dry_run: false,
210        secrets_req: None,
211        default_secret_scope: None,
212        allow_oci_tags: false,
213        require_component_manifests: false,
214        no_extra_dirs: false,
215        dev: false,
216        runtime: runtime.clone(),
217        skip_update: false,
218        allow_pack_schema: false,
219        validate_extension_refs: true,
220    };
221    build::run(&build_opts).await?;
222
223    if json_out {
224        let summary = Summary {
225            pack_id: opts.pack_id.clone(),
226            version: opts.version.to_string(),
227            pack_kind: opts.pack_manifest_kind.clone(),
228            gui_kind: gui_kind_string(&opts.pack_kind),
229            out: opts.out.display().to_string(),
230            routes: extract_route_strings(&gui_manifest),
231            assets_copied: copied,
232        };
233        println!("{}", serde_json::to_string_pretty(&summary)?);
234    } else {
235        info!(
236            pack_id = %opts.pack_id,
237            version = %opts.version,
238            gui_kind = gui_kind_string(&opts.pack_kind),
239            out = %opts.out.display(),
240            assets = copied,
241            "gui pack conversion complete"
242        );
243    }
244
245    Ok(())
246}
247
248impl TryFrom<Args> for ConvertOptions {
249    type Error = anyhow::Error;
250
251    fn try_from(args: Args) -> Result<Self> {
252        if args.assets_dir.is_some() && args.package_dir.is_some() {
253            return Err(anyhow!(
254                "--package-dir cannot be combined with --assets-dir (assets are already built)"
255            ));
256        }
257
258        let source = if let Some(url) = args.repo_url {
259            Source::Repo {
260                url,
261                branch: args.branch,
262            }
263        } else if let Some(dir) = args.dir {
264            Source::Dir(dir)
265        } else if let Some(assets) = args.assets_dir {
266            Source::AssetsDir(assets)
267        } else {
268            return Err(anyhow!(
269                "one of --repo-url, --dir, or --assets-dir must be provided"
270            ));
271        };
272
273        let routes = parse_routes(&args.routes, args.routes_flat.as_deref())?;
274        let version =
275            Version::parse(&args.version).context("invalid --version (expected semver)")?;
276        let out = if args.out.is_absolute() {
277            args.out
278        } else {
279            std::env::current_dir()
280                .context("failed to resolve current dir")?
281                .join(args.out)
282        };
283
284        Ok(Self {
285            pack_kind: args.pack_kind,
286            pack_id: args.pack_id,
287            version,
288            pack_manifest_kind: args.pack_manifest_kind.to_ascii_lowercase(),
289            publisher: args.publisher,
290            name: args.name,
291            source,
292            package_dir: args.package_dir,
293            install_cmd: args.install_cmd,
294            build_cmd: args.build_cmd,
295            build_dir: args.build_dir,
296            spa: args.spa,
297            routes,
298            out,
299        })
300    }
301}
302
303fn parse_routes(explicit: &[String], flat: Option<&str>) -> Result<Vec<RouteOverride>> {
304    let mut entries = Vec::new();
305
306    for raw in explicit {
307        entries.push(parse_route_entry(raw)?);
308    }
309
310    if let Some(flat_raw) = flat {
311        for part in flat_raw.split(',') {
312            if part.trim().is_empty() {
313                continue;
314            }
315            entries.push(parse_route_entry(part.trim())?);
316        }
317    }
318
319    Ok(entries)
320}
321
322fn parse_route_entry(raw: &str) -> Result<RouteOverride> {
323    let mut parts = raw.splitn(2, ':');
324    let path = parts
325        .next()
326        .ok_or_else(|| anyhow!("invalid route entry: {}", raw))?;
327    let html = parts
328        .next()
329        .ok_or_else(|| anyhow!("route entry must be path:html => {}", raw))?;
330
331    let path = path.trim().to_string();
332    if !path.starts_with('/') {
333        return Err(anyhow!("route path must start with '/': {}", path));
334    }
335
336    let html_path = PathBuf::from(html.trim());
337    if html_path.is_absolute() {
338        return Err(anyhow!(
339            "route html path must be relative to gui/assets: {}",
340            html
341        ));
342    }
343
344    Ok(RouteOverride {
345        path,
346        html: html_path,
347    })
348}
349
350fn clone_repo(url: &str, branch: &str) -> Result<(TempDir, PathBuf)> {
351    let temp = TempDir::new().context("failed to create temp dir for clone")?;
352    let target = temp.path().join("repo");
353
354    let status = Command::new("git")
355        .arg("clone")
356        .arg("--branch")
357        .arg(branch)
358        .arg("--depth")
359        .arg("1")
360        .arg(url)
361        .arg(&target)
362        .status()
363        .with_context(|| format!("failed to execute git clone for {}", url))?;
364
365    if !status.success() {
366        return Err(anyhow!("git clone failed with status {}", status));
367    }
368
369    Ok((temp, target))
370}
371
372fn build_assets(build_root: &Path, opts: &ConvertOptions) -> Result<PathBuf> {
373    let install_cmd = opts
374        .install_cmd
375        .clone()
376        .unwrap_or_else(|| default_install_command(build_root));
377    let build_cmd = opts
378        .build_cmd
379        .clone()
380        .unwrap_or_else(|| "npm run build".to_string());
381
382    run_shell(&install_cmd, build_root, "install dependencies")?;
383    run_shell(&build_cmd, build_root, "build GUI assets")?;
384
385    if let Some(dir) = &opts.build_dir {
386        return Ok(build_root.join(dir));
387    }
388
389    let dist = build_root.join("dist");
390    if dist.is_dir() {
391        return Ok(dist);
392    }
393
394    let build = build_root.join("build");
395    if build.is_dir() {
396        return Ok(build);
397    }
398
399    Err(anyhow!(
400        "unable to detect build output; specify --build-dir"
401    ))
402}
403
404fn default_install_command(root: &Path) -> String {
405    if root.join("pnpm-lock.yaml").exists() {
406        "pnpm install".to_string()
407    } else if root.join("yarn.lock").exists() {
408        "yarn install".to_string()
409    } else {
410        "npm install".to_string()
411    }
412}
413
414fn run_shell(cmd: &str, cwd: &Path, why: &str) -> Result<()> {
415    info!(command = %cmd, cwd = %cwd.display(), "running {}", why);
416    let status = Command::new("sh")
417        .arg("-c")
418        .arg(cmd)
419        .current_dir(cwd)
420        .status()
421        .with_context(|| format!("failed to run command: {}", cmd))?;
422
423    if !status.success() {
424        return Err(anyhow!("command failed ({}) with status {}", why, status));
425    }
426
427    Ok(())
428}
429
430fn copy_assets(src: &Path, dest: &Path) -> Result<usize> {
431    let mut count = 0usize;
432    for entry in WalkDir::new(src)
433        .into_iter()
434        .filter_map(Result::ok)
435        .filter(|e| e.file_type().is_file())
436    {
437        let rel = entry
438            .path()
439            .strip_prefix(src)
440            .expect("walkdir provided prefix");
441        let target = dest.join(rel);
442        if let Some(parent) = target.parent() {
443            fs::create_dir_all(parent)
444                .with_context(|| format!("failed to create {}", parent.display()))?;
445        }
446        fs::copy(entry.path(), &target).with_context(|| {
447            format!(
448                "failed to copy {} to {}",
449                entry.path().display(),
450                target.display()
451            )
452        })?;
453        count += 1;
454    }
455
456    Ok(count)
457}
458
459fn build_gui_manifest(opts: &ConvertOptions, assets_root: &Path) -> Result<serde_json::Value> {
460    let html_files = discover_html_files(assets_root);
461    if html_files.is_empty()
462        && !matches!(opts.pack_kind, GuiPackKind::Skin | GuiPackKind::Telemetry)
463    {
464        return Err(anyhow!(
465            "no HTML files found in assets dir {}",
466            assets_root.display()
467        ));
468    }
469
470    match opts.pack_kind {
471        GuiPackKind::Layout => {
472            let entry = select_entrypoint(&html_files);
473            let spa = opts.spa.unwrap_or_else(|| infer_spa(&html_files, &entry));
474            Ok(json!({
475                "kind": "gui-layout",
476                "layout": {
477                    "slots": ["header","menu","main","footer"],
478                    "entrypoint_html": format!("gui/assets/{}", to_unix_path(&entry)),
479                    "spa": spa,
480                    "slot_selectors": {
481                        "header": "#app-header",
482                        "menu": "#app-menu",
483                        "main": "#app-main",
484                        "footer": "#app-footer"
485                    }
486                }
487            }))
488        }
489        GuiPackKind::Auth => {
490            let routes = build_auth_routes(&html_files);
491            Ok(json!({
492                "kind": "gui-auth",
493                "routes": routes,
494                "ui_bindings": {
495                    "login_form_selector": "#login-form",
496                    "login_buttons": [
497                        { "provider": "microsoft", "selector": "#login-ms" },
498                        { "provider": "google", "selector": "#login-google" }
499                    ]
500                }
501            }))
502        }
503        GuiPackKind::Feature => {
504            let routes = build_feature_routes(opts, &html_files);
505            let workers = detect_workers(assets_root, &html_files)?;
506            Ok(json!({
507                "kind": "gui-feature",
508                "routes": routes,
509                "digital_workers": workers,
510                "fragments": []
511            }))
512        }
513        GuiPackKind::Skin => {
514            let theme_css_path = find_theme_css(assets_root);
515            let theme_css = theme_css_path.map(|p| format!("gui/assets/{}", to_unix_path(&p)));
516            Ok(json!({
517                "kind": "gui-skin",
518                "skin": {
519                    "theme_css": theme_css
520                }
521            }))
522        }
523        GuiPackKind::Telemetry => Ok(json!({
524            "kind": "gui-telemetry",
525            "telemetry": {}
526        })),
527    }
528}
529
530fn write_gui_manifest(path: &Path, value: &serde_json::Value) -> Result<()> {
531    if let Some(parent) = path.parent() {
532        fs::create_dir_all(parent)
533            .with_context(|| format!("failed to create {}", parent.display()))?;
534    }
535    let data = serde_json::to_vec_pretty(value)?;
536    fs::write(path, data).with_context(|| format!("failed to write {}", path.display()))
537}
538
539#[derive(Debug, Serialize)]
540struct PackManifestYaml<'a> {
541    pack_id: &'a str,
542    version: &'a str,
543    kind: &'a str,
544    publisher: &'a str,
545    #[serde(skip_serializing_if = "Vec::is_empty")]
546    components: Vec<()>,
547    #[serde(skip_serializing_if = "Vec::is_empty")]
548    dependencies: Vec<()>,
549    #[serde(skip_serializing_if = "Vec::is_empty")]
550    flows: Vec<()>,
551    assets: Vec<AssetEntry>,
552    #[serde(skip_serializing_if = "Option::is_none")]
553    name: Option<&'a str>,
554}
555
556#[derive(Debug, Serialize)]
557struct AssetEntry {
558    path: String,
559}
560
561fn write_pack_manifest(opts: &ConvertOptions, root: &Path, assets_copied: usize) -> Result<()> {
562    if assets_copied == 0 {
563        return Err(anyhow!("no assets copied; cannot build GUI pack"));
564    }
565
566    let mut assets = Vec::new();
567    assets.push(AssetEntry {
568        path: "gui/manifest.json".to_string(),
569    });
570
571    let assets_root = root.join("gui").join("assets");
572    for entry in WalkDir::new(&assets_root)
573        .into_iter()
574        .filter_map(Result::ok)
575        .filter(|e| e.file_type().is_file())
576    {
577        let rel = entry.path().strip_prefix(root).expect("walkdir prefix");
578        assets.push(AssetEntry {
579            path: to_unix_path(rel),
580        });
581    }
582
583    assets.sort_by(|a, b| a.path.cmp(&b.path));
584
585    let yaml = PackManifestYaml {
586        pack_id: &opts.pack_id,
587        version: &opts.version.to_string(),
588        kind: &opts.pack_manifest_kind,
589        publisher: &opts.publisher,
590        components: Vec::new(),
591        dependencies: Vec::new(),
592        flows: Vec::new(),
593        assets,
594        name: opts.name.as_deref(),
595    };
596
597    let manifest_path = root.join("pack.yaml");
598    let contents = serde_yaml_bw::to_string(&yaml)?;
599    fs::write(&manifest_path, contents)
600        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
601
602    Ok(())
603}
604
605fn discover_html_files(assets_root: &Path) -> Vec<PathBuf> {
606    WalkDir::new(assets_root)
607        .into_iter()
608        .filter_map(Result::ok)
609        .filter(|e| e.file_type().is_file())
610        .filter(|e| {
611            e.path()
612                .extension()
613                .map(|ext| ext == "html")
614                .unwrap_or(false)
615        })
616        .map(|e| {
617            e.path()
618                .strip_prefix(assets_root)
619                .unwrap_or(e.path())
620                .to_path_buf()
621        })
622        .collect()
623}
624
625fn select_entrypoint(html_files: &[PathBuf]) -> PathBuf {
626    html_files
627        .iter()
628        .find(|p| p.file_name().map(|n| n == "index.html").unwrap_or(false))
629        .cloned()
630        .unwrap_or_else(|| html_files[0].clone())
631}
632
633fn infer_spa(html_files: &[PathBuf], entry: &Path) -> bool {
634    let real_pages = html_files.iter().filter(|p| is_real_page(p)).count();
635    real_pages <= 1
636        && entry
637            .file_name()
638            .map(|n| n == "index.html")
639            .unwrap_or(false)
640}
641
642fn is_real_page(path: &Path) -> bool {
643    let ignore = ["404", "robots"];
644    path.extension().map(|ext| ext == "html").unwrap_or(false)
645        && !ignore
646            .iter()
647            .any(|ig| path.file_stem().and_then(OsStr::to_str) == Some(ig))
648}
649
650fn build_auth_routes(html_files: &[PathBuf]) -> Vec<serde_json::Value> {
651    let mut routes = Vec::new();
652    let login = html_files
653        .iter()
654        .find(|p| p.file_name().and_then(OsStr::to_str) == Some("login.html"))
655        .or_else(|| html_files.first());
656
657    if let Some(login) = login {
658        routes.push(json!({
659            "path": "/login",
660            "html": format!("gui/assets/{}", to_unix_path(login)),
661            "public": true
662        }));
663    }
664
665    routes
666}
667
668fn build_feature_routes(opts: &ConvertOptions, html_files: &[PathBuf]) -> Vec<serde_json::Value> {
669    if !opts.routes.is_empty() {
670        return opts
671            .routes
672            .iter()
673            .map(|r| {
674                json!({
675                    "path": r.path,
676                    "html": format!("gui/assets/{}", to_unix_path(&r.html)),
677                    "authenticated": true
678                })
679            })
680            .collect();
681    }
682
683    let entry = select_entrypoint(html_files);
684    let spa = opts.spa.unwrap_or_else(|| infer_spa(html_files, &entry));
685
686    let mut routes = Vec::new();
687    if spa {
688        routes.push(json!({
689            "path": "/",
690            "html": format!("gui/assets/{}", to_unix_path(&entry)),
691            "authenticated": true
692        }));
693        return routes;
694    }
695
696    for page in html_files.iter().filter(|p| is_real_page(p)) {
697        let route = route_from_path(page);
698        routes.push(json!({
699            "path": route,
700            "html": format!("gui/assets/{}", to_unix_path(page)),
701            "authenticated": true
702        }));
703    }
704
705    routes
706}
707
708fn route_from_path(path: &Path) -> String {
709    let mut parts = Vec::new();
710    if let Some(parent) = path.parent()
711        && parent != Path::new("")
712    {
713        parts.push(to_unix_path(parent));
714    }
715    if path.file_stem().and_then(OsStr::to_str) != Some("index") {
716        parts.push(
717            path.file_stem()
718                .and_then(OsStr::to_str)
719                .unwrap_or_default()
720                .to_string(),
721        );
722    }
723
724    let combined = parts.join("/");
725    if combined.is_empty() {
726        "/".to_string()
727    } else if combined.starts_with('/') {
728        combined
729    } else {
730        format!("/{}", combined)
731    }
732}
733
734fn detect_workers(assets_root: &Path, html_files: &[PathBuf]) -> Result<Vec<serde_json::Value>> {
735    let worker_re = Regex::new(r#"data-greentic-worker\s*=\s*"([^"]+)""#)?;
736    let slot_re = Regex::new(r#"data-greentic-worker-slot\s*=\s*"([^"]+)""#)?;
737    let mut seen = BTreeSet::new();
738    let mut workers = Vec::new();
739
740    for rel in html_files {
741        let abs = assets_root.join(rel);
742        let contents = fs::read_to_string(&abs).with_context(|| {
743            format!(
744                "failed to read HTML for worker detection: {}",
745                abs.display()
746            )
747        })?;
748
749        for caps in worker_re.captures_iter(&contents) {
750            let worker_id = caps
751                .get(1)
752                .map(|m| m.as_str().to_string())
753                .unwrap_or_default();
754            if worker_id.is_empty() || !seen.insert(worker_id.clone()) {
755                continue;
756            }
757
758            let slot = slot_re
759                .captures(&contents)
760                .and_then(|c| c.get(1))
761                .map(|m| m.as_str().to_string());
762
763            let selector = slot
764                .as_ref()
765                .map(|s| format!("#{}", s))
766                .unwrap_or_else(|| format!(r#"[data-greentic-worker="{}"]"#, worker_id));
767
768            workers.push(json!({
769                "id": worker_id.split('.').next_back().unwrap_or(&worker_id),
770                "worker_id": worker_id,
771                "attach": { "mode": "selector", "selector": selector },
772                "routes": ["/*"]
773            }));
774        }
775    }
776
777    Ok(workers)
778}
779
780fn extract_route_strings(manifest: &serde_json::Value) -> Vec<String> {
781    manifest
782        .get("routes")
783        .and_then(|r| r.as_array())
784        .map(|arr| {
785            arr.iter()
786                .filter_map(|r| {
787                    r.get("path")
788                        .and_then(|p| p.as_str())
789                        .map(|s| s.to_string())
790                })
791                .collect()
792        })
793        .unwrap_or_default()
794}
795
796fn to_unix_path(path: &Path) -> String {
797    path.iter()
798        .map(|p| p.to_string_lossy())
799        .collect::<Vec<_>>()
800        .join("/")
801}
802
803fn gui_kind_string(kind: &GuiPackKind) -> String {
804    match kind {
805        GuiPackKind::Layout => "gui-layout",
806        GuiPackKind::Auth => "gui-auth",
807        GuiPackKind::Feature => "gui-feature",
808        GuiPackKind::Skin => "gui-skin",
809        GuiPackKind::Telemetry => "gui-telemetry",
810    }
811    .to_string()
812}
813
814fn find_theme_css(assets_root: &Path) -> Option<PathBuf> {
815    let candidates = ["theme.css", "styles.css"];
816    for candidate in candidates {
817        let path = assets_root.join(candidate);
818        if path.exists() {
819            return Some(PathBuf::from(candidate));
820        }
821    }
822    None
823}