Skip to main content

rpi_cli/
packages.rs

1//! Project-local Rust extension packages.
2//!
3//! A package is a built cdylib plus optional `skills`, `prompts`, and `themes`
4//! directories under `.rpi/packages/<name>`. The registry is deliberately
5//! separate from the global native package registry.
6
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use serde::{Deserialize, Serialize};
11
12const REGISTRY: &str = "packages.json";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(deny_unknown_fields)]
16pub struct ProjectPackage {
17    pub name: String,
18    pub version: String,
19    pub source: String,
20    pub cargo_package: String,
21    #[serde(default)]
22    pub artifacts: Vec<String>,
23}
24
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct PackageRegistry {
27    #[serde(default)]
28    packages: Vec<ProjectPackage>,
29}
30
31/// Extension management deliberately exposes only the shared update operation.
32pub fn run_extension(args: &[String]) -> i32 {
33    match args.first().map(String::as_str) {
34        None | Some("--help") | Some("-h") => {
35            print_extension_help();
36            0
37        }
38        Some("update") => update(&args[1..]),
39        Some(other) => {
40            eprintln!("error: unknown extension command `{other}`");
41            print_extension_help();
42            2
43        }
44    }
45}
46
47pub fn run(args: &[String]) -> i32 {
48    match args.first().map(String::as_str) {
49        None | Some("--help") | Some("-h") => {
50            print_help();
51            0
52        }
53        Some("link") => link(&args[1..]),
54        Some("list") => list(&args[1..]),
55        Some("update") => update(&args[1..]),
56        Some("unlink") => unlink(&args[1..]),
57        Some(other) => {
58            eprintln!("error: unknown package command `{other}`");
59            print_help();
60            2
61        }
62    }
63}
64
65fn link(args: &[String]) -> i32 {
66    let mut source = None;
67    let mut cargo_package = None;
68    let mut name = None;
69    let mut release = true;
70    let mut force = false;
71    let mut locked = false;
72    let mut i = 0;
73    while i < args.len() {
74        match args[i].as_str() {
75            "--path" => {
76                i += 1;
77                source = value(args, i, "--path");
78                if source.is_none() {
79                    return 2;
80                }
81            }
82            "--package" | "-P" => {
83                i += 1;
84                cargo_package = value(args, i, "--package");
85                if cargo_package.is_none() {
86                    return 2;
87                }
88            }
89            "--name" => {
90                i += 1;
91                name = value(args, i, "--name");
92                if name.is_none() {
93                    return 2;
94                }
95            }
96            "--debug" => release = false,
97            "--locked" => locked = true,
98            "--force" | "-f" => force = true,
99            "--help" | "-h" => {
100                print_link_help();
101                return 0;
102            }
103            value if value.starts_with('-') => {
104                eprintln!("error: unknown link option `{value}`");
105                return 2;
106            }
107            value => {
108                if name.replace(value.to_string()).is_some() {
109                    eprintln!("error: link accepts one package name");
110                    return 2;
111                }
112            }
113        }
114        i += 1;
115    }
116    let project_root = match std::env::current_dir() {
117        Ok(p) => p,
118        Err(e) => {
119            eprintln!("error: {e}");
120            return 1;
121        }
122    };
123    let native_root = project_root.join(".rpi");
124    let _lease = match crate::resource_management::shared_lease(&native_root) {
125        Ok(lease) => lease,
126        Err(error) => {
127            eprintln!("error: {error}");
128            return 1;
129        }
130    };
131    if let Err(error) = std::fs::create_dir_all(&native_root) {
132        eprintln!("error: could not create {}: {error}", native_root.display());
133        return 1;
134    }
135    let package_lock = match std::fs::OpenOptions::new()
136        .create(true)
137        .read(true)
138        .write(true)
139        .open(native_root.join("packages.lock"))
140    {
141        Ok(file) => file,
142        Err(error) => {
143            eprintln!("error: could not open project package lock: {error}");
144            return 1;
145        }
146    };
147    if let Err(error) = fs2::FileExt::try_lock_exclusive(&package_lock) {
148        eprintln!("error: another project package update is running: {error}");
149        return 1;
150    }
151    let package_registry = match read_registry(&project_root) {
152        Ok(registry) => registry,
153        Err(error) => {
154            eprintln!("error: {error}");
155            return 1;
156        }
157    };
158    // Check an explicit identity before Cargo can execute a build script.
159    if let Some(name) = &name {
160        if let Err(error) =
161            crate::resource_management::legacy_unowned(&native_root, "package", name)
162        {
163            eprintln!("error: {error}");
164            return 1;
165        }
166    }
167    let source = source
168        .map(|p| project_root.join(p))
169        .unwrap_or_else(|| project_root.clone());
170    let metadata = match cargo_metadata(&source) {
171        Ok(m) => m,
172        Err(e) => {
173            eprintln!("error: {e}");
174            return 1;
175        }
176    };
177    let selected = match select_package(&metadata, cargo_package.as_deref(), &source) {
178        Ok(p) => p,
179        Err(e) => {
180            eprintln!("error: {e}");
181            return 1;
182        }
183    };
184    let package_name = name.unwrap_or_else(|| selected.name.clone());
185    let previous_package = package_registry
186        .packages
187        .iter()
188        .find(|package| package.name == package_name);
189    if !valid_name(&package_name) {
190        eprintln!("error: invalid package name `{package_name}`");
191        return 2;
192    }
193    if let Err(error) =
194        crate::resource_management::legacy_unowned(&native_root, "package", &package_name)
195    {
196        eprintln!("error: {error}");
197        return 1;
198    }
199    eprintln!("package: building {}...", selected.name);
200    let artifact = match build(&selected, release, locked) {
201        Ok(p) => p,
202        Err(e) => {
203            eprintln!("error: {e}");
204            return 1;
205        }
206    };
207    let root = project_root.join(".rpi/packages").join(&package_name);
208    let ext = root.join("extensions");
209    let file = match artifact.file_name().and_then(|n| n.to_str()) {
210        Some(n) => n.to_string(),
211        None => {
212            eprintln!("error: invalid artifact name");
213            return 1;
214        }
215    };
216    let target_file = if previous_package.is_some() || (force && ext.join(&file).exists()) {
217        match crate::install::versioned_artifact_name(&file, &selected.version, &ext) {
218            Ok(name) => name,
219            Err(error) => {
220                eprintln!("error: could not allocate package artifact: {error}");
221                return 1;
222            }
223        }
224    } else {
225        file.clone()
226    };
227    let target = ext.join(&target_file);
228    let tmp = ext.join(format!(".{target_file}.tmp"));
229    // Cargo can execute build scripts. Recheck the addressed write paths after
230    // it returns, before creating directories or removing/copying any artifact.
231    let mut guarded_targets = vec![target.clone(), tmp.clone()];
232    if target_file != file {
233        // A legacy first-install artifact may not be present in the old
234        // registry ledger. Keep it inside the validation boundary as well.
235        let legacy_target = ext.join(&file);
236        let legacy_tmp = ext.join(format!(".{file}.tmp"));
237        if legacy_tmp.try_exists().unwrap_or(false) {
238            eprintln!("error: could not stage package artifact: staging path already exists");
239            return 1;
240        }
241        guarded_targets.push(legacy_target);
242        guarded_targets.push(legacy_tmp);
243    }
244    if let Some(previous) = previous_package {
245        // An upgrade publishes a new artifact, but the old artifact and its
246        // staging name still belong to this transaction's safety boundary.
247        // Validate them too so a stale reparse point or pre-existing staging
248        // file cannot be silently bypassed by the new versioned name.
249        for artifact in &previous.artifacts {
250            let old_target = ext.join(artifact);
251            let old_tmp = ext.join(format!(".{artifact}.tmp"));
252            if old_tmp.try_exists().unwrap_or(false) {
253                eprintln!("error: could not stage package artifact: staging path already exists");
254                return 1;
255            }
256            guarded_targets.push(old_target);
257            guarded_targets.push(old_tmp);
258        }
259    }
260    if let Err(error) =
261        crate::resource_management::legacy_unowned(&native_root, "package", &package_name).and_then(
262            |_| crate::resource_management::legacy_unowned_targets(&native_root, &guarded_targets),
263        )
264    {
265        eprintln!("error: {error}");
266        return 1;
267    }
268    if target.exists() && !force {
269        eprintln!("error: package already exists; use --force");
270        return 1;
271    }
272    if let Err(e) = std::fs::create_dir_all(&ext) {
273        eprintln!("error: could not create {}: {e}", ext.display());
274        return 1;
275    }
276    // Never follow/truncate a pre-existing fixed staging path (including a
277    // hardlink). Only clean up a temporary file this invocation created.
278    let mut staged = match std::fs::OpenOptions::new()
279        .write(true)
280        .create_new(true)
281        .open(&tmp)
282    {
283        Ok(file) => file,
284        Err(error) => {
285            eprintln!("error: could not stage package artifact: {error}");
286            return 1;
287        }
288    };
289    let copied = std::fs::File::open(&artifact)
290        .and_then(|mut input| std::io::copy(&mut input, &mut staged))
291        .and_then(|_| staged.sync_all());
292    drop(staged);
293    if let Err(error) = copied {
294        let _ = std::fs::remove_file(&tmp);
295        eprintln!("error: could not stage package artifact: {error}");
296        return 1;
297    }
298    if force {
299        if let Err(e) = std::fs::remove_file(&target) {
300            if e.kind() != std::io::ErrorKind::NotFound {
301                let _ = std::fs::remove_file(&tmp);
302                eprintln!("error: could not replace existing package artifact: {e}");
303                return 1;
304            }
305        }
306    }
307    if let Err(e) = std::fs::rename(&tmp, &target) {
308        let _ = std::fs::remove_file(&tmp);
309        eprintln!("error: could not activate package: {e}");
310        return 1;
311    }
312    let mut registry = match read_registry(&project_root) {
313        Ok(r) => r,
314        Err(e) => {
315            eprintln!("error: {e}");
316            return 1;
317        }
318    };
319    registry.packages.retain(|p| p.name != package_name);
320    registry.packages.push(ProjectPackage {
321        name: package_name.clone(),
322        version: selected.version,
323        source: source.display().to_string(),
324        cargo_package: selected.name,
325        artifacts: vec![target_file],
326    });
327    registry.packages.sort_by(|a, b| a.name.cmp(&b.name));
328    if let Err(e) = write_registry(&project_root, &registry) {
329        eprintln!("error: {e}");
330        return 1;
331    }
332    println!("linked project package `{package_name}`; run `rpi` in this directory to load it");
333    0
334}
335
336fn list(args: &[String]) -> i32 {
337    if !args.is_empty() && args[0] != "--help" && args[0] != "-h" {
338        eprintln!("error: unexpected list argument");
339        return 2;
340    }
341    let cwd = match std::env::current_dir() {
342        Ok(p) => p,
343        Err(e) => {
344            eprintln!("error: {e}");
345            return 1;
346        }
347    };
348    match read_registry(&cwd) {
349        Ok(r) => {
350            if r.packages.is_empty() {
351                println!("no project packages");
352            } else {
353                for p in r.packages {
354                    println!("{} {} [{}]", p.name, p.version, p.source);
355                }
356            }
357            0
358        }
359        Err(e) => {
360            eprintln!("error: {e}");
361            1
362        }
363    }
364}
365
366fn update(args: &[String]) -> i32 {
367    let cwd = match std::env::current_dir() {
368        Ok(path) => path,
369        Err(error) => {
370            eprintln!("error: {error}");
371            return 1;
372        }
373    };
374    update_at(&cwd, args, update_global_package)
375}
376
377fn update_at(
378    cwd: &Path,
379    args: &[String],
380    mut update_global: impl FnMut(&crate::install::InstalledNativePackage, &[String]) -> bool,
381) -> i32 {
382    let mut name = None;
383    let mut link_args = Vec::new();
384    let mut i = 0;
385    while i < args.len() {
386        match args[i].as_str() {
387            "--debug" | "--locked" => link_args.push(args[i].clone()),
388            "--help" | "-h" => {
389                print_update_help();
390                return 0;
391            }
392            value if value.starts_with('-') => {
393                eprintln!("error: unknown update option `{value}`");
394                return 2;
395            }
396            value => {
397                if name.replace(value.to_string()).is_some() {
398                    eprintln!("error: update accepts at most one extension name");
399                    return 2;
400                }
401            }
402        }
403        i += 1;
404    }
405
406    let registry = match read_registry(cwd) {
407        Ok(r) => r,
408        Err(e) => {
409            eprintln!("error: {e}");
410            return 1;
411        }
412    };
413    let resource_owned =
414        match crate::resource_management::resource_owned_names(&cwd.join(".rpi"), "package") {
415            Ok(names) => names,
416            Err(error) => {
417                eprintln!("error: {error}");
418                return 1;
419            }
420        };
421    if name
422        .as_ref()
423        .is_some_and(|name| resource_owned.contains(name))
424    {
425        eprintln!("error: resource-owned Package; use rpi resource update package NAME --scope project --path PATH");
426        return 1;
427    }
428    let packages = registry
429        .packages
430        .iter()
431        .filter(|package| !resource_owned.contains(&package.name))
432        .filter(|package| {
433            name.as_deref()
434                .map_or(true, |wanted| wanted == package.name)
435        })
436        .cloned()
437        .collect::<Vec<_>>();
438
439    // Global extensions installed by `rpi install` are also packages. This is
440    // the path used by crates.io extensions such as rpi-im-message; delegate to
441    // the existing transactional installer so artifact and registry updates
442    // remain atomic and version resolution stays in one place.
443    let global_packages = match crate::install::installed_native_packages_strict() {
444        Ok(packages) => packages
445            .into_iter()
446            .filter(|package| {
447                package.supports_registry_update()
448                    && name
449                        .as_deref()
450                        .map_or(true, |wanted| wanted == package.name)
451            })
452            .collect::<Vec<_>>(),
453        Err(error) => {
454            eprintln!("error: {error}");
455            return 1;
456        }
457    };
458    if let Some(wanted) = name.as_deref() {
459        let project_found = registry
460            .packages
461            .iter()
462            .any(|package| package.name == wanted);
463        let global_found = global_packages.iter().any(|package| package.name == wanted);
464        if !project_found && !global_found {
465            eprintln!("error: extension `{wanted}` is not installed");
466            return 1;
467        }
468    }
469
470    if packages.is_empty() && global_packages.is_empty() {
471        println!("no project or global extensions to update");
472        return 0;
473    }
474
475    let mut status = 0;
476    for package in packages {
477        let package_name = package.name.clone();
478        eprintln!("extension: updating project extension `{package_name}`...");
479        let mut args = vec![
480            "--path".to_string(),
481            package.source,
482            "--package".to_string(),
483            package.cargo_package,
484            "--name".to_string(),
485            package.name,
486            "--force".to_string(),
487        ];
488        args.extend(link_args.iter().cloned());
489        if link(&args) != 0 {
490            status = 1;
491        }
492    }
493    for package in global_packages {
494        eprintln!("extension: updating global extension `{}`...", package.name);
495        if !update_global(&package, &link_args) {
496            status = 1;
497        }
498    }
499
500    status
501}
502
503fn global_update_args(
504    package: &crate::install::InstalledNativePackage,
505    args: &[String],
506) -> Vec<String> {
507    let mut install_args = vec!["install".into(), package.name.clone()];
508    if let Some(source) = &package.prebuilt {
509        if source.custom_repository {
510            install_args.extend(["--github".into(), source.repository.clone()]);
511        }
512    } else {
513        install_args.push("--build".into());
514    }
515    if package.prebuilt.is_none() && args.iter().any(|arg| arg == "--locked") {
516        install_args.push("--locked".into());
517    }
518    install_args
519}
520
521fn update_global_package(
522    package: &crate::install::InstalledNativePackage,
523    args: &[String],
524) -> bool {
525    let name = &package.name;
526    let executable = match std::env::current_exe() {
527        Ok(path) => path,
528        Err(error) => {
529            eprintln!("error: could not resolve rpi executable: {error}");
530            return false;
531        }
532    };
533    let mut command = std::process::Command::new(executable);
534    command.args(global_update_args(package, args));
535    match command.status() {
536        Ok(exit) if exit.success() => true,
537        Ok(exit) => {
538            eprintln!("error: updating global extension `{name}` failed with {exit}");
539            false
540        }
541        Err(error) => {
542            eprintln!("error: could not run rpi install for `{name}`: {error}");
543            false
544        }
545    }
546}
547
548fn unlink(args: &[String]) -> i32 {
549    let Some(name) = args.first() else {
550        eprintln!("error: missing package name");
551        return 2;
552    };
553    if args.len() != 1 || !valid_name(name) {
554        eprintln!("error: invalid package name or arguments");
555        return 2;
556    }
557    let cwd = match std::env::current_dir() {
558        Ok(p) => p,
559        Err(e) => {
560            eprintln!("error: {e}");
561            return 1;
562        }
563    };
564    let native_root = cwd.join(".rpi");
565    let _lease = match crate::resource_management::mutation_lease(&native_root) {
566        Ok(lease) => lease,
567        Err(error) => {
568            eprintln!("error: {error}");
569            return 1;
570        }
571    };
572    if let Err(error) = crate::resource_management::legacy_unowned(&native_root, "package", name) {
573        eprintln!("error: {error}");
574        return 1;
575    }
576    let mut registry = match read_registry(&cwd) {
577        Ok(r) => r,
578        Err(e) => {
579            eprintln!("error: {e}");
580            return 1;
581        }
582    };
583    if !registry.packages.iter().any(|p| p.name == *name) {
584        eprintln!("error: project package `{name}` is not installed");
585        return 1;
586    }
587    let dir = cwd.join(".rpi/packages").join(name);
588    if let Err(e) = std::fs::remove_dir_all(&dir) {
589        eprintln!("error: could not remove package: {e}");
590        return 1;
591    }
592    registry.packages.retain(|p| p.name != *name);
593    if let Err(e) = write_registry(&cwd, &registry) {
594        eprintln!("error: {e}");
595        return 1;
596    }
597    println!("unlinked project package `{name}`");
598    0
599}
600
601#[derive(Debug, Clone)]
602struct CargoPackage {
603    name: String,
604    version: String,
605    manifest: PathBuf,
606    target: String,
607}
608#[derive(Deserialize)]
609struct Metadata {
610    packages: Vec<MetadataPackage>,
611}
612#[derive(Deserialize)]
613struct MetadataPackage {
614    name: String,
615    version: String,
616    manifest_path: PathBuf,
617    targets: Vec<Target>,
618}
619#[derive(Deserialize)]
620struct Target {
621    name: String,
622    crate_types: Vec<String>,
623}
624
625fn cargo_metadata(cwd: &Path) -> Result<Metadata, String> {
626    let out = Command::new("cargo")
627        .args(["metadata", "--format-version", "1", "--no-deps"])
628        .current_dir(cwd)
629        .stdin(Stdio::null())
630        .output()
631        .map_err(|e| format!("could not execute cargo metadata: {e}"))?;
632    if !out.status.success() {
633        return Err(format!(
634            "cargo metadata failed:\n{}",
635            String::from_utf8_lossy(&out.stderr).trim()
636        ));
637    }
638    serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid cargo metadata output: {e}"))
639}
640fn select_package(
641    m: &Metadata,
642    requested: Option<&str>,
643    cwd: &Path,
644) -> Result<CargoPackage, String> {
645    let mut found = Vec::new();
646    for p in &m.packages {
647        if requested.is_some_and(|n| n != p.name) {
648            continue;
649        }
650        for t in &p.targets {
651            if t.crate_types.iter().any(|k| k == "cdylib") {
652                found.push(CargoPackage {
653                    name: p.name.clone(),
654                    version: p.version.clone(),
655                    manifest: p.manifest_path.clone(),
656                    target: t.name.clone(),
657                });
658            }
659        }
660    }
661    if requested.is_none() {
662        // Cargo returns normalized manifest paths. --path may contain `..` or
663        // a Windows extended-length prefix; compare normalized paths on both
664        // sides when choosing among several provider packages in a workspace.
665        let source = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
666        let mut local = found
667            .iter()
668            .filter(|p| {
669                p.manifest.parent().is_some_and(|root| {
670                    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
671                    source.starts_with(root)
672                })
673            })
674            .cloned()
675            .collect::<Vec<_>>();
676        if local.len() == 1 {
677            return Ok(local.remove(0));
678        }
679    }
680    match found.len() {
681        1 => Ok(found.remove(0)),
682        0 => Err("no cdylib package found; use --package to select one".into()),
683        _ => Err(format!(
684            "multiple cdylib packages found: {}; use --package",
685            found
686                .iter()
687                .map(|p| p.name.as_str())
688                .collect::<Vec<_>>()
689                .join(", ")
690        )),
691    }
692}
693fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
694    let mut c = Command::new("cargo");
695    c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
696        "--package",
697        &p.name,
698        "--message-format=json-render-diagnostics",
699    ]);
700    if release {
701        c.arg("--release");
702    }
703    if locked {
704        c.arg("--locked");
705    }
706    c.stdout(Stdio::piped()).stderr(Stdio::piped());
707    let out = c
708        .output()
709        .map_err(|e| format!("could not execute cargo build: {e}"))?;
710    let mut artifact = None;
711    for line in String::from_utf8_lossy(&out.stdout).lines() {
712        if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
713            if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
714                && v.get("target")
715                    .and_then(|v| v.get("name"))
716                    .and_then(|v| v.as_str())
717                    == Some(&p.target)
718            {
719                artifact = v
720                    .get("filenames")
721                    .and_then(|v| v.as_array())
722                    .into_iter()
723                    .flatten()
724                    .filter_map(|v| v.as_str())
725                    .map(PathBuf::from)
726                    .find(|p| is_dynamic(p));
727            }
728        }
729    }
730    eprint!("{}", String::from_utf8_lossy(&out.stderr));
731    if !out.status.success() {
732        return Err(format!("cargo build exited with {}", out.status));
733    }
734    artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
735}
736fn is_dynamic(p: &Path) -> bool {
737    matches!(
738        p.extension()
739            .and_then(|x| x.to_str())
740            .map(|x| x.to_ascii_lowercase())
741            .as_deref(),
742        Some("dll" | "so" | "dylib")
743    )
744}
745pub(crate) fn valid_name(s: &str) -> bool {
746    !s.is_empty()
747        && s != "."
748        && s != ".."
749        && s.bytes()
750            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
751}
752fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
753    match args.get(i) {
754        Some(v) if !v.starts_with('-') => Some(v.clone()),
755        _ => {
756            eprintln!("error: {flag} requires a value");
757            None
758        }
759    }
760}
761fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
762    let path = cwd.join(".rpi").join(REGISTRY);
763    let registry = match std::fs::read_to_string(path) {
764        Ok(s) => serde_json::from_str(&s)
765            .map_err(|e| format!("invalid project package registry: {e}"))?,
766        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(PackageRegistry::default()),
767        Err(e) => return Err(e.to_string()),
768    };
769    validate_registry(&registry)?;
770    Ok(registry)
771}
772
773fn validate_registry(registry: &PackageRegistry) -> Result<(), String> {
774    let mut names = std::collections::HashSet::new();
775    for package in &registry.packages {
776        if !valid_name(&package.name) || !names.insert(package.name.to_ascii_lowercase()) {
777            return Err(format!(
778                "invalid or duplicate project package name `{}`",
779                package.name
780            ));
781        }
782        let mut artifacts = std::collections::HashSet::new();
783        for artifact in &package.artifacts {
784            let path = Path::new(artifact);
785            if path.components().count() != 1
786                || path.file_name().and_then(|value| value.to_str()) != Some(artifact)
787                || !is_dynamic(path)
788                || !artifacts.insert(artifact.to_ascii_lowercase())
789            {
790                return Err(format!(
791                    "invalid or duplicate project package artifact `{artifact}`"
792                ));
793            }
794        }
795    }
796    Ok(())
797}
798fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
799    let dir = cwd.join(".rpi");
800    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
801    let path = dir.join(REGISTRY);
802    let tmp = dir.join(".packages.json.tmp");
803    let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
804    std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
805    std::fs::rename(tmp, path).map_err(|e| e.to_string())
806}
807pub fn selected_project_package(cwd: &Path, name: &str) -> Result<ProjectPackage, String> {
808    selected_package_at(&cwd.join(".rpi"), name)
809}
810
811pub(crate) fn selected_package_at(root: &Path, name: &str) -> Result<ProjectPackage, String> {
812    if !valid_name(name) {
813        return Err("invalid main package name".into());
814    }
815    use std::io::Read;
816    let mut bytes = Vec::new();
817    let limit = crate::resource_selection::MAX_STATE_BYTES;
818    std::fs::File::open(root.join(REGISTRY))
819        .map_err(|e| e.to_string())?
820        .take((limit + 1) as u64)
821        .read_to_end(&mut bytes)
822        .map_err(|e| e.to_string())?;
823    if bytes.len() > limit {
824        return Err("project package registry exceeds 256 KiB".into());
825    }
826    let registry: PackageRegistry = serde_json::from_slice(&bytes)
827        .map_err(|e| format!("invalid project package registry: {e}"))?;
828    validate_registry(&registry)?;
829    registry
830        .packages
831        .into_iter()
832        .find(|p| p.name == name)
833        .ok_or_else(|| format!("main package `{name}` not in .rpi/packages.json"))
834}
835
836pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
837    match read_registry(cwd) {
838        Ok(registry) => registry
839            .packages
840            .into_iter()
841            .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
842            .collect(),
843        Err(error) => {
844            eprintln!("warning: could not load project package registry: {error}");
845            Vec::new()
846        }
847    }
848}
849
850/// Resolve the active project package extension files. Package directories
851/// are not scanned because upgrades retain retired versioned artifacts for
852/// already-running hosts.
853pub fn active_project_package_artifacts(cwd: &Path) -> Vec<PathBuf> {
854    let Ok(registry) = read_registry(cwd) else {
855        return Vec::new();
856    };
857    registry
858        .packages
859        .into_iter()
860        .flat_map(|package| {
861            package.artifacts.into_iter().map(move |artifact| {
862                cwd.join(".rpi/packages")
863                    .join(package.name.clone())
864                    .join("extensions")
865                    .join(artifact)
866            })
867        })
868        .collect()
869}
870fn print_extension_help() {
871    println!("Usage: rpi extension <command>\n\nCommands:\n  update [name] [--debug] [--locked]  Update project and global installed extensions\n\nSee `rpi extension update --help` for options.");
872}
873
874pub fn print_help() {
875    println!("Usage: rpi package <command>\n\nCommands:\n  link [name] [--path <dir>] [--package <name>] [--force]  Build and link a project-local Rust extension\n  list                                                   List project packages\n  update [name] [--debug] [--locked]                    Legacy compatibility alias for `rpi extension update`\n  unlink <name>                                          Remove a project package");
876}
877fn print_link_help() {
878    println!("Usage: rpi package link [name] [options]\n\nOptions:\n  --path <dir>       Extension source directory (default: current directory)\n  --package, -P <n>  Select a Cargo cdylib package\n  --debug            Build debug artifacts instead of release\n  --locked           Pass --locked to Cargo\n  --force, -f        Replace an existing package");
879}
880fn print_update_help() {
881    println!("Usage: rpi extension update [name] [options]\n\nOptions:\n  --debug            Build debug artifacts instead of release\n  --locked           Pass --locked to Cargo\n  --help, -h         Show this help\n\nWithout a name, all project and global installed extensions are updated.\nLegacy compatibility alias: rpi package update [name] [options]");
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    fn native_package(name: &str, source: Option<&str>) -> crate::install::InstalledNativePackage {
889        crate::install::InstalledNativePackage {
890            name: name.into(),
891            version: "0.1.11".into(),
892            source: source.map(str::to_owned),
893            prebuilt: None,
894            artifacts: Vec::new(),
895        }
896    }
897
898    fn prebuilt_package(custom_repository: bool) -> crate::install::InstalledNativePackage {
899        serde_json::from_value(serde_json::json!({
900            "name": "rpi-im-message", "version": "0.1.11",
901            "source": "prebuilt:https://github.com/owner/extensions",
902            "prebuilt": {
903                "repository": "https://github.com/owner/extensions",
904                "target": "x86_64-pc-windows-msvc",
905                "manifest_sha256": "a".repeat(64), "source_commit": "b".repeat(40),
906                "sdk_version": "0.4.0", "plugin_abi": 4,
907                "archive_sha256": "c".repeat(64), "library_sha256": "d".repeat(64),
908                "custom_repository": custom_repository
909            }
910        }))
911        .unwrap()
912    }
913
914    fn with_registry(data: &[u8], test: impl FnOnce(&Path)) {
915        struct RestoreConfigDir(Option<std::ffi::OsString>);
916        impl Drop for RestoreConfigDir {
917            fn drop(&mut self) {
918                match self.0.take() {
919                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
920                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
921                }
922            }
923        }
924        let _guard = crate::config::test_support::env_lock()
925            .lock()
926            .unwrap_or_else(|p| p.into_inner());
927        let agent = tempfile::tempdir().unwrap();
928        let project = tempfile::tempdir().unwrap();
929        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
930        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
931        std::fs::write(agent.path().join("native-packages.json"), data).unwrap();
932        test(project.path());
933    }
934
935    #[test]
936    fn update_includes_prebuilt_packages_and_preserves_install_backends() {
937        let records = vec![
938            prebuilt_package(false),
939            native_package("cargo-extension", None),
940            native_package("local-extension", Some("/dev/local-extension")),
941        ];
942        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
943            // Reproduce the empty project registry from the bug report. Neither
944            // the prebuilt source marker nor the lack of project links should
945            // hide an installed remote package.
946            let mut commands = Vec::new();
947            assert_eq!(
948                update_at(cwd, &[], |package, args| {
949                    commands.push(global_update_args(package, args));
950                    true
951                }),
952                0
953            );
954            assert_eq!(
955                commands,
956                [
957                    vec!["install", "rpi-im-message"],
958                    vec!["install", "cargo-extension", "--build"],
959                ]
960            );
961
962            for wanted in ["rpi-im-message", "cargo-extension"] {
963                commands.clear();
964                assert_eq!(
965                    update_at(cwd, &[wanted.into(), "--locked".into()], |package, args| {
966                        commands.push(global_update_args(package, args));
967                        true
968                    }),
969                    0
970                );
971                assert_eq!(commands.len(), 1);
972                assert_eq!(commands[0][1], wanted);
973                assert_eq!(
974                    commands[0].iter().any(|arg| arg == "--locked"),
975                    wanted == "cargo-extension"
976                );
977            }
978            for wanted in ["local-extension", "missing-extension"] {
979                assert_eq!(
980                    update_at(cwd, &[wanted.into()], |_, _| {
981                        panic!("local or unknown packages must not trigger a remote install")
982                    }),
983                    1
984                );
985            }
986        });
987    }
988
989    #[test]
990    fn update_preserves_explicit_github_repository() {
991        let record = prebuilt_package(true);
992        with_registry(&serde_json::to_vec(&[record]).unwrap(), |cwd| {
993            let mut commands = Vec::new();
994            assert_eq!(
995                update_at(cwd, &[], |package, args| {
996                    commands.push(global_update_args(package, args));
997                    true
998                }),
999                0
1000            );
1001            assert_eq!(
1002                commands,
1003                [vec![
1004                    "install",
1005                    "rpi-im-message",
1006                    "--github",
1007                    "https://github.com/owner/extensions"
1008                ]]
1009            );
1010        });
1011    }
1012
1013    #[test]
1014    fn update_continues_after_a_failed_package_and_returns_failure() {
1015        let records = vec![
1016            prebuilt_package(false),
1017            native_package("other-extension", None),
1018        ];
1019        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
1020            let mut visited = Vec::new();
1021            assert_eq!(
1022                update_at(cwd, &[], |package, _| {
1023                    visited.push(package.name.clone());
1024                    package.name != "rpi-im-message"
1025                }),
1026                1
1027            );
1028            assert_eq!(visited, ["rpi-im-message", "other-extension"]);
1029        });
1030    }
1031
1032    #[test]
1033    fn update_rejects_corrupt_global_metadata_before_building_project_packages() {
1034        with_registry(b"invalid json", |cwd| {
1035            write_registry(
1036                cwd,
1037                &PackageRegistry {
1038                    packages: vec![ProjectPackage {
1039                        name: "project-extension".into(),
1040                        version: "0.1.0".into(),
1041                        source: "/missing/source".into(),
1042                        cargo_package: "project-extension".into(),
1043                        artifacts: Vec::new(),
1044                    }],
1045                },
1046            )
1047            .unwrap();
1048            assert_eq!(update_at(cwd, &[], |_, _| panic!("invalid registry")), 1);
1049            assert!(!cwd.join(".rpi/packages/project-extension").exists());
1050        });
1051    }
1052
1053    #[test]
1054    fn package_names_reject_path_traversal() {
1055        assert!(valid_name("review-tools"));
1056        assert!(!valid_name("../review-tools"));
1057        assert!(!valid_name("review/tools"));
1058        assert!(!valid_name(""));
1059    }
1060
1061    #[test]
1062    fn update_help_is_available_without_a_registry() {
1063        print_update_help();
1064    }
1065
1066    #[test]
1067    fn dynamic_library_extensions_are_platform_neutral() {
1068        assert!(is_dynamic(Path::new("plugin.dll")));
1069        assert!(is_dynamic(Path::new("libplugin.so")));
1070        assert!(is_dynamic(Path::new("libplugin.dylib")));
1071        assert!(!is_dynamic(Path::new("plugin.exe")));
1072    }
1073}