Skip to main content

tempo_ext/
launcher.rs

1//! Routes `tempo <extension>` to the right binary, handles auto-install
2//! of missing extensions, and provides built-in commands (add/update/remove).
3
4use 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
36/// Parses arguments and dispatches to built-in commands (add/update/remove/list)
37/// or extension subcommands. This is the entry point for the `tempo` CLI.
38pub 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            // Let clap handle --help and --version by printing and exiting.
53            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/// Extension manager for the Tempo CLI.
73#[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    /// Install an extension (e.g., `tempo add wallet`).
87    #[command(after_help = "Examples:\n  tempo add wallet\n  tempo add wallet 0.2.0")]
88    Add(ManagementArgs),
89
90    /// Update tempo and/or extensions. Without arguments, updates tempo
91    /// itself via tempoup and then updates all installed extensions.
92    #[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    /// Remove an extension.
98    #[command(after_help = "Example: tempo remove wallet")]
99    Remove(RemoveArgs),
100
101    /// List installed extensions.
102    List,
103
104    /// External extension subcommand.
105    #[command(external_subcommand)]
106    Extension(Vec<OsString>),
107}
108
109#[derive(Parser, Debug)]
110struct ManagementArgs {
111    /// Extension name (e.g., wallet, mpp).
112    extension: String,
113
114    /// Version to install (e.g., 0.2.0).
115    version: Option<String>,
116
117    /// URL of the signed release manifest.
118    #[arg(long = "release-manifest")]
119    manifest: Option<String>,
120
121    /// Base64-encoded public key for manifest verification.
122    #[arg(long = "release-public-key")]
123    public_key: Option<String>,
124
125    /// Show what would be done without making changes.
126    #[arg(long)]
127    dry_run: bool,
128}
129
130#[derive(Parser, Debug)]
131struct UpdateArgs {
132    /// Extension name. If omitted, updates tempo itself and all installed extensions.
133    extension: Option<String>,
134
135    /// Version to install (e.g., 0.2.0). Only valid with an extension name.
136    version: Option<String>,
137
138    /// URL of the signed release manifest.
139    #[arg(long = "release-manifest")]
140    manifest: Option<String>,
141
142    /// Base64-encoded public key for manifest verification.
143    #[arg(long = "release-public-key")]
144    public_key: Option<String>,
145
146    /// Show what would be done without making changes.
147    #[arg(long)]
148    dry_run: bool,
149}
150
151#[derive(Parser, Debug)]
152struct RemoveArgs {
153    /// Extension name (e.g., wallet, mpp).
154    extension: String,
155
156    /// Show what would be done without making changes.
157    #[arg(long)]
158    dry_run: bool,
159}
160
161/// Runs `tempoup` to update the tempo binary itself.
162///
163/// Passes `TEMPO_BIN_DIR` so tempoup installs into the same directory as the
164/// running binary. If tempoup is not found on `PATH`, it is installed first
165/// via `https://tempo.xyz/install`.
166fn 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
191/// Internal dispatcher that holds the directory of the running `tempo` binary
192/// and implements all built-in and extension subcommands.
193struct Launcher {
194    /// Directory containing the `tempo` binary, used to co-locate extensions.
195    exe_dir: Option<PathBuf>,
196}
197
198impl Launcher {
199    /// Installs an extension, recording the result in the registry.
200    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    /// Handles `tempo update [extension]`.
236    ///
237    /// Without an extension name, updates tempo itself via `tempoup` and then
238    /// updates all installed extensions. With an extension name, only updates
239    /// that extension (and unpins it). With an explicit version, behaves like
240    /// `add`.
241    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        // Explicit version: user knows what they want, treat like `add`.
253        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    /// Updates tempo itself via `tempoup`, then updates all installed extensions.
329    fn handle_update_all(&self, dry_run: bool) -> Result<i32, LauncherError> {
330        let installer = Installer::from_env(self.exe_dir.as_deref())?;
331
332        // 1. Update tempo itself via tempoup.
333        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        // 2. Update all installed extensions (skip pinned ones).
343        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    /// Removes an extension's binary, skill files, and registry entry.
402    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    /// Prints a table of installed extensions with version and metadata.
422    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    /// Dispatches to an external extension binary.
464    ///
465    /// `ext_args` comes from clap's `external_subcommand` — the first element
466    /// is the subcommand name, the rest are arguments to forward as-is.
467    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        // Try to auto-install as an extension.
487        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    /// Attempts to install an unknown extension from the default manifest URL.
503    /// Returns the binary path on success, `None` if the extension doesn't exist.
504    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    /// Checks for extension updates and installs if a newer version is available.
541    ///
542    /// Runs at most once every 6 hours per extension. Update-check and
543    /// install failures are silent — the existing binary is always used —
544    /// but a corrupt registry is surfaced to the caller.
545    fn maybe_auto_update(&self, extension: &str) -> Result<(), LauncherError> {
546        // TEMPO_HOME indicates a managed or test environment where updates
547        // should be explicit (via `tempo update`), not automatic.
548        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            // Pinned to a specific version — check for updates but don't
578            // install. Only fetch the manifest to compare versions.
579            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    /// Warns if the binary we found is not in the directory where the
610    /// installer would place new versions. This happens when exe_dir is
611    /// read-only — updates go to `~/.local/bin` but `find_binary` keeps
612    /// discovering the stale copy next to the running executable.
613    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    /// Searches for an extension binary: exe_dir, fallback bin dir, then `PATH`.
637    fn find_binary(&self, binary: &str) -> Option<PathBuf> {
638        let candidates = binary_candidates(binary);
639
640        // 1. Check next to the running binary.
641        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        // 2. Check the fallback install directory (~/.local/bin or
651        //    TEMPO_HOME/bin) in case exe_dir wasn't writable when the
652        //    extension was installed.
653        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        // 3. Search PATH.
665        find_in_path(binary)
666    }
667}
668
669/// Returns the base URL for extension manifests (`TEMPO_EXT_BASE_URL` or the default).
670fn 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    // Allow overriding the release public key only in debug/test builds.
676    // In release builds the key is always the compiled-in constant to
677    // prevent environment-based signature bypass attacks.
678    #[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
685/// Builds the manifest URL for an extension, optionally pinned to a version.
686///
687/// Strips `tempo-` and `v` prefixes from the extension name and version respectively
688/// to avoid double-prefixing (e.g. `tempo add tempo-wallet` resolves correctly).
689fn 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
702/// Executes the extension binary with the given arguments and returns the exit code.
703fn 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
728/// Validates an extension name: non-empty, ASCII alphanumeric plus `-` and `_`.
729fn 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
736/// Prints a user-facing hint when an unknown subcommand has no matching extension.
737fn 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        // Clear any env override to test the default.
1006        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    /// RAII guard that saves and restores an environment variable.
1067    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}