1use 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
31pub 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 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 let mut guarded_targets = vec![target.clone(), tmp.clone()];
232 if target_file != file {
233 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 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 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, ®istry) {
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 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, ®istry) {
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 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(®istry)?;
797 Ok(registry)
798}
799
800fn validate_registry(registry: &PackageRegistry) -> Result<(), String> {
801 let mut names = std::collections::HashSet::new();
802 for package in ®istry.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(®istry)?;
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
877pub 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 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}