node-app-build 7.5.16

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Where the host finds a UI-only app.
//!
//! The minimal core registers and serves non-standalone apps only from its
//! apps directory (`APT_APPS_DIR`, `<monorepo>/modules` on a lane): boot and
//! the app reconcile read manifests there, and the HTTP server mounts app
//! bundles from there when it starts. The dev dir is read for standalone apps
//! only, and `app.dev_load` is gone (#2939). A UI-only app has no process to
//! load, so `node-app dev` links `<apps dir>/<name>` to its staged copy, the
//! way bootstrap-apps links `modules/friends-stage`.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

/// What answers to a UI-only app's name in the host's apps directory.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AppsDirEntry {
    /// `<apps dir>/<name>` links to one of this session's staged copies.
    Staged(PathBuf),
    /// `<apps dir>/<name>` links to the app's source directory, so the host
    /// serves what the UI build leaves there.
    Source(PathBuf),
    /// Another copy answers to the name; the host reads it, not the staged one.
    Shadowed { path: PathBuf, reason: String },
}

/// Link `<apps_dir>/<name>` to `staged[0]`. `staged` holds this session's
/// staged copies, one per instance; a link to any of them, or to `source`,
/// is kept. A dangling link, or one to another dev dir's copy
/// (`.../dev-apps/<name>`), is replaced. Anything else is left alone.
pub(crate) fn link_staged_copy(
    apps_dir: &Path,
    name: &str,
    staged: &[PathBuf],
    source: &Path,
) -> Result<AppsDirEntry> {
    let first = staged.first().context("no staged copy to link")?;
    let target = first
        .canonicalize()
        .with_context(|| format!("resolve {}", first.display()))?;
    if let Some(other) = other_dir_declaring(apps_dir, name) {
        return Ok(AppsDirEntry::Shadowed {
            path: other,
            reason: format!("it also declares '{name}'"),
        });
    }
    let link = apps_dir.join(name);
    match std::fs::symlink_metadata(&link) {
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => return Err(error).with_context(|| format!("stat {}", link.display())),
        Ok(meta) if !meta.file_type().is_symlink() => {
            return Ok(AppsDirEntry::Shadowed {
                path: link,
                reason: "a directory the host loads instead".into(),
            });
        }
        Ok(_) => {
            let current = std::fs::read_link(&link)
                .with_context(|| format!("read link {}", link.display()))?;
            if let Ok(resolved) = link.canonicalize() {
                if staged.iter().any(|copy| copy.canonicalize().ok() == Some(resolved.clone())) {
                    return Ok(AppsDirEntry::Staged(link));
                }
                if source.canonicalize().ok() == Some(resolved) {
                    return Ok(AppsDirEntry::Source(link));
                }
                if !is_staged_copy(&current, name) {
                    return Ok(AppsDirEntry::Shadowed {
                        path: link,
                        reason: format!("it links to {}", current.display()),
                    });
                }
            }
            std::fs::remove_file(&link).with_context(|| format!("remove {}", link.display()))?;
        }
    }
    std::os::unix::fs::symlink(&target, &link)
        .with_context(|| format!("link {} -> {}", link.display(), target.display()))?;
    Ok(AppsDirEntry::Staged(link))
}

/// Whether the host started after `link` appeared. It reads apps and mounts
/// bundles at boot, and creates its control socket then; the socket's mtime
/// does not move with IPC. `None` when either time is unknown.
pub(crate) fn host_booted_after(link: &Path, socket: &Path) -> Option<bool> {
    let linked = std::fs::symlink_metadata(link).ok()?.modified().ok()?;
    let booted = std::fs::metadata(socket).ok()?.modified().ok()?;
    Some(booted >= linked)
}

/// One line on where the host reads a UI-only app and what it takes to see it.
/// `entry` is `None` when this host's apps directory is unknown.
pub(crate) fn describe(
    name: &str,
    staged: &Path,
    entry: Option<&Result<AppsDirEntry>>,
    socket: &Path,
) -> String {
    let staged = staged.display();
    let (link, via) = match entry {
        None => {
            return format!(
                "âš  UI-only '{name}' staged in {staged}; this host reads apps only from its apps \
                 directory (APT_APPS_DIR), which node-app dev does not know here: link \
                 <APT_APPS_DIR>/{name} to the staged copy and restart the host"
            )
        }
        Some(Err(error)) => {
            return format!(
                "âš  UI-only '{name}' staged in {staged}, but not linked where the host reads apps: {error:#}"
            )
        }
        Some(Ok(AppsDirEntry::Shadowed { path, reason })) => {
            return format!(
                "âš  UI-only '{name}' staged in {staged}, but the host reads {} ({reason}), not this copy",
                path.display()
            )
        }
        Some(Ok(AppsDirEntry::Staged(link))) => (link, format!("from {}", link.display())),
        Some(Ok(AppsDirEntry::Source(link))) => {
            (link, format!("from the source directory through {}", link.display()))
        }
    };
    match host_booted_after(link, socket) {
        Some(true) => format!("✓ UI-only '{name}' staged; the host serves it {via}, nothing to start — reload the page"),
        Some(false) => format!(
            "✓ UI-only '{name}' staged and linked at {}; the host started before the link, so restart the lane to list and serve it",
            link.display()
        ),
        None => format!("✓ UI-only '{name}' staged; the host lists and serves it {via} once it boots"),
    }
}

fn is_staged_copy(target: &Path, name: &str) -> bool {
    target.file_name().is_some_and(|file| file == name)
        && target
            .parent()
            .and_then(Path::file_name)
            .is_some_and(|dir| dir == "dev-apps")
}

/// Another entry of `apps_dir` whose manifest has this name: the host keeps
/// one row per name, so two copies would race for it.
fn other_dir_declaring(apps_dir: &Path, name: &str) -> Option<PathBuf> {
    std::fs::read_dir(apps_dir)
        .ok()?
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.file_name().is_some_and(|file| file != name))
        .find(|path| declared_name(path).as_deref() == Some(name))
}

fn declared_name(dir: &Path) -> Option<String> {
    let raw = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
    let manifest: serde_json::Value = serde_json::from_str(&raw).ok()?;
    manifest.get("name")?.as_str().map(str::to_owned)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::symlink;
    use std::time::{Duration, SystemTime};

    fn app_dir(path: &Path, name: &str) -> PathBuf {
        std::fs::create_dir_all(path).unwrap();
        std::fs::write(path.join("manifest.json"), format!(r#"{{"name":"{name}"}}"#)).unwrap();
        path.to_path_buf()
    }

    struct Layout {
        _tmp: tempfile::TempDir,
        apps: PathBuf,
        alice: PathBuf,
        bob: PathBuf,
        source: PathBuf,
    }

    fn layout() -> Layout {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().canonicalize().unwrap();
        let apps = root.join("node/modules");
        std::fs::create_dir_all(&apps).unwrap();
        Layout {
            alice: app_dir(&root.join("alice/dev-apps/agent-chat"), "agent-chat"),
            bob: app_dir(&root.join("bob/dev-apps/agent-chat"), "agent-chat"),
            source: app_dir(&root.join("node-app-agent/chat-widget"), "agent-chat"),
            apps,
            _tmp: tmp,
        }
    }

    #[test]
    fn links_the_first_staged_copy_and_keeps_a_link_to_any_of_them() {
        let l = layout();
        let staged = [l.alice.clone(), l.bob.clone()];
        let link = l.apps.join("agent-chat");

        assert_eq!(
            link_staged_copy(&l.apps, "agent-chat", &staged, &l.source).unwrap(),
            AppsDirEntry::Staged(link.clone())
        );
        assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
        // Bob's pass keeps alice's link rather than flipping it.
        let bob_first = [l.bob.clone(), l.alice.clone()];
        link_staged_copy(&l.apps, "agent-chat", &bob_first, &l.source).unwrap();
        assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
    }

    #[test]
    fn replaces_a_dangling_link_or_another_lanes_staged_copy() {
        let l = layout();
        let link = l.apps.join("agent-chat");
        let other_lane = app_dir(&l.apps.join("../../other-lane/dev-apps/agent-chat"), "agent-chat");
        symlink(&other_lane, &link).unwrap();

        link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap();
        assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);

        std::fs::remove_file(&link).unwrap();
        symlink(l.apps.join("gone"), &link).unwrap();
        link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap();
        assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
    }

    #[test]
    fn leaves_what_it_did_not_make_and_says_so() {
        let l = layout();
        let link = l.apps.join("agent-chat");

        // A link to the source, as bootstrap-apps makes for friends-stage.
        symlink(&l.source, &link).unwrap();
        assert_eq!(
            link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap(),
            AppsDirEntry::Source(link.clone())
        );

        // A link to some other checkout.
        std::fs::remove_file(&link).unwrap();
        let checkout = app_dir(&l.apps.join("../elsewhere"), "agent-chat");
        symlink(&checkout, &link).unwrap();
        assert!(matches!(
            link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap(),
            AppsDirEntry::Shadowed { .. }
        ));
        assert_eq!(std::fs::read_link(&link).unwrap(), checkout);

        // A real directory (a bootstrap clone).
        std::fs::remove_file(&link).unwrap();
        app_dir(&link, "agent-chat");
        assert!(matches!(
            link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap(),
            AppsDirEntry::Shadowed { .. }
        ));
        assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
    }

    #[test]
    fn refuses_to_add_a_second_copy_of_a_name_the_host_already_loads() {
        let l = layout();
        let clone = app_dir(&l.apps.join("agent-chat-ui"), "agent-chat");

        let entry = link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap();

        assert_eq!(
            entry,
            AppsDirEntry::Shadowed { path: clone, reason: "it also declares 'agent-chat'".into() }
        );
        assert!(!l.apps.join("agent-chat").exists());
    }

    #[test]
    fn describe_tells_a_host_that_booted_before_the_link_to_restart() {
        let l = layout();
        let socket = l.apps.join("../control.sock");
        std::fs::write(&socket, "").unwrap();
        let entry = link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source);
        let set_socket_mtime = |at: SystemTime| {
            std::fs::File::options().write(true).open(&socket).unwrap().set_modified(at).unwrap();
        };

        set_socket_mtime(SystemTime::now() - Duration::from_secs(60));
        let line = describe("agent-chat", &l.alice, Some(&entry), &socket);
        assert!(line.contains("restart the lane"), "{line}");

        set_socket_mtime(SystemTime::now() + Duration::from_secs(60));
        let line = describe("agent-chat", &l.alice, Some(&entry), &socket);
        assert!(line.contains("the host serves it from"), "{line}");

        let line = describe("agent-chat", &l.alice, Some(&entry), &l.apps.join("absent.sock"));
        assert!(line.contains("once it boots"), "{line}");
        let line = describe("agent-chat", &l.alice, None, &socket);
        assert!(line.contains("APT_APPS_DIR"), "{line}");
    }
}