Skip to main content

rpi_cli/
install_pi.rs

1//! Pi package installer (`rpi install-pi`).
2//!
3//! Pi packages are ordinary npm/git/local directories. The installer keeps
4//! the package under the rpi-owned `.rpi/packages` (or global agent store),
5//! installs production dependencies, and enables the resolved directory in
6//! settings so static resources and JS/TS extensions are loaded on startup.
7
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10
11#[derive(Debug, Clone)]
12struct Options {
13    spec: String,
14    global: bool,
15    force: bool,
16}
17
18pub fn run(args: &[String]) -> i32 {
19    let options = match parse_args(args) {
20        Ok(options) => options,
21        Err(message) if message == "help" => {
22            print_help();
23            return 0;
24        }
25        Err(message) => {
26            eprintln!("error: {message}");
27            print_help();
28            return 2;
29        }
30    };
31    let cwd = match std::env::current_dir() {
32        Ok(path) => path,
33        Err(error) => {
34            eprintln!("error: could not determine current directory: {error}");
35            return 1;
36        }
37    };
38    let destination = match destination(&cwd, &options) {
39        Ok(path) => path,
40        Err(error) => {
41            eprintln!("error: {error}");
42            return 1;
43        }
44    };
45    if let Err(error) = std::fs::create_dir_all(&destination) {
46        eprintln!("error: could not create {}: {error}", destination.display());
47        return 1;
48    }
49    let package_root = match install_spec(&cwd, &destination, &options) {
50        Ok(path) => path,
51        Err(error) => {
52            eprintln!("error: {error}");
53            return 1;
54        }
55    };
56
57    let package_root =
58        normalize_config_path(std::fs::canonicalize(&package_root).unwrap_or(package_root));
59    let mut settings = crate::settings::load_settings().unwrap_or_default();
60    let packages = settings.packages.get_or_insert_with(Vec::new);
61    let enabled = format!("file:{}", package_root.display());
62    if !packages.iter().any(|value| value == &enabled) {
63        packages.push(enabled);
64        if let Err(error) = crate::settings::save_settings(&settings) {
65            eprintln!("error: installed package but could not save settings: {error}");
66            return 1;
67        }
68    }
69    println!("installed Pi package {}", package_root.display());
70    println!("JS/TS extensions and static resources will load on the next start.");
71    println!("warning: Pi extensions execute JavaScript with the current user's permissions.");
72    0
73}
74
75fn parse_args(args: &[String]) -> Result<Options, String> {
76    let mut spec = None;
77    let mut global = false;
78    let mut force = false;
79    let mut i = 0;
80    while i < args.len() {
81        match args[i].as_str() {
82            "--help" | "-h" => return Err("help".into()),
83            "--global" | "-g" => global = true,
84            "--force" | "-f" => force = true,
85            value if value.starts_with('-') => {
86                return Err(format!("unknown install-pi option `{value}`"))
87            }
88            value => {
89                if spec.replace(value.to_string()).is_some() {
90                    return Err("install-pi accepts exactly one package spec".into());
91                }
92            }
93        }
94        i += 1;
95    }
96    Ok(Options {
97        spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
98        global,
99        force,
100    })
101}
102
103fn destination(cwd: &Path, options: &Options) -> Result<PathBuf, String> {
104    if options.global {
105        return crate::config::agent_dir()
106            .map(|path| path.join("packages"))
107            .map_err(|error| error.to_string());
108    }
109    Ok(cwd.join(".rpi/packages"))
110}
111
112fn install_spec(cwd: &Path, destination: &Path, options: &Options) -> Result<PathBuf, String> {
113    let spec = options.spec.as_str();
114    if spec.starts_with("git:") || spec.starts_with("https://") || spec.starts_with("http://") {
115        return install_git(cwd, destination, spec, options.force);
116    }
117    if spec.starts_with("npm:") || (!Path::new(spec).exists() && looks_like_npm(spec)) {
118        return install_npm(destination, spec, options.force);
119    }
120    install_local(cwd, destination, spec, options.force)
121}
122
123fn package_name(spec: &str) -> String {
124    let raw = spec.strip_prefix("npm:").unwrap_or(spec);
125    let raw = if raw.starts_with('@') {
126        raw.rfind('@')
127            .filter(|index| *index > 0)
128            .map(|index| &raw[..index])
129            .unwrap_or(raw)
130    } else {
131        raw.split('@').next().unwrap_or(raw)
132    };
133    raw.trim_start_matches('@')
134        .replace('/', "__")
135        .replace('\\', "__")
136}
137
138fn safe_name(spec: &str) -> String {
139    package_name(spec)
140        .chars()
141        .map(|ch| {
142            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
143                ch
144            } else {
145                '-'
146            }
147        })
148        .collect()
149}
150
151fn install_npm(destination: &Path, spec: &str, force: bool) -> Result<PathBuf, String> {
152    let name = safe_name(spec);
153    let stage = tempfile::tempdir()
154        .map_err(|error| format!("could not create npm staging dir: {error}"))?;
155    let mut command = Command::new(npm_program());
156    command
157        .args(["install", "--prefix"])
158        .arg(stage.path())
159        .args(["--omit=dev", "--no-package-lock"])
160        .arg(spec.strip_prefix("npm:").unwrap_or(spec))
161        .stdin(Stdio::inherit())
162        .stdout(Stdio::inherit())
163        .stderr(Stdio::inherit());
164    let status = command
165        .status()
166        .map_err(|error| format!("could not execute npm (install Node.js/npm first): {error}"))?;
167    if !status.success() {
168        return Err(format!("npm install exited with {status}"));
169    }
170    let installed = stage
171        .path()
172        .join("node_modules")
173        .join(npm_module_name(spec));
174    if !installed.is_dir() {
175        return Err(format!(
176            "npm installed `{spec}` but no package directory was found"
177        ));
178    }
179    let target = destination.join(&name);
180    replace_dir(&installed, &target, force)?;
181    install_production_dependencies(&target)?;
182    Ok(target)
183}
184
185/// Refresh an installed npm package in-place. The package root is expected to
186/// be the safe-name directory created by `install-pi`; replacing that directory
187/// preserves the settings entry while updating its contents.
188pub fn update_npm_package(root: &Path, name: &str) -> Result<PathBuf, String> {
189    let destination = root
190        .parent()
191        .ok_or_else(|| format!("package root has no parent: {}", root.display()))?;
192    install_npm(destination, &format!("npm:{name}@latest"), true)
193}
194
195#[derive(Debug, Clone)]
196struct UninstallOptions {
197    spec: String,
198    global: bool,
199}
200
201/// Remove a Pi package installed by `rpi install-pi` and disable its settings
202/// entry. Source directories outside rpi/pi package stores are never deleted.
203pub fn uninstall(args: &[String]) -> i32 {
204    let options = match parse_uninstall_args(args) {
205        Ok(options) => options,
206        Err(message) if message == "help" => {
207            print_uninstall_help();
208            return 0;
209        }
210        Err(message) => {
211            eprintln!("error: {message}");
212            print_uninstall_help();
213            return 2;
214        }
215    };
216    let cwd = match std::env::current_dir() {
217        Ok(path) => path,
218        Err(error) => {
219            eprintln!("error: could not determine current directory: {error}");
220            return 1;
221        }
222    };
223    let root = find_installed_root(&cwd, &options);
224    let removed_settings = match remove_settings_entries(&cwd, &options.spec, root.as_deref()) {
225        Ok(count) => count,
226        Err(error) => {
227            eprintln!("error: could not update package settings: {error}");
228            return 1;
229        }
230    };
231    let mut removed_files = false;
232    if let Some(root) = root {
233        if root.exists() && is_managed_package_root(&cwd, &root) {
234            match std::fs::remove_dir_all(&root) {
235                Ok(()) => {
236                    println!("removed Pi package {}", root.display());
237                    removed_files = true;
238                }
239                Err(error) => {
240                    eprintln!("error: could not remove {}: {error}", root.display());
241                    return 1;
242                }
243            }
244        } else if root.exists() {
245            println!(
246                "disabled Pi package at {}; source directory was left intact",
247                root.display()
248            );
249        }
250    }
251    if !removed_files && removed_settings == 0 {
252        println!("Pi package is not installed: {}", options.spec);
253    } else if removed_settings > 0 && !removed_files {
254        println!("disabled Pi package {}", options.spec);
255    }
256    0
257}
258
259fn parse_uninstall_args(args: &[String]) -> Result<UninstallOptions, String> {
260    let mut spec = None;
261    let mut global = false;
262    for arg in args {
263        match arg.as_str() {
264            "--help" | "-h" => return Err("help".into()),
265            "--global" | "-g" => global = true,
266            value if value.starts_with('-') => {
267                return Err(format!("unknown uninstall-pi option `{value}`"));
268            }
269            value => {
270                if spec.replace(value.to_string()).is_some() {
271                    return Err("uninstall-pi accepts exactly one package spec".into());
272                }
273            }
274        }
275    }
276    Ok(UninstallOptions {
277        spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
278        global,
279    })
280}
281
282fn find_installed_root(cwd: &Path, options: &UninstallOptions) -> Option<PathBuf> {
283    let raw = options.spec.strip_prefix("file:").unwrap_or(&options.spec);
284    let package_key = package_dir_name(&options.spec);
285    let mut candidates = Vec::new();
286    let direct = PathBuf::from(raw);
287    if direct.is_absolute() || raw.starts_with('.') {
288        candidates.push(if direct.is_absolute() {
289            direct
290        } else {
291            cwd.join(direct)
292        });
293    }
294    let add_store = |store: PathBuf, candidates: &mut Vec<PathBuf>| {
295        candidates.push(store.join(&package_key));
296    };
297    if !options.global {
298        add_store(cwd.join(".rpi/packages"), &mut candidates);
299        add_store(cwd.join(".pi/packages"), &mut candidates);
300    }
301    if let Ok(agent) = crate::config::agent_dir() {
302        add_store(agent.join("packages"), &mut candidates);
303    }
304    if let Some(home) = dirs::home_dir() {
305        add_store(home.join(".pi/agent/packages"), &mut candidates);
306    }
307    let wanted_name = package_name(&options.spec);
308    for package in crate::packages::discover_from_settings(cwd).packages {
309        if package.name == wanted_name
310            || safe_name(&package.name) == package_key
311            || package
312                .root
313                .file_name()
314                .and_then(|name| name.to_str())
315                .is_some_and(|name| name == package_key)
316        {
317            candidates.push(package.root);
318        }
319    }
320    candidates
321        .into_iter()
322        .find_map(|path| std::fs::canonicalize(path).ok())
323}
324
325fn package_dir_name(spec: &str) -> String {
326    let raw = spec.strip_prefix("git:").unwrap_or(spec);
327    if raw.starts_with("http://") || raw.starts_with("https://") {
328        return safe_name(
329            raw.trim_end_matches('/')
330                .rsplit('/')
331                .next()
332                .unwrap_or("package"),
333        );
334    }
335    safe_name(raw)
336}
337
338fn is_managed_package_root(cwd: &Path, root: &Path) -> bool {
339    let mut stores = vec![cwd.join(".rpi/packages"), cwd.join(".pi/packages")];
340    if let Ok(agent) = crate::config::agent_dir() {
341        stores.push(agent.join("packages"));
342    }
343    if let Some(home) = dirs::home_dir() {
344        stores.push(home.join(".pi/agent/packages"));
345    }
346    stores.iter().any(|store| {
347        let store = std::fs::canonicalize(store).unwrap_or_else(|_| store.clone());
348        root.starts_with(store)
349    })
350}
351
352fn remove_settings_entries(cwd: &Path, spec: &str, root: Option<&Path>) -> Result<usize, String> {
353    let mut settings = crate::settings::load_settings().unwrap_or_default();
354    let Some(packages) = settings.packages.as_mut() else {
355        return Ok(0);
356    };
357    let wanted_name = package_name(spec);
358    let before = packages.len();
359    packages.retain(|entry| {
360        if entry == spec || package_name(entry) == wanted_name && !entry.starts_with('.') {
361            return false;
362        }
363        let matches_root = root.is_some_and(|root| {
364            crate::packages::resolve_package(cwd, entry)
365                .ok()
366                .and_then(|package| std::fs::canonicalize(package.root).ok())
367                .is_some_and(|candidate| candidate == root)
368        });
369        !matches_root
370    });
371    let removed = before - packages.len();
372    if removed == 0 {
373        return Ok(0);
374    }
375    if packages.is_empty() {
376        settings.packages = None;
377    }
378    crate::settings::save_settings(&settings).map_err(|error| error.to_string())?;
379    Ok(removed)
380}
381
382fn npm_module_name(spec: &str) -> String {
383    let raw = spec.strip_prefix("npm:").unwrap_or(spec);
384    if raw.starts_with('@') {
385        raw.rfind('@')
386            .filter(|index| *index > 0)
387            .map(|index| raw[..index].to_string())
388            .unwrap_or_else(|| raw.to_string())
389    } else {
390        raw.split('@').next().unwrap_or(raw).to_string()
391    }
392}
393
394fn install_git(cwd: &Path, destination: &Path, spec: &str, force: bool) -> Result<PathBuf, String> {
395    let url = spec.strip_prefix("git:").unwrap_or(spec);
396    let (url, revision) = url
397        .split_once('@')
398        .map_or((url, None), |(u, r)| (u, Some(r)));
399    let name = safe_name(
400        url.trim_end_matches('/')
401            .rsplit('/')
402            .next()
403            .unwrap_or("package"),
404    );
405    let target = destination.join(name);
406    if target.exists() {
407        if !force {
408            return Err(format!(
409                "{} already exists; use --force to replace it",
410                target.display()
411            ));
412        }
413        std::fs::remove_dir_all(&target)
414            .map_err(|error| format!("could not replace {}: {error}", target.display()))?;
415    }
416    let status = Command::new("git")
417        .args(["clone", "--depth", "1"])
418        .arg(url)
419        .arg(&target)
420        .current_dir(cwd)
421        .stdin(Stdio::inherit())
422        .stdout(Stdio::inherit())
423        .stderr(Stdio::inherit())
424        .status()
425        .map_err(|error| format!("could not execute git: {error}"))?;
426    if !status.success() {
427        return Err(format!("git clone exited with {status}"));
428    }
429    if let Some(revision) = revision {
430        let status = Command::new("git")
431            .args(["fetch", "--depth", "1", "origin", revision])
432            .current_dir(&target)
433            .stdin(Stdio::inherit())
434            .stdout(Stdio::inherit())
435            .stderr(Stdio::inherit())
436            .status()
437            .map_err(|error| format!("could not fetch git revision: {error}"))?;
438        if !status.success() {
439            return Err(format!("git fetch exited with {status}"));
440        }
441        let status = Command::new("git")
442            .args(["checkout", revision])
443            .current_dir(&target)
444            .stdin(Stdio::inherit())
445            .stdout(Stdio::inherit())
446            .stderr(Stdio::inherit())
447            .status()
448            .map_err(|error| format!("could not checkout git revision: {error}"))?;
449        if !status.success() {
450            return Err(format!("git checkout exited with {status}"));
451        }
452    }
453    install_production_dependencies(&target)?;
454    Ok(target)
455}
456
457fn install_local(
458    cwd: &Path,
459    destination: &Path,
460    spec: &str,
461    force: bool,
462) -> Result<PathBuf, String> {
463    let source = PathBuf::from(spec);
464    let source = if source.is_absolute() {
465        source
466    } else {
467        cwd.join(source)
468    };
469    if !source.is_dir() {
470        return Err(format!(
471            "local package directory not found: {}",
472            source.display()
473        ));
474    }
475    let name = std::fs::read_to_string(source.join("package.json"))
476        .ok()
477        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
478        .and_then(|value| {
479            value
480                .get("name")
481                .and_then(|name| name.as_str())
482                .map(safe_name)
483        })
484        .unwrap_or_else(|| safe_name(spec));
485    let target = destination.join(name);
486    if std::fs::canonicalize(&source).ok() == std::fs::canonicalize(&target).ok() {
487        return Ok(target);
488    }
489    replace_dir(&source, &target, force)?;
490    install_production_dependencies(&target)?;
491    Ok(target)
492}
493
494fn replace_dir(source: &Path, target: &Path, force: bool) -> Result<(), String> {
495    if target.exists() {
496        if !force {
497            return Err(format!(
498                "{} already exists; use --force to replace it",
499                target.display()
500            ));
501        }
502        std::fs::remove_dir_all(target)
503            .map_err(|error| format!("could not replace {}: {error}", target.display()))?;
504    }
505    std::fs::create_dir_all(target.parent().unwrap_or(target))
506        .map_err(|error| error.to_string())?;
507    copy_dir(source, target).map_err(|error| format!("could not copy package: {error}"))
508}
509
510fn copy_dir(source: &Path, target: &Path) -> std::io::Result<()> {
511    std::fs::create_dir_all(target)?;
512    for entry in std::fs::read_dir(source)? {
513        let entry = entry?;
514        if matches!(entry.file_name().to_str(), Some("node_modules" | ".git")) {
515            continue;
516        }
517        let from = entry.path();
518        let to = target.join(entry.file_name());
519        if from.is_dir() {
520            copy_dir(&from, &to)?;
521        } else {
522            std::fs::copy(from, to)?;
523        }
524    }
525    Ok(())
526}
527
528fn install_production_dependencies(root: &Path) -> Result<(), String> {
529    if !root.join("package.json").is_file() {
530        return Ok(());
531    }
532    let status = Command::new(npm_program())
533        .args(["install", "--omit=dev", "--no-package-lock"])
534        .current_dir(root)
535        .stdin(Stdio::inherit())
536        .stdout(Stdio::inherit())
537        .stderr(Stdio::inherit())
538        .status()
539        .map_err(|error| format!("could not execute npm for package dependencies: {error}"))?;
540    if !status.success() {
541        return Err(format!("npm install exited with {status}"));
542    }
543    let mut host_packages = Vec::new();
544    if let Ok(text) = std::fs::read_to_string(root.join("package.json")) {
545        if let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text) {
546            if let Some(peers) = manifest
547                .get("peerDependencies")
548                .and_then(|value| value.as_object())
549            {
550                for (name, version) in peers {
551                    let version = version.as_str().unwrap_or("*");
552                    host_packages.push(if version == "*" {
553                        name.to_string()
554                    } else {
555                        format!("{name}@{version}")
556                    });
557                }
558                if peers.contains_key("@earendil-works/pi-coding-agent") {
559                    // Pi's coding-agent package imports this host runtime from
560                    // its UI barrel, although it is not declared as a peer.
561                    host_packages.push("@earendil-works/pi-server".to_string());
562                }
563            }
564        }
565    }
566    if !host_packages.is_empty() {
567        let mut command = Command::new(npm_program());
568        command
569            .args(["install", "--omit=dev", "--no-package-lock", "--no-save"])
570            .args(&host_packages)
571            .current_dir(root)
572            .stdin(Stdio::inherit())
573            .stdout(Stdio::inherit())
574            .stderr(Stdio::inherit());
575        let status = command
576            .status()
577            .map_err(|error| format!("could not install Pi host dependencies: {error}"))?;
578        if !status.success() {
579            return Err(format!("npm host dependency install exited with {status}"));
580        }
581    }
582    Ok(())
583}
584
585fn npm_program() -> &'static str {
586    if cfg!(windows) {
587        "npm.cmd"
588    } else {
589        "npm"
590    }
591}
592
593fn normalize_config_path(path: PathBuf) -> PathBuf {
594    if cfg!(windows) {
595        let text = path.to_string_lossy();
596        if let Some(stripped) = text.strip_prefix("\\\\?\\") {
597            return PathBuf::from(stripped);
598        }
599    }
600    path
601}
602
603fn looks_like_npm(spec: &str) -> bool {
604    !spec.contains(std::path::MAIN_SEPARATOR) && !spec.contains('/') && !spec.ends_with(".json")
605}
606
607pub fn print_help() {
608    println!("Usage: rpi install-pi [options] <spec>\n\nInstall a Pi npm/git/local package.\n\nSpecs:\n  npm:@scope/package@1.0.0\n  git:github.com/user/repo@v1\n  ./local-package\n\nOptions:\n  --global, -g  Install into ~/.rpi/agent/packages\n  --force, -f   Replace an existing package\n  --help, -h    Show this help");
609}
610
611fn print_uninstall_help() {
612    println!("Usage: rpi uninstall-pi [options] <spec>\n\nRemove an installed Pi npm/git/local package and disable it in settings.\n\nSpecs:\n  npm:@scope/package\n  git:github.com/user/repo\n  ./local-package\n\nOptions:\n  --global, -g  Remove from ~/.rpi/agent/packages\n  --help, -h    Show this help\n\nAliases:\n  rpi uninstall pi <spec>");
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn parses_install_specs_and_flags() {
621        let options = parse_args(&["--global".into(), "npm:@scope/pkg@1.2.3".into()]).unwrap();
622        assert!(options.global);
623        assert_eq!(options.spec, "npm:@scope/pkg@1.2.3");
624        assert_eq!(package_name(&options.spec), "scope__pkg");
625        assert_eq!(npm_module_name(&options.spec), "@scope/pkg");
626    }
627
628    #[test]
629    fn rejects_missing_spec() {
630        assert!(parse_args(&[]).is_err());
631    }
632
633    #[test]
634    fn parses_uninstall_specs_and_flags() {
635        let options = parse_uninstall_args(&["--global".into(), "npm:@scope/pkg".into()]).unwrap();
636        assert!(options.global);
637        assert_eq!(options.spec, "npm:@scope/pkg");
638    }
639
640    #[test]
641    fn rejects_multiple_uninstall_specs() {
642        assert!(parse_uninstall_args(&["a".into(), "b".into()]).is_err());
643    }
644}