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(
381        &crate::install::InstalledNativePackage,
382        &[String],
383        Option<&str>,
384    ) -> bool,
385) -> i32 {
386    let mut name = None;
387    let mut cdn = None;
388    let mut link_args = Vec::new();
389    let mut i = 0;
390    while i < args.len() {
391        match args[i].as_str() {
392            "--debug" | "--locked" => link_args.push(args[i].clone()),
393            "--cdn" => {
394                i += 1;
395                let value = match args.get(i).filter(|value| !value.starts_with('-')) {
396                    Some(value) => value,
397                    None => {
398                        eprintln!("error: --cdn requires a value");
399                        return 2;
400                    }
401                };
402                if let Err(error) = crate::cdn::resolve_base(Some(value)) {
403                    eprintln!("error: {error}");
404                    return 2;
405                }
406                cdn = Some(value.clone());
407            }
408            "--help" | "-h" => {
409                print_update_help();
410                return 0;
411            }
412            value if value.starts_with('-') => {
413                eprintln!("error: unknown update option `{value}`");
414                return 2;
415            }
416            value => {
417                if name.replace(value.to_string()).is_some() {
418                    eprintln!("error: update accepts at most one extension name");
419                    return 2;
420                }
421            }
422        }
423        i += 1;
424    }
425
426    let registry = match read_registry(cwd) {
427        Ok(r) => r,
428        Err(e) => {
429            eprintln!("error: {e}");
430            return 1;
431        }
432    };
433    let resource_owned =
434        match crate::resource_management::resource_owned_names(&cwd.join(".rpi"), "package") {
435            Ok(names) => names,
436            Err(error) => {
437                eprintln!("error: {error}");
438                return 1;
439            }
440        };
441    if name
442        .as_ref()
443        .is_some_and(|name| resource_owned.contains(name))
444    {
445        eprintln!("error: resource-owned Package; use rpi resource update package NAME --scope project --path PATH");
446        return 1;
447    }
448    let packages = registry
449        .packages
450        .iter()
451        .filter(|package| !resource_owned.contains(&package.name))
452        .filter(|package| {
453            name.as_deref()
454                .map_or(true, |wanted| wanted == package.name)
455        })
456        .cloned()
457        .collect::<Vec<_>>();
458
459    // Global extensions installed by `rpi install` are also packages. This is
460    // the path used by crates.io extensions such as rpi-im-message; delegate to
461    // the existing transactional installer so artifact and registry updates
462    // remain atomic and version resolution stays in one place.
463    let global_packages = match crate::install::installed_native_packages_strict() {
464        Ok(packages) => packages
465            .into_iter()
466            .filter(|package| {
467                package.supports_registry_update()
468                    && name
469                        .as_deref()
470                        .map_or(true, |wanted| wanted == package.name)
471            })
472            .collect::<Vec<_>>(),
473        Err(error) => {
474            eprintln!("error: {error}");
475            return 1;
476        }
477    };
478    if let Some(wanted) = name.as_deref() {
479        let project_found = registry
480            .packages
481            .iter()
482            .any(|package| package.name == wanted);
483        let global_found = global_packages.iter().any(|package| package.name == wanted);
484        if !project_found && !global_found {
485            eprintln!("error: extension `{wanted}` is not installed");
486            return 1;
487        }
488    }
489
490    if packages.is_empty() && global_packages.is_empty() {
491        println!("no project or global extensions to update");
492        return 0;
493    }
494
495    let mut status = 0;
496    for package in packages {
497        let package_name = package.name.clone();
498        eprintln!("extension: updating project extension `{package_name}`...");
499        let mut args = vec![
500            "--path".to_string(),
501            package.source,
502            "--package".to_string(),
503            package.cargo_package,
504            "--name".to_string(),
505            package.name,
506            "--force".to_string(),
507        ];
508        args.extend(link_args.iter().cloned());
509        if link(&args) != 0 {
510            status = 1;
511        }
512    }
513    for package in global_packages {
514        eprintln!("extension: updating global extension `{}`...", package.name);
515        if !update_global(&package, &link_args, cdn.as_deref()) {
516            status = 1;
517        }
518    }
519
520    status
521}
522
523fn global_update_args(
524    package: &crate::install::InstalledNativePackage,
525    args: &[String],
526    cdn: Option<&str>,
527) -> Vec<String> {
528    let mut install_args = vec!["install".into(), package.name.clone()];
529    if let Some(source) = &package.prebuilt {
530        if source.custom_repository {
531            install_args.extend(["--github".into(), source.repository.clone()]);
532        }
533    } else {
534        install_args.push("--build".into());
535    }
536    if package.prebuilt.is_none() && args.iter().any(|arg| arg == "--locked") {
537        install_args.push("--locked".into());
538    }
539    if package.prebuilt.is_some() {
540        if let Some(cdn) = cdn {
541            install_args.extend(["--cdn".into(), cdn.into()]);
542        }
543    }
544    install_args
545}
546
547fn update_global_package(
548    package: &crate::install::InstalledNativePackage,
549    args: &[String],
550    cdn: Option<&str>,
551) -> bool {
552    let name = &package.name;
553    let executable = match std::env::current_exe() {
554        Ok(path) => path,
555        Err(error) => {
556            eprintln!("error: could not resolve rpi executable: {error}");
557            return false;
558        }
559    };
560    let mut command = std::process::Command::new(executable);
561    command.args(global_update_args(package, args, cdn));
562    match command.status() {
563        Ok(exit) if exit.success() => true,
564        Ok(exit) => {
565            eprintln!("error: updating global extension `{name}` failed with {exit}");
566            false
567        }
568        Err(error) => {
569            eprintln!("error: could not run rpi install for `{name}`: {error}");
570            false
571        }
572    }
573}
574
575fn unlink(args: &[String]) -> i32 {
576    let Some(name) = args.first() else {
577        eprintln!("error: missing package name");
578        return 2;
579    };
580    if args.len() != 1 || !valid_name(name) {
581        eprintln!("error: invalid package name or arguments");
582        return 2;
583    }
584    let cwd = match std::env::current_dir() {
585        Ok(p) => p,
586        Err(e) => {
587            eprintln!("error: {e}");
588            return 1;
589        }
590    };
591    let native_root = cwd.join(".rpi");
592    let _lease = match crate::resource_management::mutation_lease(&native_root) {
593        Ok(lease) => lease,
594        Err(error) => {
595            eprintln!("error: {error}");
596            return 1;
597        }
598    };
599    if let Err(error) = crate::resource_management::legacy_unowned(&native_root, "package", name) {
600        eprintln!("error: {error}");
601        return 1;
602    }
603    let mut registry = match read_registry(&cwd) {
604        Ok(r) => r,
605        Err(e) => {
606            eprintln!("error: {e}");
607            return 1;
608        }
609    };
610    if !registry.packages.iter().any(|p| p.name == *name) {
611        eprintln!("error: project package `{name}` is not installed");
612        return 1;
613    }
614    let dir = cwd.join(".rpi/packages").join(name);
615    if let Err(e) = std::fs::remove_dir_all(&dir) {
616        eprintln!("error: could not remove package: {e}");
617        return 1;
618    }
619    registry.packages.retain(|p| p.name != *name);
620    if let Err(e) = write_registry(&cwd, &registry) {
621        eprintln!("error: {e}");
622        return 1;
623    }
624    println!("unlinked project package `{name}`");
625    0
626}
627
628#[derive(Debug, Clone)]
629struct CargoPackage {
630    name: String,
631    version: String,
632    manifest: PathBuf,
633    target: String,
634}
635#[derive(Deserialize)]
636struct Metadata {
637    packages: Vec<MetadataPackage>,
638}
639#[derive(Deserialize)]
640struct MetadataPackage {
641    name: String,
642    version: String,
643    manifest_path: PathBuf,
644    targets: Vec<Target>,
645}
646#[derive(Deserialize)]
647struct Target {
648    name: String,
649    crate_types: Vec<String>,
650}
651
652fn cargo_metadata(cwd: &Path) -> Result<Metadata, String> {
653    let out = Command::new("cargo")
654        .args(["metadata", "--format-version", "1", "--no-deps"])
655        .current_dir(cwd)
656        .stdin(Stdio::null())
657        .output()
658        .map_err(|e| format!("could not execute cargo metadata: {e}"))?;
659    if !out.status.success() {
660        return Err(format!(
661            "cargo metadata failed:\n{}",
662            String::from_utf8_lossy(&out.stderr).trim()
663        ));
664    }
665    serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid cargo metadata output: {e}"))
666}
667fn select_package(
668    m: &Metadata,
669    requested: Option<&str>,
670    cwd: &Path,
671) -> Result<CargoPackage, String> {
672    let mut found = Vec::new();
673    for p in &m.packages {
674        if requested.is_some_and(|n| n != p.name) {
675            continue;
676        }
677        for t in &p.targets {
678            if t.crate_types.iter().any(|k| k == "cdylib") {
679                found.push(CargoPackage {
680                    name: p.name.clone(),
681                    version: p.version.clone(),
682                    manifest: p.manifest_path.clone(),
683                    target: t.name.clone(),
684                });
685            }
686        }
687    }
688    if requested.is_none() {
689        // Cargo returns normalized manifest paths. --path may contain `..` or
690        // a Windows extended-length prefix; compare normalized paths on both
691        // sides when choosing among several provider packages in a workspace.
692        let source = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
693        let mut local = found
694            .iter()
695            .filter(|p| {
696                p.manifest.parent().is_some_and(|root| {
697                    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
698                    source.starts_with(root)
699                })
700            })
701            .cloned()
702            .collect::<Vec<_>>();
703        if local.len() == 1 {
704            return Ok(local.remove(0));
705        }
706    }
707    match found.len() {
708        1 => Ok(found.remove(0)),
709        0 => Err("no cdylib package found; use --package to select one".into()),
710        _ => Err(format!(
711            "multiple cdylib packages found: {}; use --package",
712            found
713                .iter()
714                .map(|p| p.name.as_str())
715                .collect::<Vec<_>>()
716                .join(", ")
717        )),
718    }
719}
720fn build(p: &CargoPackage, release: bool, locked: bool) -> Result<PathBuf, String> {
721    let mut c = Command::new("cargo");
722    c.args(["build", "--manifest-path"]).arg(&p.manifest).args([
723        "--package",
724        &p.name,
725        "--message-format=json-render-diagnostics",
726    ]);
727    if release {
728        c.arg("--release");
729    }
730    if locked {
731        c.arg("--locked");
732    }
733    c.stdout(Stdio::piped()).stderr(Stdio::piped());
734    let out = c
735        .output()
736        .map_err(|e| format!("could not execute cargo build: {e}"))?;
737    let mut artifact = None;
738    for line in String::from_utf8_lossy(&out.stdout).lines() {
739        if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
740            if v.get("reason").and_then(|v| v.as_str()) == Some("compiler-artifact")
741                && v.get("target")
742                    .and_then(|v| v.get("name"))
743                    .and_then(|v| v.as_str())
744                    == Some(&p.target)
745            {
746                artifact = v
747                    .get("filenames")
748                    .and_then(|v| v.as_array())
749                    .into_iter()
750                    .flatten()
751                    .filter_map(|v| v.as_str())
752                    .map(PathBuf::from)
753                    .find(|p| is_dynamic(p));
754            }
755        }
756    }
757    eprint!("{}", String::from_utf8_lossy(&out.stderr));
758    if !out.status.success() {
759        return Err(format!("cargo build exited with {}", out.status));
760    }
761    artifact.ok_or_else(|| "Cargo did not report a cdylib artifact".into())
762}
763fn is_dynamic(p: &Path) -> bool {
764    matches!(
765        p.extension()
766            .and_then(|x| x.to_str())
767            .map(|x| x.to_ascii_lowercase())
768            .as_deref(),
769        Some("dll" | "so" | "dylib")
770    )
771}
772pub(crate) fn valid_name(s: &str) -> bool {
773    !s.is_empty()
774        && s != "."
775        && s != ".."
776        && s.bytes()
777            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
778}
779fn value(args: &[String], i: usize, flag: &str) -> Option<String> {
780    match args.get(i) {
781        Some(v) if !v.starts_with('-') => Some(v.clone()),
782        _ => {
783            eprintln!("error: {flag} requires a value");
784            None
785        }
786    }
787}
788fn read_registry(cwd: &Path) -> Result<PackageRegistry, String> {
789    let path = cwd.join(".rpi").join(REGISTRY);
790    let registry = match std::fs::read_to_string(path) {
791        Ok(s) => serde_json::from_str(&s)
792            .map_err(|e| format!("invalid project package registry: {e}"))?,
793        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(PackageRegistry::default()),
794        Err(e) => return Err(e.to_string()),
795    };
796    validate_registry(&registry)?;
797    Ok(registry)
798}
799
800fn validate_registry(registry: &PackageRegistry) -> Result<(), String> {
801    let mut names = std::collections::HashSet::new();
802    for package in &registry.packages {
803        if !valid_name(&package.name) || !names.insert(package.name.to_ascii_lowercase()) {
804            return Err(format!(
805                "invalid or duplicate project package name `{}`",
806                package.name
807            ));
808        }
809        let mut artifacts = std::collections::HashSet::new();
810        for artifact in &package.artifacts {
811            let path = Path::new(artifact);
812            if path.components().count() != 1
813                || path.file_name().and_then(|value| value.to_str()) != Some(artifact)
814                || !is_dynamic(path)
815                || !artifacts.insert(artifact.to_ascii_lowercase())
816            {
817                return Err(format!(
818                    "invalid or duplicate project package artifact `{artifact}`"
819                ));
820            }
821        }
822    }
823    Ok(())
824}
825fn write_registry(cwd: &Path, r: &PackageRegistry) -> Result<(), String> {
826    let dir = cwd.join(".rpi");
827    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
828    let path = dir.join(REGISTRY);
829    let tmp = dir.join(".packages.json.tmp");
830    let bytes = serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?;
831    std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
832    std::fs::rename(tmp, path).map_err(|e| e.to_string())
833}
834pub fn selected_project_package(cwd: &Path, name: &str) -> Result<ProjectPackage, String> {
835    selected_package_at(&cwd.join(".rpi"), name)
836}
837
838pub(crate) fn selected_package_at(root: &Path, name: &str) -> Result<ProjectPackage, String> {
839    if !valid_name(name) {
840        return Err("invalid main package name".into());
841    }
842    use std::io::Read;
843    let mut bytes = Vec::new();
844    let limit = crate::resource_selection::MAX_STATE_BYTES;
845    std::fs::File::open(root.join(REGISTRY))
846        .map_err(|e| e.to_string())?
847        .take((limit + 1) as u64)
848        .read_to_end(&mut bytes)
849        .map_err(|e| e.to_string())?;
850    if bytes.len() > limit {
851        return Err("project package registry exceeds 256 KiB".into());
852    }
853    let registry: PackageRegistry = serde_json::from_slice(&bytes)
854        .map_err(|e| format!("invalid project package registry: {e}"))?;
855    validate_registry(&registry)?;
856    registry
857        .packages
858        .into_iter()
859        .find(|p| p.name == name)
860        .ok_or_else(|| format!("main package `{name}` not in .rpi/packages.json"))
861}
862
863pub fn project_package_dirs(cwd: &Path, kind: &str) -> Vec<PathBuf> {
864    match read_registry(cwd) {
865        Ok(registry) => registry
866            .packages
867            .into_iter()
868            .map(|p| cwd.join(".rpi/packages").join(p.name).join(kind))
869            .collect(),
870        Err(error) => {
871            eprintln!("warning: could not load project package registry: {error}");
872            Vec::new()
873        }
874    }
875}
876
877/// Resolve the active project package extension files. Package directories
878/// are not scanned because upgrades retain retired versioned artifacts for
879/// already-running hosts.
880pub fn active_project_package_artifacts(cwd: &Path) -> Vec<PathBuf> {
881    let Ok(registry) = read_registry(cwd) else {
882        return Vec::new();
883    };
884    registry
885        .packages
886        .into_iter()
887        .flat_map(|package| {
888            package.artifacts.into_iter().map(move |artifact| {
889                cwd.join(".rpi/packages")
890                    .join(package.name.clone())
891                    .join("extensions")
892                    .join(artifact)
893            })
894        })
895        .collect()
896}
897fn print_extension_help() {
898    println!("Usage: rpi extension <command>\n\nCommands:\n  update [name] [--debug] [--locked] [--cdn <URL>]  Update project and global installed extensions\n\nSee `rpi extension update --help` for options.");
899}
900
901pub fn print_help() {
902    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] [--cdn <URL>]      Legacy compatibility alias for `rpi extension update`\n  unlink <name>                                          Remove a project package");
903}
904fn print_link_help() {
905    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");
906}
907fn print_update_help() {
908    println!("Usage: rpi extension update [name] [options]\n\nOptions:\n  --debug            Build debug artifacts instead of release\n  --locked           Pass --locked to Cargo\n  --cdn <URL>        Download prebuilt extensions from this HTTPS CDN; manifests remain on GitHub\n  --help, -h         Show this help\n\nWithout a name, all project and global installed extensions are updated.\n`--cdn` affects prebuilt global extensions; project-linked and Cargo-built extensions are unchanged.\nLegacy compatibility alias: rpi package update [name] [options]");
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    fn native_package(name: &str, source: Option<&str>) -> crate::install::InstalledNativePackage {
916        crate::install::InstalledNativePackage {
917            name: name.into(),
918            version: "0.1.11".into(),
919            source: source.map(str::to_owned),
920            prebuilt: None,
921            artifacts: Vec::new(),
922        }
923    }
924
925    fn prebuilt_package(custom_repository: bool) -> crate::install::InstalledNativePackage {
926        serde_json::from_value(serde_json::json!({
927            "name": "rpi-im-message", "version": "0.1.11",
928            "source": "prebuilt:https://github.com/owner/extensions",
929            "prebuilt": {
930                "repository": "https://github.com/owner/extensions",
931                "target": "x86_64-pc-windows-msvc",
932                "manifest_sha256": "a".repeat(64), "source_commit": "b".repeat(40),
933                "sdk_version": "0.4.0", "plugin_abi": 4,
934                "archive_sha256": "c".repeat(64), "library_sha256": "d".repeat(64),
935                "custom_repository": custom_repository
936            }
937        }))
938        .unwrap()
939    }
940
941    fn with_registry(data: &[u8], test: impl FnOnce(&Path)) {
942        struct RestoreConfigDir(Option<std::ffi::OsString>);
943        impl Drop for RestoreConfigDir {
944            fn drop(&mut self) {
945                match self.0.take() {
946                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
947                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
948                }
949            }
950        }
951        let _guard = crate::config::test_support::env_lock()
952            .lock()
953            .unwrap_or_else(|p| p.into_inner());
954        let agent = tempfile::tempdir().unwrap();
955        let project = tempfile::tempdir().unwrap();
956        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
957        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
958        std::fs::write(agent.path().join("native-packages.json"), data).unwrap();
959        test(project.path());
960    }
961
962    #[test]
963    fn update_includes_prebuilt_packages_and_preserves_install_backends() {
964        let records = vec![
965            prebuilt_package(false),
966            native_package("cargo-extension", None),
967            native_package("local-extension", Some("/dev/local-extension")),
968        ];
969        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
970            // Reproduce the empty project registry from the bug report. Neither
971            // the prebuilt source marker nor the lack of project links should
972            // hide an installed remote package.
973            let mut commands = Vec::new();
974            assert_eq!(
975                update_at(cwd, &[], |package, args, cdn| {
976                    commands.push(global_update_args(package, args, cdn));
977                    true
978                }),
979                0
980            );
981            assert_eq!(
982                commands,
983                [
984                    vec!["install", "rpi-im-message"],
985                    vec!["install", "cargo-extension", "--build"],
986                ]
987            );
988
989            for wanted in ["rpi-im-message", "cargo-extension"] {
990                commands.clear();
991                assert_eq!(
992                    update_at(
993                        cwd,
994                        &[wanted.into(), "--locked".into()],
995                        |package, args, cdn| {
996                            commands.push(global_update_args(package, args, cdn));
997                            true
998                        }
999                    ),
1000                    0
1001                );
1002                assert_eq!(commands.len(), 1);
1003                assert_eq!(commands[0][1], wanted);
1004                assert_eq!(
1005                    commands[0].iter().any(|arg| arg == "--locked"),
1006                    wanted == "cargo-extension"
1007                );
1008            }
1009            for wanted in ["local-extension", "missing-extension"] {
1010                assert_eq!(
1011                    update_at(cwd, &[wanted.into()], |_, _, _| {
1012                        panic!("local or unknown packages must not trigger a remote install")
1013                    }),
1014                    1
1015                );
1016            }
1017        });
1018    }
1019
1020    #[test]
1021    fn update_preserves_explicit_github_repository() {
1022        let record = prebuilt_package(true);
1023        with_registry(&serde_json::to_vec(&[record]).unwrap(), |cwd| {
1024            let mut commands = Vec::new();
1025            assert_eq!(
1026                update_at(cwd, &[], |package, args, cdn| {
1027                    commands.push(global_update_args(package, args, cdn));
1028                    true
1029                }),
1030                0
1031            );
1032            assert_eq!(
1033                commands,
1034                [vec![
1035                    "install",
1036                    "rpi-im-message",
1037                    "--github",
1038                    "https://github.com/owner/extensions"
1039                ]]
1040            );
1041        });
1042    }
1043
1044    #[test]
1045    fn update_forwards_cdn_only_to_prebuilt_global_extensions() {
1046        let records = vec![
1047            prebuilt_package(false),
1048            native_package("cargo-extension", None),
1049        ];
1050        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
1051            let mut commands = Vec::new();
1052            assert_eq!(
1053                update_at(
1054                    cwd,
1055                    &["--cdn".into(), "https://assets.example.test/mirror".into()],
1056                    |package, args, cdn| {
1057                        commands.push(global_update_args(package, args, cdn));
1058                        true
1059                    },
1060                ),
1061                0
1062            );
1063            assert_eq!(
1064                commands,
1065                [
1066                    vec![
1067                        "install",
1068                        "rpi-im-message",
1069                        "--cdn",
1070                        "https://assets.example.test/mirror"
1071                    ],
1072                    vec!["install", "cargo-extension", "--build"],
1073                ]
1074            );
1075        });
1076    }
1077
1078    #[test]
1079    fn update_continues_after_a_failed_package_and_returns_failure() {
1080        let records = vec![
1081            prebuilt_package(false),
1082            native_package("other-extension", None),
1083        ];
1084        with_registry(&serde_json::to_vec(&records).unwrap(), |cwd| {
1085            let mut visited = Vec::new();
1086            assert_eq!(
1087                update_at(cwd, &[], |package, _, _| {
1088                    visited.push(package.name.clone());
1089                    package.name != "rpi-im-message"
1090                }),
1091                1
1092            );
1093            assert_eq!(visited, ["rpi-im-message", "other-extension"]);
1094        });
1095    }
1096
1097    #[test]
1098    fn update_rejects_corrupt_global_metadata_before_building_project_packages() {
1099        with_registry(b"invalid json", |cwd| {
1100            write_registry(
1101                cwd,
1102                &PackageRegistry {
1103                    packages: vec![ProjectPackage {
1104                        name: "project-extension".into(),
1105                        version: "0.1.0".into(),
1106                        source: "/missing/source".into(),
1107                        cargo_package: "project-extension".into(),
1108                        artifacts: Vec::new(),
1109                    }],
1110                },
1111            )
1112            .unwrap();
1113            assert_eq!(update_at(cwd, &[], |_, _, _| panic!("invalid registry")), 1);
1114            assert!(!cwd.join(".rpi/packages/project-extension").exists());
1115        });
1116    }
1117
1118    #[test]
1119    fn package_names_reject_path_traversal() {
1120        assert!(valid_name("review-tools"));
1121        assert!(!valid_name("../review-tools"));
1122        assert!(!valid_name("review/tools"));
1123        assert!(!valid_name(""));
1124    }
1125
1126    #[test]
1127    fn update_help_is_available_without_a_registry() {
1128        print_update_help();
1129    }
1130
1131    #[test]
1132    fn dynamic_library_extensions_are_platform_neutral() {
1133        assert!(is_dynamic(Path::new("plugin.dll")));
1134        assert!(is_dynamic(Path::new("libplugin.so")));
1135        assert!(is_dynamic(Path::new("libplugin.dylib")));
1136        assert!(!is_dynamic(Path::new("plugin.exe")));
1137    }
1138}