1use crate::{
5 installer::{
6 InstallSource, Installer, InstallerError, binary_candidates, fallback_bin_dir, find_in_path,
7 },
8 registry::Registry,
9};
10use clap::{Parser, Subcommand};
11use std::{
12 env,
13 ffi::OsString,
14 path::{Path, PathBuf},
15 process::Command,
16};
17
18const BASE_URL: &str = "https://cli.tempo.xyz";
19const PUBLIC_KEY: &str = "RWTtoEUPuapAfh06rC7BZLjm1hG40/lsVAA/2afN88FZ8/Fdk97LzJDf";
20
21#[derive(Debug, thiserror::Error)]
22pub enum LauncherError {
23 #[error("io error: {0}")]
24 Io(#[from] std::io::Error),
25
26 #[error("installer error: {0}")]
27 Installer(#[from] InstallerError),
28
29 #[error("invalid arguments: {0}")]
30 InvalidArgs(String),
31
32 #[error("{0}")]
33 Registry(String),
34}
35
36pub fn run<I, T>(args: I) -> Result<i32, LauncherError>
39where
40 I: IntoIterator<Item = T>,
41 T: Into<OsString> + Clone,
42{
43 let exe_dir = env::current_exe()
44 .ok()
45 .as_deref()
46 .and_then(|path| path.parent().map(Path::to_path_buf));
47 let launcher = Launcher { exe_dir };
48
49 let cli = match Cli::try_parse_from(args) {
50 Ok(cli) => cli,
51 Err(err) => {
52 if matches!(
54 err.kind(),
55 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
56 ) {
57 err.exit();
58 }
59 return Err(LauncherError::InvalidArgs(err.to_string()));
60 }
61 };
62
63 match cli.command {
64 Commands::Add(args) => launcher.handle_install(args),
65 Commands::Update(args) => launcher.handle_update(args),
66 Commands::Remove(args) => launcher.handle_remove(&args.extension, args.dry_run),
67 Commands::List => launcher.handle_list(),
68 Commands::Extension(ext_args) => launcher.handle_extension(ext_args),
69 }
70}
71
72#[derive(Parser, Debug)]
74#[command(
75 name = "tempo",
76 disable_version_flag = true,
77 disable_help_subcommand = true
78)]
79struct Cli {
80 #[command(subcommand)]
81 command: Commands,
82}
83
84#[derive(Subcommand, Debug)]
85enum Commands {
86 #[command(after_help = "Examples:\n tempo add wallet\n tempo add wallet 0.2.0")]
88 Add(ManagementArgs),
89
90 #[command(
93 after_help = "Examples:\n tempo update # update tempo + all extensions\n tempo update wallet # update a single extension"
94 )]
95 Update(UpdateArgs),
96
97 #[command(after_help = "Example: tempo remove wallet")]
99 Remove(RemoveArgs),
100
101 List,
103
104 #[command(external_subcommand)]
106 Extension(Vec<OsString>),
107}
108
109#[derive(Parser, Debug)]
110struct ManagementArgs {
111 extension: String,
113
114 version: Option<String>,
116
117 #[arg(long = "release-manifest")]
119 manifest: Option<String>,
120
121 #[arg(long = "release-public-key")]
123 public_key: Option<String>,
124
125 #[arg(long)]
127 dry_run: bool,
128}
129
130#[derive(Parser, Debug)]
131struct UpdateArgs {
132 extension: Option<String>,
134
135 version: Option<String>,
137
138 #[arg(long = "release-manifest")]
140 manifest: Option<String>,
141
142 #[arg(long = "release-public-key")]
144 public_key: Option<String>,
145
146 #[arg(long)]
148 dry_run: bool,
149}
150
151#[derive(Parser, Debug)]
152struct RemoveArgs {
153 extension: String,
155
156 #[arg(long)]
158 dry_run: bool,
159}
160
161fn run_tempoup(bin_dir: &Path) -> Result<bool, LauncherError> {
167 let status = match Command::new("tempoup")
168 .env("TEMPO_BIN_DIR", bin_dir)
169 .status()
170 {
171 Ok(s) => s,
172 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
173 println!("tempoup not found, installing...");
174 let install_status = Command::new("sh")
175 .arg("-c")
176 .arg("curl -fsSL https://tempo.xyz/install | bash")
177 .status()?;
178 if !install_status.success() {
179 tracing::error!("failed to install tempoup");
180 return Ok(false);
181 }
182 Command::new("tempoup")
183 .env("TEMPO_BIN_DIR", bin_dir)
184 .status()?
185 }
186 Err(err) => return Err(LauncherError::Io(err)),
187 };
188 Ok(status.success())
189}
190
191struct Launcher {
194 exe_dir: Option<PathBuf>,
196}
197
198impl Launcher {
199 fn handle_install(&self, args: ManagementArgs) -> Result<i32, LauncherError> {
201 if !is_valid_extension_name(&args.extension) {
202 return Err(LauncherError::InvalidArgs(format!(
203 "invalid extension name: {} (only alphanumeric, hyphens, and underscores)",
204 args.extension
205 )));
206 }
207
208 let installer = Installer::from_env(self.exe_dir.as_deref())?;
209 let source = if args.manifest.is_none() {
210 InstallSource {
211 manifest: Some(manifest_url(&args.extension, args.version.as_deref())),
212 public_key: Some(release_public_key()),
213 }
214 } else {
215 InstallSource {
216 manifest: args.manifest,
217 public_key: Some(args.public_key.unwrap_or_else(release_public_key)),
218 }
219 };
220 let pinned = args.version.is_some();
221 let result = installer.install(&args.extension, &source, args.dry_run, false)?;
222 if !args.dry_run {
223 let mut registry = Registry::load().map_err(LauncherError::Registry)?;
224 registry.record_check(
225 &args.extension,
226 &result.version,
227 pinned,
228 &result.description,
229 );
230 registry.save();
231 }
232 Ok(0)
233 }
234
235 fn handle_update(&self, args: UpdateArgs) -> Result<i32, LauncherError> {
242 let Some(extension) = args.extension else {
243 return self.handle_update_all(args.dry_run);
244 };
245
246 if !is_valid_extension_name(&extension) {
247 return Err(LauncherError::InvalidArgs(format!(
248 "invalid extension name: {extension} (only alphanumeric, hyphens, and underscores)",
249 )));
250 }
251
252 if args.version.is_some() {
254 return self.handle_install(ManagementArgs {
255 extension,
256 version: args.version,
257 manifest: args.manifest,
258 public_key: args.public_key,
259 dry_run: args.dry_run,
260 });
261 }
262
263 let installer = Installer::from_env(self.exe_dir.as_deref())?;
264 let source = if args.manifest.is_none() {
265 InstallSource {
266 manifest: Some(manifest_url(&extension, None)),
267 public_key: Some(release_public_key()),
268 }
269 } else {
270 InstallSource {
271 manifest: args.manifest,
272 public_key: Some(args.public_key.unwrap_or_else(release_public_key)),
273 }
274 };
275
276 let registry = Registry::load().map_err(LauncherError::Registry)?;
277 let installed_version = registry
278 .extensions
279 .get(&extension)
280 .map(|e| e.installed_version.as_str());
281
282 if args.dry_run {
283 match Installer::check_latest_version(&source, installed_version) {
284 Ok(Some(latest)) => {
285 println!(
286 "dry-run: would update tempo-{extension} from {} to {latest}",
287 installed_version.unwrap_or("none")
288 );
289 }
290 Ok(None) => {
291 println!(
292 "dry-run: tempo-{extension} is already at the latest version ({})",
293 installed_version.unwrap_or("unknown")
294 );
295 }
296 Err(err) => {
297 tracing::warn!("dry-run: failed to check for updates: {err}");
298 }
299 }
300 return Ok(0);
301 }
302
303 match installer.install_if_changed(&extension, &source, installed_version)? {
304 Some(result) => {
305 if installed_version.is_some_and(|v| !v.is_empty()) {
306 println!("Updated tempo-{extension} to {}", result.version);
307 } else {
308 println!("Installed tempo-{extension} {}", result.version);
309 }
310 let mut registry = registry;
311 registry.record_check(&extension, &result.version, false, &result.description);
312 registry.save();
313 }
314 None => {
315 println!(
316 "tempo-{extension} is already at the latest version ({})",
317 installed_version.unwrap_or("unknown")
318 );
319 let mut registry = registry;
320 registry.touch_check(&extension);
321 registry.save();
322 }
323 }
324
325 Ok(0)
326 }
327
328 fn handle_update_all(&self, dry_run: bool) -> Result<i32, LauncherError> {
330 let installer = Installer::from_env(self.exe_dir.as_deref())?;
331
332 if dry_run {
334 println!("dry-run: update tempo via tempoup");
335 } else {
336 println!("Updating tempo...");
337 if !run_tempoup(&installer.bin_dir)? {
338 tracing::error!("tempo update failed");
339 }
340 }
341
342 let registry = Registry::load().map_err(LauncherError::Registry)?;
344 let extensions: Vec<(String, String, bool)> = registry
345 .extensions
346 .iter()
347 .filter(|(_, state)| !state.installed_version.is_empty())
348 .map(|(name, state)| (name.clone(), state.installed_version.clone(), state.pinned))
349 .collect();
350
351 if extensions.is_empty() {
352 return Ok(0);
353 }
354
355 println!("Updating extensions...");
356 let mut updated_registry = registry;
357
358 for (name, installed_version, pinned) in &extensions {
359 if *pinned {
360 println!("Skipping tempo-{name} (pinned at {installed_version})");
361 continue;
362 }
363
364 let source = InstallSource {
365 manifest: Some(manifest_url(name, None)),
366 public_key: Some(release_public_key()),
367 };
368
369 if dry_run {
370 println!("dry-run: update {name} (installed: {installed_version})");
371 continue;
372 }
373
374 match installer.install_if_changed(name, &source, Some(installed_version)) {
375 Ok(Some(result)) => {
376 println!("Updated tempo-{name} to {}", result.version);
377 updated_registry.record_check(
378 name,
379 &result.version,
380 false,
381 &result.description,
382 );
383 }
384 Ok(None) => {
385 updated_registry.touch_check(name);
386 }
387 Err(err) => {
388 tracing::error!(extension = %name, "failed to update: {err}");
389 updated_registry.touch_check(name);
390 }
391 }
392 }
393
394 if !dry_run {
395 updated_registry.save();
396 }
397
398 Ok(0)
399 }
400
401 fn handle_remove(&self, extension: &str, dry_run: bool) -> Result<i32, LauncherError> {
403 if !is_valid_extension_name(extension) {
404 return Err(LauncherError::InvalidArgs(format!(
405 "invalid extension name: {extension} (only alphanumeric, hyphens, and underscores)",
406 )));
407 }
408
409 let installer = Installer::from_env(self.exe_dir.as_deref())?;
410 installer.remove(extension, dry_run)?;
411
412 if !dry_run {
413 let mut registry = Registry::load().map_err(LauncherError::Registry)?;
414 registry.extensions.remove(extension);
415 registry.save();
416 }
417
418 Ok(0)
419 }
420
421 fn handle_list(&self) -> Result<i32, LauncherError> {
423 let registry = Registry::load().map_err(LauncherError::Registry)?;
424 let mut entries: Vec<_> = registry
425 .extensions
426 .iter()
427 .filter(|(_, state)| !state.installed_version.is_empty())
428 .collect();
429
430 if entries.is_empty() {
431 println!("No extensions installed.");
432 println!();
433 println!("Run `tempo add <extension>` to install one.");
434 return Ok(0);
435 }
436
437 entries.sort_by_key(|(a, _)| *a);
438
439 println!();
440 println!(" {:<22} {:<12}", "Extension", "Version");
441 println!(" {:<22} {:<12}", "─────────", "───────");
442
443 for (name, state) in &entries {
444 let mut meta = Vec::new();
445 if state.pinned {
446 meta.push("pinned".to_string());
447 }
448 if !state.description.is_empty() {
449 meta.push(state.description.clone());
450 }
451 let suffix = if meta.is_empty() {
452 String::new()
453 } else {
454 meta.join(" · ")
455 };
456 println!(" {:<22} {:<12} {}", name, state.installed_version, suffix);
457 }
458 println!();
459
460 Ok(0)
461 }
462
463 fn handle_extension(&self, ext_args: Vec<OsString>) -> Result<i32, LauncherError> {
468 let extension = ext_args[0].to_string_lossy();
469 if !is_valid_extension_name(&extension) {
470 print_missing_install_hint(&extension);
471 return Ok(1);
472 }
473 tracing::debug!("extension={extension}");
474
475 let binary_name = format!("tempo-{extension}");
476 let display_name = format!("tempo {extension}");
477 let child_args = &ext_args[1..];
478
479 if let Some(binary) = self.find_binary(&binary_name) {
480 tracing::debug!("extension found locally: {}", binary.display());
481 self.warn_path_mismatch(&binary);
482 self.maybe_auto_update(&extension)?;
483 return run_child(binary, child_args, &display_name);
484 }
485
486 tracing::debug!("attempting extension auto-install");
488 match self.try_auto_install_extension(&extension) {
489 Ok(Some(binary)) => {
490 return run_child(binary, child_args, &display_name);
491 }
492 Ok(None) => {}
493 Err(err) => {
494 tracing::debug!("extension auto-install failed: {err}");
495 }
496 }
497
498 print_missing_install_hint(&extension);
499 Ok(1)
500 }
501
502 fn try_auto_install_extension(
505 &self,
506 extension: &str,
507 ) -> Result<Option<PathBuf>, LauncherError> {
508 let manifest = manifest_url(extension, None);
509 tracing::debug!("auto-install manifest={manifest}");
510
511 let binary_name = format!("tempo-{extension}");
512
513 let installer = Installer::from_env(self.exe_dir.as_deref())?;
514 match installer.install(
515 extension,
516 &InstallSource {
517 manifest: Some(manifest),
518 public_key: Some(release_public_key()),
519 },
520 false,
521 false,
522 ) {
523 Ok(result) => {
524 let mut registry = Registry::load().map_err(LauncherError::Registry)?;
525 registry.record_check(extension, &result.version, false, &result.description);
526 registry.save();
527 Ok(self.find_binary(&binary_name))
528 }
529 Err(InstallerError::ReleaseManifestNotFound(_))
530 | Err(InstallerError::ExtensionNotInManifest(_)) => Ok(None),
531 Err(InstallerError::Network(err))
532 if err.status() == Some(reqwest::StatusCode::NOT_FOUND) =>
533 {
534 Ok(None)
535 }
536 Err(err) => Err(err.into()),
537 }
538 }
539
540 fn maybe_auto_update(&self, extension: &str) -> Result<(), LauncherError> {
546 if env::var_os("TEMPO_HOME").is_some() {
549 return Ok(());
550 }
551
552 let mut registry = Registry::load().map_err(LauncherError::Registry)?;
553 if !registry.needs_update_check(extension) {
554 return Ok(());
555 }
556
557 let installed_version = registry
558 .extensions
559 .get(extension)
560 .map(|e| e.installed_version.as_str());
561
562 let installer = match Installer::from_env(self.exe_dir.as_deref()) {
563 Ok(i) => i,
564 Err(_) => {
565 registry.touch_check(extension);
566 registry.save();
567 return Ok(());
568 }
569 };
570
571 let source = InstallSource {
572 manifest: Some(manifest_url(extension, None)),
573 public_key: Some(release_public_key()),
574 };
575
576 if registry.is_pinned(extension) {
577 if let Ok(Some(new_version)) =
580 Installer::check_latest_version(&source, installed_version)
581 {
582 eprintln!(
583 "tempo-{extension} {new_version} available (pinned to {}; run `tempo update {extension}` to upgrade)",
584 installed_version.unwrap_or("unknown")
585 );
586 }
587 registry.touch_check(extension);
588 } else {
589 match installer.install_if_changed(extension, &source, installed_version) {
590 Ok(Some(result)) => {
591 if installed_version.is_some_and(|v| !v.is_empty()) {
592 eprintln!("updated tempo-{extension} to {}", result.version);
593 }
594 registry.record_check(extension, &result.version, false, &result.description);
595 }
596 Ok(None) => {
597 registry.touch_check(extension);
598 }
599 Err(err) => {
600 tracing::debug!("auto-update: failed for {extension}: {err}");
601 registry.touch_check(extension);
602 }
603 }
604 }
605 registry.save();
606 Ok(())
607 }
608
609 fn warn_path_mismatch(&self, binary_path: &Path) {
614 let binary_dir = match binary_path.parent() {
615 Some(d) => d,
616 None => return,
617 };
618 let install_dir = match Installer::from_env(self.exe_dir.as_deref()) {
619 Ok(i) => i.bin_dir,
620 Err(_) => return,
621 };
622 if binary_dir != install_dir {
623 let name = binary_path
624 .file_name()
625 .unwrap_or_default()
626 .to_string_lossy();
627 tracing::warn!(
628 binary = %name,
629 found_in = %binary_dir.display(),
630 install_dir = %install_dir.display(),
631 "extension binary found in a different directory than the install target; updates may not take effect",
632 );
633 }
634 }
635
636 fn find_binary(&self, binary: &str) -> Option<PathBuf> {
638 let candidates = binary_candidates(binary);
639
640 if let Some(dir) = &self.exe_dir {
642 for name in &candidates {
643 let path = dir.join(name);
644 if path.is_file() {
645 return Some(path);
646 }
647 }
648 }
649
650 if let Some(dir) = &fallback_bin_dir()
654 && self.exe_dir.as_deref() != Some(dir.as_path())
655 {
656 for name in &candidates {
657 let path = dir.join(name);
658 if path.is_file() {
659 return Some(path);
660 }
661 }
662 }
663
664 find_in_path(binary)
666 }
667}
668
669fn base_url() -> String {
671 env::var("TEMPO_EXT_BASE_URL").unwrap_or_else(|_| BASE_URL.to_string())
672}
673
674fn release_public_key() -> String {
675 #[cfg(debug_assertions)]
679 if let Ok(key) = env::var("TEMPO_EXT_PUBLIC_KEY") {
680 return key;
681 }
682 PUBLIC_KEY.to_string()
683}
684
685fn manifest_url(extension: &str, version: Option<&str>) -> String {
687 let base = base_url();
688 let base = base.trim_end_matches('/');
689 match version {
690 Some(v) => {
691 let v = v.strip_prefix('v').unwrap_or(v);
692 format!("{base}/extensions/tempo-{extension}/v{v}/manifest.json")
693 }
694 None => format!("{base}/extensions/tempo-{extension}/manifest.json"),
695 }
696}
697
698fn run_child(binary: PathBuf, args: &[OsString], display_name: &str) -> Result<i32, LauncherError> {
700 tracing::debug!("exec {} args={args:?}", binary.display());
701
702 let mut cmd = Command::new(&binary);
703
704 #[cfg(unix)]
705 {
706 use std::os::unix::process::CommandExt;
707 cmd.arg0(display_name);
708 }
709
710 let status = cmd.args(args).status()?;
711 let code = status.code().unwrap_or_else(|| {
712 #[cfg(unix)]
713 {
714 use std::os::unix::process::ExitStatusExt;
715 if let Some(sig) = status.signal() {
716 return 128 + sig;
717 }
718 }
719 1
720 });
721 Ok(code)
722}
723
724fn is_valid_extension_name(name: &str) -> bool {
726 !name.is_empty()
727 && name
728 .bytes()
729 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
730}
731
732fn print_missing_install_hint(extension: &str) {
734 println!("Unknown command '{extension}' and no compatible extension found.");
735 println!("Run: tempo add {extension}");
736}
737
738#[cfg(test)]
739mod tests {
740 use super::{
741 BASE_URL, Cli, Commands, PUBLIC_KEY, base_url, is_valid_extension_name, manifest_url,
742 release_public_key,
743 };
744 use crate::{installer::is_allowed_manifest_url, test_util::ENV_MUTEX};
745 use clap::Parser;
746
747 #[test]
748 fn runtime_manifest_url_policy_enforces_https_or_local() {
749 assert!(is_allowed_manifest_url(
750 "https://cli.tempo.xyz/tempo-wallet/manifest.json"
751 ));
752 assert!(is_allowed_manifest_url("file:///tmp/manifest.json"));
753 assert!(is_allowed_manifest_url("./manifest.json"));
754 assert!(is_allowed_manifest_url("/tmp/manifest.json"));
755 assert!(!is_allowed_manifest_url(
756 "http://insecure.example.com/manifest.json"
757 ));
758 assert!(!is_allowed_manifest_url("ftp://example.com/manifest.json"));
759 }
760
761 #[test]
762 fn manifest_url_uses_expected_format() {
763 let _lock = ENV_MUTEX.lock().unwrap();
764 let _guard = EnvGuard::new("TEMPO_EXT_BASE_URL");
765 assert_eq!(
766 manifest_url("wallet", None),
767 "https://cli.tempo.xyz/extensions/tempo-wallet/manifest.json"
768 );
769
770 assert_eq!(
771 manifest_url("wallet", Some("0.2.0")),
772 "https://cli.tempo.xyz/extensions/tempo-wallet/v0.2.0/manifest.json"
773 );
774
775 assert_eq!(
776 manifest_url("wallet", Some("v0.2.0")),
777 "https://cli.tempo.xyz/extensions/tempo-wallet/v0.2.0/manifest.json",
778 "v-prefix should not be doubled"
779 );
780 }
781
782 #[test]
783 fn valid_extension_names() {
784 assert!(is_valid_extension_name("wallet"));
785 assert!(is_valid_extension_name("my-ext"));
786 assert!(is_valid_extension_name("my_ext"));
787 assert!(is_valid_extension_name("ext123"));
788 }
789
790 #[test]
791 fn invalid_extension_names() {
792 assert!(!is_valid_extension_name(""));
793 assert!(!is_valid_extension_name("../evil"));
794 assert!(!is_valid_extension_name("foo/bar"));
795 assert!(!is_valid_extension_name("foo bar"));
796 assert!(!is_valid_extension_name(".hidden"));
797 }
798
799 fn parse(args: &[&str]) -> Cli {
800 Cli::try_parse_from(args).unwrap()
801 }
802
803 fn parse_err(args: &[&str]) -> clap::Error {
804 Cli::try_parse_from(args).unwrap_err()
805 }
806
807 #[test]
808 fn parse_add_extension_only() {
809 let cli = parse(&["tempo", "add", "wallet"]);
810 match cli.command {
811 Commands::Add(ref args) => {
812 assert_eq!(args.extension, "wallet");
813 assert_eq!(args.version, None);
814 assert!(!args.dry_run);
815 assert!(args.manifest.is_none());
816 }
817 _ => panic!("expected Add"),
818 }
819 }
820
821 #[test]
822 fn parse_add_extension_and_version() {
823 let cli = parse(&["tempo", "add", "wallet", "1.0.0"]);
824 match cli.command {
825 Commands::Add(ref args) => {
826 assert_eq!(args.extension, "wallet");
827 assert_eq!(args.version, Some("1.0.0".to_string()));
828 }
829 _ => panic!("expected Add"),
830 }
831 }
832
833 #[test]
834 fn parse_add_with_dry_run() {
835 let cli = parse(&["tempo", "add", "wallet", "--dry-run"]);
836 match cli.command {
837 Commands::Add(ref args) => assert!(args.dry_run),
838 _ => panic!("expected Add"),
839 }
840 }
841
842 #[test]
843 fn parse_add_with_manifest() {
844 let cli = parse(&[
845 "tempo",
846 "add",
847 "wallet",
848 "--release-manifest",
849 "https://example.com/m.json",
850 ]);
851 match cli.command {
852 Commands::Add(ref args) => {
853 assert_eq!(
854 args.manifest,
855 Some("https://example.com/m.json".to_string())
856 );
857 }
858 _ => panic!("expected Add"),
859 }
860 }
861
862 #[test]
863 fn parse_add_with_public_key() {
864 let cli = parse(&["tempo", "add", "wallet", "--release-public-key", "abc123"]);
865 match cli.command {
866 Commands::Add(ref args) => {
867 assert_eq!(args.public_key, Some("abc123".to_string()));
868 }
869 _ => panic!("expected Add"),
870 }
871 }
872
873 #[test]
874 fn parse_list() {
875 let cli = parse(&["tempo", "list"]);
876 assert!(matches!(cli.command, Commands::List));
877 }
878
879 #[test]
880 fn parse_remove() {
881 let cli = parse(&["tempo", "remove", "wallet"]);
882 assert!(matches!(cli.command, Commands::Remove(_)));
883 }
884
885 #[test]
886 fn parse_update_with_extension() {
887 let cli = parse(&["tempo", "update", "wallet"]);
888 match cli.command {
889 Commands::Update(ref args) => {
890 assert_eq!(args.extension.as_deref(), Some("wallet"));
891 assert_eq!(args.version, None);
892 }
893 _ => panic!("expected Update"),
894 }
895 }
896
897 #[test]
898 fn parse_update_no_args() {
899 let cli = parse(&["tempo", "update"]);
900 match cli.command {
901 Commands::Update(ref args) => {
902 assert!(args.extension.is_none());
903 }
904 _ => panic!("expected Update"),
905 }
906 }
907
908 #[test]
909 fn parse_add_missing_extension() {
910 let _ = parse_err(&["tempo", "add"]);
911 }
912
913 #[test]
914 fn parse_add_unknown_flag() {
915 let _ = parse_err(&["tempo", "add", "wallet", "--unknown"]);
916 }
917
918 #[test]
919 fn parse_add_manifest_missing_value() {
920 let _ = parse_err(&["tempo", "add", "wallet", "--release-manifest"]);
921 }
922
923 #[test]
924 fn parse_external_subcommand() {
925 let cli = parse(&["tempo", "wallet", "--help"]);
926 match cli.command {
927 Commands::Extension(ref args) => {
928 assert_eq!(args[0], "wallet");
929 assert_eq!(args[1], "--help");
930 }
931 _ => panic!("expected Extension"),
932 }
933 }
934
935 #[test]
936 fn parse_external_subcommand_preserves_all_args() {
937 let cli = parse(&["tempo", "wallet", "login", "--verbose", "extra"]);
938 match cli.command {
939 Commands::Extension(ref args) => {
940 assert_eq!(args.len(), 4);
941 assert_eq!(args[0], "wallet");
942 assert_eq!(args[1], "login");
943 assert_eq!(args[2], "--verbose");
944 assert_eq!(args[3], "extra");
945 }
946 _ => panic!("expected Extension"),
947 }
948 }
949
950 #[test]
951 fn parse_add_too_many_positional() {
952 let _ = parse_err(&["tempo", "add", "wallet", "1.0.0", "extra"]);
953 }
954
955 #[test]
956 fn parse_remove_extension_only() {
957 let cli = parse(&["tempo", "remove", "wallet"]);
958 match cli.command {
959 Commands::Remove(ref args) => {
960 assert_eq!(args.extension, "wallet");
961 assert!(!args.dry_run);
962 }
963 _ => panic!("expected Remove"),
964 }
965 }
966
967 #[test]
968 fn parse_remove_with_dry_run() {
969 let cli = parse(&["tempo", "remove", "wallet", "--dry-run"]);
970 match cli.command {
971 Commands::Remove(ref args) => assert!(args.dry_run),
972 _ => panic!("expected Remove"),
973 }
974 }
975
976 #[test]
977 fn parse_remove_rejects_manifest_flag() {
978 let _ = parse_err(&["tempo", "remove", "wallet", "--release-manifest", "url"]);
979 }
980
981 #[test]
982 fn parse_remove_rejects_version() {
983 let _ = parse_err(&["tempo", "remove", "wallet", "1.0.0"]);
984 }
985
986 #[test]
987 fn base_url_defaults_to_constant() {
988 let _lock = ENV_MUTEX.lock().unwrap();
989 let _guard = EnvGuard::new("TEMPO_EXT_BASE_URL");
991 assert_eq!(base_url(), BASE_URL);
992 }
993
994 #[test]
995 fn base_url_respects_env_override() {
996 let _lock = ENV_MUTEX.lock().unwrap();
997 let _guard = EnvGuard::set("TEMPO_EXT_BASE_URL", "https://custom.example.com");
998 assert_eq!(base_url(), "https://custom.example.com");
999 }
1000
1001 #[test]
1002 fn release_public_key_defaults_to_constant() {
1003 let _lock = ENV_MUTEX.lock().unwrap();
1004 let _guard = EnvGuard::new("TEMPO_EXT_PUBLIC_KEY");
1005 assert_eq!(release_public_key(), PUBLIC_KEY);
1006 }
1007
1008 #[test]
1009 fn release_public_key_respects_env_override() {
1010 let _lock = ENV_MUTEX.lock().unwrap();
1011 let _guard = EnvGuard::set("TEMPO_EXT_PUBLIC_KEY", "custom-key");
1012 assert_eq!(release_public_key(), "custom-key");
1013 }
1014
1015 #[test]
1016 fn manifest_url_with_custom_base_url() {
1017 let _lock = ENV_MUTEX.lock().unwrap();
1018 let _guard = EnvGuard::set("TEMPO_EXT_BASE_URL", "https://custom.example.com/");
1019 assert_eq!(
1020 manifest_url("wallet", None),
1021 "https://custom.example.com/extensions/tempo-wallet/manifest.json"
1022 );
1023 }
1024
1025 #[test]
1026 fn manifest_url_trims_trailing_slashes() {
1027 let _lock = ENV_MUTEX.lock().unwrap();
1028 let _guard = EnvGuard::set("TEMPO_EXT_BASE_URL", "https://example.com///");
1029 assert_eq!(
1030 manifest_url("wallet", None),
1031 "https://example.com/extensions/tempo-wallet/manifest.json"
1032 );
1033 }
1034
1035 #[test]
1036 fn is_valid_extension_name_single_chars() {
1037 assert!(is_valid_extension_name("a"));
1038 assert!(is_valid_extension_name("-"));
1039 assert!(is_valid_extension_name("_"));
1040 }
1041
1042 #[test]
1043 fn is_valid_extension_name_rejects_special() {
1044 assert!(!is_valid_extension_name("foo@bar"));
1045 assert!(!is_valid_extension_name("a b"));
1046 assert!(!is_valid_extension_name("foo\0bar"));
1047 assert!(!is_valid_extension_name("foo!bar"));
1048 }
1049
1050 struct EnvGuard {
1052 key: &'static str,
1053 prev: Option<String>,
1054 }
1055
1056 impl EnvGuard {
1057 fn new(key: &'static str) -> Self {
1058 let prev = std::env::var(key).ok();
1059 unsafe { std::env::remove_var(key) };
1060 Self { key, prev }
1061 }
1062
1063 fn set(key: &'static str, value: &str) -> Self {
1064 let prev = std::env::var(key).ok();
1065 unsafe { std::env::set_var(key, value) };
1066 Self { key, prev }
1067 }
1068 }
1069
1070 impl Drop for EnvGuard {
1071 fn drop(&mut self) {
1072 match &self.prev {
1073 Some(v) => unsafe { std::env::set_var(self.key, v) },
1074 None => unsafe { std::env::remove_var(self.key) },
1075 }
1076 }
1077 }
1078}