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                .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
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.
686fn 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
698/// Executes the extension binary with the given arguments and returns the exit code.
699fn 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
724/// Validates an extension name: non-empty, ASCII alphanumeric plus `-` and `_`.
725fn 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
732/// Prints a user-facing hint when an unknown subcommand has no matching extension.
733fn 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        // Clear any env override to test the default.
990        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    /// RAII guard that saves and restores an environment variable.
1051    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}