offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline add <package>`: add a registry package (and requires) to package.json.

use std::path::PathBuf;

use anyhow::{bail, Context, Result};
use clap::Args;
use offline_generator::package_json;
use offline_registry::Registry;
use serde_json::{json, Value};

use crate::output;

#[derive(Args)]
pub struct AddArgs {
    /// Package name (e.g. @offline-protocol/mesh-sdk or short name mesh-sdk)
    pub package: String,
    /// Project directory (defaults to current directory)
    #[arg(long, default_value = ".")]
    pub dir: PathBuf,
    /// Print what would change without writing
    #[arg(long)]
    pub dry_run: bool,
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

pub fn run(args: AddArgs) -> Result<()> {
    let registry = Registry::load_default()?;

    let full_name = if registry.package(&args.package).is_some() {
        args.package.clone()
    } else {
        let scoped = format!("@offline-protocol/{}", args.package);
        if registry.package(&scoped).is_some() {
            scoped
        } else {
            let hits = registry.search_packages(&args.package);
            let hint = hits
                .first()
                .map(|h| format!(". Did you mean `{}`?", h.name))
                .unwrap_or_default();
            bail!("unknown package `{}`{hint}", args.package);
        }
    };

    // `offline add` edits package.json; a PyPI, crates.io, Swift or release
    // package installs with its own tool.
    if let Some(manifest) = registry.package(&full_name) {
        if manifest.registry != "npm" {
            let install = registry
                .resolve_packages(
                    std::slice::from_ref(&full_name),
                    manifest.platforms.first().map(String::as_str).unwrap_or(""),
                )
                .map(|r| r.install_commands.join("; "))
                .unwrap_or_default();
            bail!(
                "`{full_name}` is not an npm package (it ships on {}), so `offline add` cannot \
add it to package.json. Install it with: {install}",
                manifest.registry
            );
        }
    }

    let pkg_path = args.dir.join("package.json");
    let raw = std::fs::read_to_string(&pkg_path)
        .with_context(|| format!("no package.json at {}", pkg_path.display()))?;
    let mut pkg: Value = serde_json::from_str(&raw)?;
    let deps = pkg
        .get("dependencies")
        .and_then(|d| d.as_object())
        .cloned()
        .unwrap_or_default();

    let platform = if deps.contains_key("react-native")
        || pkg
            .get("devDependencies")
            .and_then(|d| d.get("react-native"))
            .is_some()
    {
        "react-native"
    } else if deps.contains_key("next") {
        "nextjs"
    } else {
        "web"
    };

    let manifest = registry
        .package(&full_name)
        .expect("package resolved above");
    // Refuse pins that cannot install from the public registry.
    if manifest.version == "0.0.0" || manifest.status == "unpublished" {
        bail!(
            "`{full_name}` is not published (version {}, status {}) — \
cannot add it to package.json",
            manifest.version,
            manifest.status
        );
    }

    let resolution = registry.resolve_packages(std::slice::from_ref(&full_name), platform)?;

    let entries: Vec<(String, String)> = resolution
        .packages
        .iter()
        .filter(|rp| rp.registry == "npm")
        .map(|rp| (rp.name.clone(), format!("^{}", rp.version)))
        .collect();
    let added: Vec<String> = package_json::add_dependencies(&mut pkg, "dependencies", &entries)
        .map_err(anyhow::Error::msg)?
        .into_iter()
        .filter_map(|name| {
            entries
                .iter()
                .find(|(n, _)| *n == name)
                .map(|(n, range)| format!("{n}@{range}"))
        })
        .collect();

    if !args.dry_run && !added.is_empty() {
        // Keep key order, indentation and trailing newline: only the added
        // dependencies show up in the diff.
        let text = package_json::to_string_like(&raw, &pkg).map_err(anyhow::Error::msg)?;
        std::fs::write(&pkg_path, text)?;
    }

    if args.json {
        return output::json(&json!({
            "package": full_name,
            "platform": platform,
            "added": added,
            "dryRun": args.dry_run,
            "peerDependencies": resolution.peer_dependencies,
            "warnings": resolution.warnings,
            "notes": manifest.notes,
            "skills": manifest.skills,
        }));
    }

    if added.is_empty() {
        output::success(&format!(
            "{full_name} (and its requirements) already present"
        ));
    } else {
        output::success(&format!(
            "{} package.json: {}",
            if args.dry_run {
                "Would update"
            } else {
                "Updated"
            },
            added.join(", ")
        ));
        output::note("run `npm install` to fetch the new packages");
    }
    if !resolution.peer_dependencies.is_empty() {
        output::heading("Peer dependencies you must provide");
        for (name, range) in &resolution.peer_dependencies {
            output::item(name, range);
        }
    }
    for w in &resolution.warnings {
        output::warn(w);
    }
    if !manifest.notes.is_empty() {
        output::heading("Integration notes");
        for n in &manifest.notes {
            println!("  • {n}");
        }
    }
    if !manifest.skills.is_empty() {
        output::note(&format!(
            "read the skills before wiring it up: {}",
            manifest
                .skills
                .iter()
                .map(|s| format!("`offline skills show {s}`"))
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    Ok(())
}