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(&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 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, ®istry) {
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 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(®istry)?;
770 Ok(registry)
771}
772
773fn validate_registry(registry: &PackageRegistry) -> Result<(), String> {
774 let mut names = std::collections::HashSet::new();
775 for package in ®istry.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(®istry)?;
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
850pub 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 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}