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 .env("TEMPO_BIN_DIR", bin_dir)
178 .status()?;
179 if !install_status.success() {
180 tracing::error!("failed to install tempoup");
181 return Ok(false);
182 }
183
184 return Ok(true);
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 {
690 let base = base_url();
691 let base = base.trim_end_matches('/');
692 let extension = extension.strip_prefix("tempo-").unwrap_or(extension);
693 match version {
694 Some(v) => {
695 let v = v.strip_prefix('v').unwrap_or(v);
696 format!("{base}/extensions/tempo-{extension}/v{v}/manifest.json")
697 }
698 None => format!("{base}/extensions/tempo-{extension}/manifest.json"),
699 }
700}
701
702fn run_child(binary: PathBuf, args: &[OsString], display_name: &str) -> Result<i32, LauncherError> {
704 tracing::debug!("exec {} args={args:?}", binary.display());
705
706 let mut cmd = Command::new(&binary);
707
708 #[cfg(unix)]
709 {
710 use std::os::unix::process::CommandExt;
711 cmd.arg0(display_name);
712 }
713
714 let status = cmd.args(args).status()?;
715 let code = status.code().unwrap_or_else(|| {
716 #[cfg(unix)]
717 {
718 use std::os::unix::process::ExitStatusExt;
719 if let Some(sig) = status.signal() {
720 return 128 + sig;
721 }
722 }
723 1
724 });
725 Ok(code)
726}
727
728fn is_valid_extension_name(name: &str) -> bool {
730 !name.is_empty()
731 && name
732 .bytes()
733 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
734}
735
736fn print_missing_install_hint(extension: &str) {
738 println!("Unknown command '{extension}' and no compatible extension found.");
739 println!("Run: tempo add {extension}");
740}
741
742#[cfg(test)]
743mod tests {
744 use super::{
745 BASE_URL, Cli, Commands, PUBLIC_KEY, base_url, is_valid_extension_name, manifest_url,
746 release_public_key,
747 };
748 use crate::{installer::is_allowed_manifest_url, test_util::ENV_MUTEX};
749 use clap::Parser;
750
751 #[test]
752 fn runtime_manifest_url_policy_enforces_https_or_local() {
753 assert!(is_allowed_manifest_url(
754 "https://cli.tempo.xyz/tempo-wallet/manifest.json"
755 ));
756 assert!(is_allowed_manifest_url("file:///tmp/manifest.json"));
757 assert!(is_allowed_manifest_url("./manifest.json"));
758 assert!(is_allowed_manifest_url("/tmp/manifest.json"));
759 assert!(!is_allowed_manifest_url(
760 "http://insecure.example.com/manifest.json"
761 ));
762 assert!(!is_allowed_manifest_url("ftp://example.com/manifest.json"));
763 }
764
765 #[test]
766 fn manifest_url_uses_expected_format() {
767 let _lock = ENV_MUTEX.lock().unwrap();
768 let _guard = EnvGuard::new("TEMPO_EXT_BASE_URL");
769 assert_eq!(
770 manifest_url("wallet", None),
771 "https://cli.tempo.xyz/extensions/tempo-wallet/manifest.json"
772 );
773
774 assert_eq!(
775 manifest_url("wallet", Some("0.2.0")),
776 "https://cli.tempo.xyz/extensions/tempo-wallet/v0.2.0/manifest.json"
777 );
778
779 assert_eq!(
780 manifest_url("wallet", Some("v0.2.0")),
781 "https://cli.tempo.xyz/extensions/tempo-wallet/v0.2.0/manifest.json",
782 "v-prefix should not be doubled"
783 );
784
785 assert_eq!(
786 manifest_url("tempo-wallet", None),
787 "https://cli.tempo.xyz/extensions/tempo-wallet/manifest.json",
788 "tempo- prefix should not be doubled"
789 );
790
791 assert_eq!(
792 manifest_url("tempo-wallet", Some("0.2.0")),
793 "https://cli.tempo.xyz/extensions/tempo-wallet/v0.2.0/manifest.json",
794 "tempo- prefix should not be doubled with version"
795 );
796 }
797
798 #[test]
799 fn valid_extension_names() {
800 assert!(is_valid_extension_name("wallet"));
801 assert!(is_valid_extension_name("my-ext"));
802 assert!(is_valid_extension_name("my_ext"));
803 assert!(is_valid_extension_name("ext123"));
804 }
805
806 #[test]
807 fn invalid_extension_names() {
808 assert!(!is_valid_extension_name(""));
809 assert!(!is_valid_extension_name("../evil"));
810 assert!(!is_valid_extension_name("foo/bar"));
811 assert!(!is_valid_extension_name("foo bar"));
812 assert!(!is_valid_extension_name(".hidden"));
813 }
814
815 fn parse(args: &[&str]) -> Cli {
816 Cli::try_parse_from(args).unwrap()
817 }
818
819 fn parse_err(args: &[&str]) -> clap::Error {
820 Cli::try_parse_from(args).unwrap_err()
821 }
822
823 #[test]
824 fn parse_add_extension_only() {
825 let cli = parse(&["tempo", "add", "wallet"]);
826 match cli.command {
827 Commands::Add(ref args) => {
828 assert_eq!(args.extension, "wallet");
829 assert_eq!(args.version, None);
830 assert!(!args.dry_run);
831 assert!(args.manifest.is_none());
832 }
833 _ => panic!("expected Add"),
834 }
835 }
836
837 #[test]
838 fn parse_add_extension_and_version() {
839 let cli = parse(&["tempo", "add", "wallet", "1.0.0"]);
840 match cli.command {
841 Commands::Add(ref args) => {
842 assert_eq!(args.extension, "wallet");
843 assert_eq!(args.version, Some("1.0.0".to_string()));
844 }
845 _ => panic!("expected Add"),
846 }
847 }
848
849 #[test]
850 fn parse_add_with_dry_run() {
851 let cli = parse(&["tempo", "add", "wallet", "--dry-run"]);
852 match cli.command {
853 Commands::Add(ref args) => assert!(args.dry_run),
854 _ => panic!("expected Add"),
855 }
856 }
857
858 #[test]
859 fn parse_add_with_manifest() {
860 let cli = parse(&[
861 "tempo",
862 "add",
863 "wallet",
864 "--release-manifest",
865 "https://example.com/m.json",
866 ]);
867 match cli.command {
868 Commands::Add(ref args) => {
869 assert_eq!(
870 args.manifest,
871 Some("https://example.com/m.json".to_string())
872 );
873 }
874 _ => panic!("expected Add"),
875 }
876 }
877
878 #[test]
879 fn parse_add_with_public_key() {
880 let cli = parse(&["tempo", "add", "wallet", "--release-public-key", "abc123"]);
881 match cli.command {
882 Commands::Add(ref args) => {
883 assert_eq!(args.public_key, Some("abc123".to_string()));
884 }
885 _ => panic!("expected Add"),
886 }
887 }
888
889 #[test]
890 fn parse_list() {
891 let cli = parse(&["tempo", "list"]);
892 assert!(matches!(cli.command, Commands::List));
893 }
894
895 #[test]
896 fn parse_remove() {
897 let cli = parse(&["tempo", "remove", "wallet"]);
898 assert!(matches!(cli.command, Commands::Remove(_)));
899 }
900
901 #[test]
902 fn parse_update_with_extension() {
903 let cli = parse(&["tempo", "update", "wallet"]);
904 match cli.command {
905 Commands::Update(ref args) => {
906 assert_eq!(args.extension.as_deref(), Some("wallet"));
907 assert_eq!(args.version, None);
908 }
909 _ => panic!("expected Update"),
910 }
911 }
912
913 #[test]
914 fn parse_update_no_args() {
915 let cli = parse(&["tempo", "update"]);
916 match cli.command {
917 Commands::Update(ref args) => {
918 assert!(args.extension.is_none());
919 }
920 _ => panic!("expected Update"),
921 }
922 }
923
924 #[test]
925 fn parse_add_missing_extension() {
926 let _ = parse_err(&["tempo", "add"]);
927 }
928
929 #[test]
930 fn parse_add_unknown_flag() {
931 let _ = parse_err(&["tempo", "add", "wallet", "--unknown"]);
932 }
933
934 #[test]
935 fn parse_add_manifest_missing_value() {
936 let _ = parse_err(&["tempo", "add", "wallet", "--release-manifest"]);
937 }
938
939 #[test]
940 fn parse_external_subcommand() {
941 let cli = parse(&["tempo", "wallet", "--help"]);
942 match cli.command {
943 Commands::Extension(ref args) => {
944 assert_eq!(args[0], "wallet");
945 assert_eq!(args[1], "--help");
946 }
947 _ => panic!("expected Extension"),
948 }
949 }
950
951 #[test]
952 fn parse_external_subcommand_preserves_all_args() {
953 let cli = parse(&["tempo", "wallet", "login", "--verbose", "extra"]);
954 match cli.command {
955 Commands::Extension(ref args) => {
956 assert_eq!(args.len(), 4);
957 assert_eq!(args[0], "wallet");
958 assert_eq!(args[1], "login");
959 assert_eq!(args[2], "--verbose");
960 assert_eq!(args[3], "extra");
961 }
962 _ => panic!("expected Extension"),
963 }
964 }
965
966 #[test]
967 fn parse_add_too_many_positional() {
968 let _ = parse_err(&["tempo", "add", "wallet", "1.0.0", "extra"]);
969 }
970
971 #[test]
972 fn parse_remove_extension_only() {
973 let cli = parse(&["tempo", "remove", "wallet"]);
974 match cli.command {
975 Commands::Remove(ref args) => {
976 assert_eq!(args.extension, "wallet");
977 assert!(!args.dry_run);
978 }
979 _ => panic!("expected Remove"),
980 }
981 }
982
983 #[test]
984 fn parse_remove_with_dry_run() {
985 let cli = parse(&["tempo", "remove", "wallet", "--dry-run"]);
986 match cli.command {
987 Commands::Remove(ref args) => assert!(args.dry_run),
988 _ => panic!("expected Remove"),
989 }
990 }
991
992 #[test]
993 fn parse_remove_rejects_manifest_flag() {
994 let _ = parse_err(&["tempo", "remove", "wallet", "--release-manifest", "url"]);
995 }
996
997 #[test]
998 fn parse_remove_rejects_version() {
999 let _ = parse_err(&["tempo", "remove", "wallet", "1.0.0"]);
1000 }
1001
1002 #[test]
1003 fn base_url_defaults_to_constant() {
1004 let _lock = ENV_MUTEX.lock().unwrap();
1005 let _guard = EnvGuard::new("TEMPO_EXT_BASE_URL");
1007 assert_eq!(base_url(), BASE_URL);
1008 }
1009
1010 #[test]
1011 fn base_url_respects_env_override() {
1012 let _lock = ENV_MUTEX.lock().unwrap();
1013 let _guard = EnvGuard::set("TEMPO_EXT_BASE_URL", "https://custom.example.com");
1014 assert_eq!(base_url(), "https://custom.example.com");
1015 }
1016
1017 #[test]
1018 fn release_public_key_defaults_to_constant() {
1019 let _lock = ENV_MUTEX.lock().unwrap();
1020 let _guard = EnvGuard::new("TEMPO_EXT_PUBLIC_KEY");
1021 assert_eq!(release_public_key(), PUBLIC_KEY);
1022 }
1023
1024 #[test]
1025 fn release_public_key_respects_env_override() {
1026 let _lock = ENV_MUTEX.lock().unwrap();
1027 let _guard = EnvGuard::set("TEMPO_EXT_PUBLIC_KEY", "custom-key");
1028 assert_eq!(release_public_key(), "custom-key");
1029 }
1030
1031 #[test]
1032 fn manifest_url_with_custom_base_url() {
1033 let _lock = ENV_MUTEX.lock().unwrap();
1034 let _guard = EnvGuard::set("TEMPO_EXT_BASE_URL", "https://custom.example.com/");
1035 assert_eq!(
1036 manifest_url("wallet", None),
1037 "https://custom.example.com/extensions/tempo-wallet/manifest.json"
1038 );
1039 }
1040
1041 #[test]
1042 fn manifest_url_trims_trailing_slashes() {
1043 let _lock = ENV_MUTEX.lock().unwrap();
1044 let _guard = EnvGuard::set("TEMPO_EXT_BASE_URL", "https://example.com///");
1045 assert_eq!(
1046 manifest_url("wallet", None),
1047 "https://example.com/extensions/tempo-wallet/manifest.json"
1048 );
1049 }
1050
1051 #[test]
1052 fn is_valid_extension_name_single_chars() {
1053 assert!(is_valid_extension_name("a"));
1054 assert!(is_valid_extension_name("-"));
1055 assert!(is_valid_extension_name("_"));
1056 }
1057
1058 #[test]
1059 fn is_valid_extension_name_rejects_special() {
1060 assert!(!is_valid_extension_name("foo@bar"));
1061 assert!(!is_valid_extension_name("a b"));
1062 assert!(!is_valid_extension_name("foo\0bar"));
1063 assert!(!is_valid_extension_name("foo!bar"));
1064 }
1065
1066 struct EnvGuard {
1068 key: &'static str,
1069 prev: Option<String>,
1070 }
1071
1072 impl EnvGuard {
1073 fn new(key: &'static str) -> Self {
1074 let prev = std::env::var(key).ok();
1075 unsafe { std::env::remove_var(key) };
1076 Self { key, prev }
1077 }
1078
1079 fn set(key: &'static str, value: &str) -> Self {
1080 let prev = std::env::var(key).ok();
1081 unsafe { std::env::set_var(key, value) };
1082 Self { key, prev }
1083 }
1084 }
1085
1086 impl Drop for EnvGuard {
1087 fn drop(&mut self) {
1088 match &self.prev {
1089 Some(v) => unsafe { std::env::set_var(self.key, v) },
1090 None => unsafe { std::env::remove_var(self.key) },
1091 }
1092 }
1093 }
1094}