did-hosting-control 0.10.1

DID Hosting Control Plane — Unified management UI and service registry
use std::path::Path;

fn main() {
    // ---- Storage backend feature-gate validation ----
    let store_features = [
        cfg!(feature = "store-fjall"),
        cfg!(feature = "store-redis"),
        cfg!(feature = "store-dynamodb"),
        cfg!(feature = "store-firestore"),
        cfg!(feature = "store-cosmosdb"),
    ];
    let enabled_count = store_features.iter().filter(|&&f| f).count();

    if enabled_count == 0 {
        println!(
            "cargo:warning=No storage backend feature enabled! Enable one of: store-fjall, store-redis, store-dynamodb, store-firestore, store-cosmosdb"
        );
    }
    if enabled_count > 1 {
        println!(
            "cargo:warning=Multiple storage backend features enabled — only one will be used at runtime."
        );
    }

    // ---- Secret store feature-gate validation ----
    let secret_features = [
        cfg!(feature = "keyring"),
        cfg!(feature = "aws-secrets"),
        cfg!(feature = "gcp-secrets"),
    ];
    let secret_count = secret_features.iter().filter(|&&f| f).count();
    if secret_count > 1 {
        println!(
            "cargo:warning=Multiple secret store features enabled — only one will be used at runtime."
        );
    }

    // ---- UI build (when ui feature is enabled) ----
    #[cfg(feature = "ui")]
    build_ui();
}

#[cfg(feature = "ui")]
const MIN_NODE_MAJOR: u32 = 20;

#[cfg(feature = "ui")]
fn mtime(path: &Path) -> Option<std::time::SystemTime> {
    std::fs::metadata(path).ok()?.modified().ok()
}

#[cfg(feature = "ui")]
fn build_ui() {
    let ui_dir = Path::new("../did-hosting-ui");
    // The embedded bundle lives inside this crate (`ui-dist/`) so it is carried
    // by the published tarball; `npm run build:web` writes straight into it.
    // See the `include` list in Cargo.toml and `frontend.rs`.
    let dist_dir = Path::new("ui-dist");

    // No UI workspace — we are building from the published `.crate` (or any
    // checkout of this crate alone), where `ui-dist` is pre-bundled. Nothing to
    // build, and crucially neither Node nor npm is required.
    if !ui_dir.is_dir() {
        if !dist_dir.join("index.html").exists() {
            // rust-embed's derive fails outright on a missing folder, so leave
            // an empty one behind: the binary builds and `static_handler`
            // already answers 404 when `index.html` is absent.
            std::fs::create_dir_all(dist_dir)
                .unwrap_or_else(|e| panic!("failed to create {}: {e}", dist_dir.display()));
            println!(
                "cargo:warning=No management UI bundle found in did-hosting-control/ui-dist and \
                 the ../did-hosting-ui workspace is absent — UI routes will return 404. Build \
                 from the workspace checkout, or disable the `ui` feature."
            );
        }
        return;
    }

    // Track UI source files for rebuild detection
    for dir in &["app", "components", "lib"] {
        let path = ui_dir.join(dir);
        if path.is_dir() {
            track_dir_recursive(&path);
        }
    }
    for file in &[
        "package.json",
        "package-lock.json",
        "tsconfig.json",
        "app.json",
        "App.tsx",
        "index.ts",
    ] {
        let path = ui_dir.join(file);
        if path.exists() {
            println!("cargo:rerun-if-changed={}", path.display());
        }
    }

    // Rebuild if dist is missing or stale (any tracked source file is newer)
    let needs_build = if let Ok(dist_meta) = std::fs::metadata(dist_dir.join("index.html")) {
        let dist_mtime = dist_meta
            .modified()
            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
        any_source_newer(ui_dir, dist_mtime)
    } else {
        true // dist doesn't exist
    };

    if !needs_build {
        return;
    }

    // Only now that a bundler run is actually due does Node become a hard
    // requirement — a fresh `ui-dist` builds fine without it.
    check_node_version();

    // Install deps when `node_modules` is missing OR out of sync with the
    // lockfile. npm writes `node_modules/.package-lock.json` on a successful
    // install; if the committed `package-lock.json` is newer (e.g. a
    // dependency bump just landed via `git pull`), the existing tree is stale
    // and must be resynced before building — otherwise `expo export` runs
    // against the old packages. Installing only on a missing `node_modules`
    // silently used stale deps despite the `rerun-if-changed` on the lockfile.
    let node_modules = ui_dir.join("node_modules");
    let needs_install = !node_modules.exists()
        || match (
            mtime(&ui_dir.join("package-lock.json")),
            mtime(&node_modules.join(".package-lock.json")),
        ) {
            (Some(lock), Some(installed)) => lock > installed,
            // No install marker (older npm) but node_modules exists: assume
            // in sync rather than reinstalling on every build.
            _ => false,
        };
    if needs_install {
        run_npm(ui_dir, &["install", "--prefer-offline"]);
    }

    // Build. `expo export` refuses to write into a non-empty output directory,
    // so clear the previous bundle first — a failed build fails the crate build
    // too, so there is nothing to preserve.
    let _ = std::fs::remove_dir_all(dist_dir);
    run_npm(ui_dir, &["run", "build:web"]);
}

/// Fail fast with a clear error if `node` is missing or older than the Metro
/// / Expo floor. Otherwise a bundler failure deep in `expo export` surfaces as
/// `TypeError: configs.toReversed is not a function` — a known ES2023 symptom
/// of Node < 20.
#[cfg(feature = "ui")]
fn check_node_version() {
    let output = match std::process::Command::new("node").arg("--version").output() {
        Ok(o) => o,
        Err(e) => panic!(
            "failed to invoke `node --version`: {e}. The `ui` feature requires \
             Node.js {MIN_NODE_MAJOR}+ to build the management UI. Install it \
             (e.g. via nvm) or disable the `ui` feature."
        ),
    };
    if !output.status.success() {
        panic!(
            "`node --version` exited non-zero. Install Node.js {MIN_NODE_MAJOR}+ \
             or disable the `ui` feature."
        );
    }
    let raw = String::from_utf8_lossy(&output.stdout);
    let version = raw.trim().trim_start_matches('v');
    let major: u32 = version
        .split('.')
        .next()
        .and_then(|s| s.parse().ok())
        .unwrap_or_else(|| {
            panic!("could not parse Node.js version from `node --version` output: {raw:?}")
        });
    if major < MIN_NODE_MAJOR {
        panic!(
            "Node.js {major} detected; the `ui` feature requires Node.js \
             {MIN_NODE_MAJOR}+ (Metro/Expo depend on Array.prototype.toReversed, \
             added in Node 20). Upgrade Node — e.g. `nvm install 22 && nvm use \
             22` — or disable the `ui` feature."
        );
    }
}

#[cfg(feature = "ui")]
fn run_npm(cwd: &Path, args: &[&str]) {
    let status = std::process::Command::new("npm")
        .current_dir(cwd)
        .args(args)
        .status()
        .unwrap_or_else(|e| panic!("failed to run npm {}: {e}", args.join(" ")));
    if !status.success() {
        panic!("npm {} failed with {status}", args.join(" "));
    }
}

/// Returns true if any source file under the UI dirs is newer than `threshold`.
#[cfg(feature = "ui")]
fn any_source_newer(ui_dir: &Path, threshold: std::time::SystemTime) -> bool {
    for dir_name in &["app", "components", "lib"] {
        let path = ui_dir.join(dir_name);
        if path.is_dir() && dir_has_newer(&path, threshold) {
            return true;
        }
    }
    for file in &["package.json", "tsconfig.json", "app.json"] {
        let path = ui_dir.join(file);
        if let Ok(meta) = std::fs::metadata(&path)
            && let Ok(mtime) = meta.modified()
            && mtime > threshold
        {
            return true;
        }
    }
    false
}

#[cfg(feature = "ui")]
fn dir_has_newer(dir: &Path, threshold: std::time::SystemTime) -> bool {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return false;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            let name = path.file_name().unwrap_or_default().to_string_lossy();
            if name.starts_with('.') || name == "node_modules" {
                continue;
            }
            if dir_has_newer(&path, threshold) {
                return true;
            }
        } else if let Ok(meta) = std::fs::metadata(&path)
            && let Ok(mtime) = meta.modified()
            && mtime > threshold
        {
            return true;
        }
    }
    false
}

#[cfg(feature = "ui")]
fn track_dir_recursive(dir: &Path) {
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                // Skip node_modules and hidden dirs
                let name = path.file_name().unwrap_or_default().to_string_lossy();
                if name.starts_with('.') || name == "node_modules" {
                    continue;
                }
                track_dir_recursive(&path);
            } else {
                println!("cargo:rerun-if-changed={}", path.display());
            }
        }
    }
}