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 {
pub package: String,
#[arg(long, default_value = ".")]
pub dir: PathBuf,
#[arg(long)]
pub dry_run: bool,
#[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);
}
};
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");
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() {
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(())
}