diff --git a/Cargo.lock b/Cargo.lock index bce0de8ba..972571262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5137,6 +5137,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "shell_completion" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73937c192504363290613e241705a02dff92ae7c03f544e2a69bbef24cc1042c" + [[package]] name = "shellexpand" version = "2.1.2" @@ -5369,6 +5375,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "shell_completion", "spin-app", "spin-build", "spin-common", diff --git a/Cargo.toml b/Cargo.toml index 652a0156f..b3cca04aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.82" sha2 = "0.10.2" +shell_completion = "0.0.2" terminal = { path = "crates/terminal" } spin-app = { path = "crates/app" } spin-build = { path = "crates/build" } @@ -115,3 +116,7 @@ spin-componentize = { git = "https://github.com/fermyon/spin-componentize", rev [[bin]] name = "spin" path = "src/bin/spin.rs" + +[[bin]] +name = "_spin_completions" +path = "src/bin/completions.rs" diff --git a/crates/trigger-http/src/lib.rs b/crates/trigger-http/src/lib.rs index e5778610a..4ca8598e3 100644 --- a/crates/trigger-http/src/lib.rs +++ b/crates/trigger-http/src/lib.rs @@ -60,11 +60,11 @@ pub struct CliArgs { pub address: SocketAddr, /// The path to the certificate to use for https, if this is not set, normal http will be used. The cert should be in PEM format - #[clap(long, env = "SPIN_TLS_CERT", requires = "tls-key")] + #[clap(long, env = "SPIN_TLS_CERT", requires = "tls-key", value_hint = clap::ValueHint::FilePath)] pub tls_cert: Option, /// The path to the certificate key to use for https, if this is not set, normal http will be used. The key should be in PKCS#8 format - #[clap(long, env = "SPIN_TLS_KEY", requires = "tls-cert")] + #[clap(long, env = "SPIN_TLS_KEY", requires = "tls-cert", value_hint = clap::ValueHint::FilePath)] pub tls_key: Option, } diff --git a/crates/trigger/src/cli.rs b/crates/trigger/src/cli.rs index 091eaa2ec..5109a55bc 100644 --- a/crates/trigger/src/cli.rs +++ b/crates/trigger/src/cli.rs @@ -42,6 +42,7 @@ where name = APP_LOG_DIR, short = 'L', long = "log-dir", + value_hint = clap::ValueHint::DirPath, )] pub log: Option, @@ -61,6 +62,7 @@ where long = "cache", env = WASMTIME_CACHE_FILE, conflicts_with = DISABLE_WASMTIME_CACHE, + value_hint = clap::ValueHint::FilePath, )] pub cache: Option, @@ -94,6 +96,7 @@ where name = RUNTIME_CONFIG_FILE, long = "runtime-config-file", env = RUNTIME_CONFIG_FILE, + value_hint = clap::ValueHint::FilePath, )] pub runtime_config_file: Option, @@ -103,7 +106,7 @@ where /// For local apps, this defaults to `.spin/` relative to the `spin.toml` file. /// For remote apps, this has no default (unset). /// Passing an empty value forces the value to be unset. - #[clap(long)] + #[clap(long, value_hint = clap::ValueHint::DirPath)] pub state_dir: Option, #[clap(flatten)] diff --git a/src/bin/completions.rs b/src/bin/completions.rs new file mode 100644 index 000000000..13cf4aa57 --- /dev/null +++ b/src/bin/completions.rs @@ -0,0 +1,302 @@ +use clap::CommandFactory; +use shell_completion::{CompletionInput, CompletionSet}; +use spin_cli::SpinApp; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let input = shell_completion::BashCompletionInput::from_env().unwrap(); + complete(input).await.suggest(); + Ok(()) +} + +async fn complete(input: impl CompletionInput) -> Vec { + match input.arg_index() { + 0 => unreachable!(), + 1 => complete_spin_commands(input), + _ => { + let sc = input.args()[1].to_owned(); + complete_spin_subcommand(&sc, input).await + } + } +} + +fn complete_spin_commands(input: impl CompletionInput) -> Vec { + let command = SpinApp::command(); + + // --help and --version don't show up as options so this doesn't complete them, + // but I'm not going to lose much sleep over that. + + // TODO: this doesn't currently offer plugin names as completions. + + let candidates = command + .get_subcommands() + .filter(|c| !c.is_hide_set()) + .map(|c| c.get_name()); + input.complete_subcommand(candidates) +} + +async fn complete_spin_subcommand(name: &str, input: impl CompletionInput) -> Vec { + let command = SpinApp::command().to_owned(); + let Some(subcommand) = command.find_subcommand(name) else { + return vec![]; // TODO: is there a way to hand off to a plugin? + }; + let subcommand = subcommand.to_owned(); + + if subcommand.has_subcommands() { + // TODO: make this properly recursive instead of hardwiring to 2 levels of subcommand + if input.arg_index() <= 2 { + let sub_subcommands = subcommand + .get_subcommands() + .filter(|c| !c.is_hide_set()) + .map(|c| c.get_name()); + return input.complete_subcommand(sub_subcommands); + } else { + let ssc = input.args()[2]; + let Some(sub_subcommand) = subcommand.find_subcommand(ssc) else { + return vec![]; + }; + let sub_subcommand = sub_subcommand.to_owned(); + return complete_cmd(sub_subcommand, 2, input).await; + } + } + + complete_cmd(subcommand, 1, input).await +} + +async fn complete_cmd( + cmd: clap::Command<'_>, + depth: usize, + input: impl CompletionInput, +) -> Vec { + let subcommand_key = input.args()[1..(depth + 1)].join("-"); + let forwards_args = ["up", "build", "watch"].contains(&(subcommand_key.as_str())); // RUST Y U TREAT ME THIS WAY + + // Strategy: + // If the PREVIOUS word was a PARAMETERISED option: + // - Figure out possible values and offer them + // Otherwise (i.e. if the PREVIOUS word was a NON-OPTION (did not start with '-'), or a UNARY option): + // - If ALL positional parameters are satisfied: + // - Offer the options + // - Otherwise: + // - If the current word is EMPTY and the NEXT available positional is completable: + // - Offer the NEXT positional + // - If the current word is EMPTY and the NEXT positional is NON-COMPLETABLE: + // - Offer the options + // - If the current word is NON-EMTPY: + // - Offer the options AND the NEXT positional if completable + + // IMPORTANT: this strategy *completely breaks* for `spin up` because it technically has + // an infinitely repeatable positional parameter for `trigger_args`. Also `build` and + // `watch` which have `up_args`. + + let app = SpinApp::command(); + let mut args = cmd + .get_arguments() + .map(|a| a.to_owned()) + .collect::>(); + if forwards_args { + let trigger_args = app + .find_subcommand("trigger") + .unwrap() + .find_subcommand("http") + .unwrap() + .get_arguments() + .map(|a| a.to_owned()) + .collect::>(); + args.extend(trigger_args.into_iter()); + + if subcommand_key != "up" { + let up_args = app + .find_subcommand("up") + .unwrap() + .get_arguments() + .map(|a| a.to_owned()) + .collect::>(); + args.extend(up_args.into_iter()); + args.retain(|a| a.get_name() != "up-args"); + } + args.retain(|a| a.get_name() != "trigger-args"); + } + let prev_arg = args.iter().find(|o| o.is_match(input.previous_word())); + + // Are we in a position of completing a value-ful flag? + if let Some(prev_option) = prev_arg { + if prev_option.is_takes_value_set() { + let complete_with = CompleteWith::infer(&subcommand_key, prev_option); + return complete_with.completions(input).await; + } + } + + // No: previous word was not a flag, or was unary (or was unknown) + + // Are all positional parameters satisfied? + let num_positionals = if forwards_args { + 0 + } else { + cmd.get_positionals().count() + }; + let first_unfulfilled_positional = if num_positionals == 0 { + None + } else { + let mut num_positionals_provided = 0; + let in_progress = !(input.args().last().unwrap().is_empty()); // safe to unwrap because we are deep in subcommanery here + let mut provided = input.args().into_iter().skip(depth + 1); + let mut prev: Option<&str> = None; + let mut last_was_positional = false; + loop { + let Some(cur) = provided.next() else { + if in_progress && last_was_positional { + num_positionals_provided -= 1; + } + break; + }; + + if cur.is_empty() { + continue; + } + + let is_cur_positional = if cur.starts_with('-') { + false + } else { + // It might be a positional or it might be governed by a flag + let is_governed_by_prev = match prev { + None => false, + Some(p) => { + let matching_opt = cmd + .get_arguments() + .find(|a| a.long_and_short().contains(&p.to_string())); + match matching_opt { + None => false, // the previous thing was not an option, so cannot govern + Some(o) => o.is_takes_value_set(), + } + } + }; + !is_governed_by_prev + }; + + if is_cur_positional { + num_positionals_provided += 1; + } + + last_was_positional = is_cur_positional; + prev = Some(cur); + } + cmd.get_positionals().nth(num_positionals_provided) + }; + + match first_unfulfilled_positional { + Some(arg) => { + let complete_with = CompleteWith::infer(&subcommand_key, arg); + complete_with.completions(input).await + } + None => { + // TODO: consider positionals + let all_args: Vec<_> = args.iter().flat_map(|o| o.long_and_short()).collect(); + input.complete_subcommand(all_args.iter().map(|s| s.as_str())) + } + } +} + +trait ArgInfo { + fn long_and_short(&self) -> Vec; + + fn is_match(&self, text: &str) -> bool { + self.long_and_short().contains(&text.to_string()) + } +} + +impl<'a> ArgInfo for clap::Arg<'a> { + fn long_and_short(&self) -> Vec { + let mut result = vec![]; + if let Some(c) = self.get_short() { + result.push(format!("-{c}")); + } + if let Some(s) = self.get_long() { + result.push(format!("--{s}")); + } + result + } +} + +enum CompleteWith { + File, + Directory, + Template, + KnownPlugin, + InstalledPlugin, + None, +} + +impl CompleteWith { + fn infer(subcommand_key: &str, governing_arg: &clap::Arg) -> Self { + match governing_arg.get_value_hint() { + clap::ValueHint::FilePath => CompleteWith::File, + clap::ValueHint::DirPath => CompleteWith::Directory, + _ => Self::infer_from_names(subcommand_key, governing_arg.get_name()), + } + } + + fn infer_from_names(subcommand_key: &str, arg_name: &str) -> Self { + match (subcommand_key, arg_name) { + ("add", "template-id") => Self::Template, + ("new", "template-id") => Self::Template, + ("plugins-install", spin_cli::opts::PLUGIN_NAME_OPT) => Self::KnownPlugin, + ("plugins-uninstall", "name") => Self::InstalledPlugin, + ("plugins-upgrade", "name") => Self::InstalledPlugin, + ("templates-uninstall", "template-id") => Self::Template, + _ => Self::None, + } + } + + async fn completions(&self, input: impl CompletionInput) -> Vec { + match self { + Self::File => input.complete_file(), + Self::Directory => input.complete_directory(), + Self::Template => input.complete_text(templates().await), + Self::KnownPlugin => input.complete_text(known_plugins().await), + Self::InstalledPlugin => input.complete_text(installed_plugins().await), + Self::None => vec![], + } + } +} + +async fn templates() -> Vec { + if let Ok(mgr) = spin_templates::TemplateManager::try_default() { + if let Ok(list) = mgr.list().await { + return list + .templates + .into_iter() + .map(|t| t.id().to_string()) + .collect(); + } + } + vec![] +} + +async fn known_plugins() -> Vec { + if let Ok(mgr) = spin_plugins::manager::PluginManager::try_default() { + if let Ok(manifests) = mgr.store().catalogue_manifests() { + return manifests.into_iter().map(|m| m.name()).collect(); + } + } + vec![] +} + +async fn installed_plugins() -> Vec { + if let Ok(mgr) = spin_plugins::manager::PluginManager::try_default() { + if let Ok(manifests) = mgr.store().installed_manifests() { + return manifests.into_iter().map(|m| m.name()).collect(); + } + } + vec![] +} + +trait CompletionInputExt { + fn complete_text(&self, options: Vec) -> Vec; +} + +impl CompletionInputExt for T { + fn complete_text(&self, options: Vec) -> Vec { + self.complete_subcommand(options.iter().map(|s| s.as_str())) + } +} diff --git a/src/bin/spin.rs b/src/bin/spin.rs index a8a8a96f0..418502d7f 100644 --- a/src/bin/spin.rs +++ b/src/bin/spin.rs @@ -1,25 +1,9 @@ -use anyhow::Error; -use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches}; use is_terminal::IsTerminal; -use lazy_static::lazy_static; +use spin_cli::commands::external::execute_external_subcommand; use spin_cli::commands::external::predefined_externals; -use spin_cli::commands::{ - build::BuildCommand, - cloud::{DeployCommand, LoginCommand}, - doctor::DoctorCommand, - external::execute_external_subcommand, - new::{AddCommand, NewCommand}, - plugins::PluginCommands, - registry::RegistryCommands, - templates::TemplateCommands, - up::UpCommand, - watch::WatchCommand, -}; -use spin_cli::{build_info::*, subprocess::ExitStatusError}; -use spin_redis_engine::RedisTrigger; -use spin_trigger::cli::help::HelpArgsOnlyTrigger; -use spin_trigger::cli::TriggerExecutorCommand; -use spin_trigger_http::HttpTrigger; +use spin_cli::subprocess::ExitStatusError; +use spin_cli::SpinApp; #[tokio::main] async fn main() { @@ -97,80 +81,6 @@ fn print_error_chain(err: anyhow::Error) { } } -lazy_static! { - pub static ref VERSION: String = build_info(); -} - -/// Helper for passing VERSION to structopt. -fn version() -> &'static str { - &VERSION -} - -/// The Spin CLI -#[derive(Parser)] -#[clap( - name = "spin", - version = version() -)] -enum SpinApp { - #[clap(subcommand, alias = "template")] - Templates(TemplateCommands), - New(NewCommand), - Add(AddCommand), - Up(UpCommand), - // acts as a cross-level subcommand shortcut -> `spin cloud deploy` - Deploy(DeployCommand), - // acts as a cross-level subcommand shortcut -> `spin cloud login` - Login(LoginCommand), - #[clap(subcommand, alias = "oci")] - Registry(RegistryCommands), - Build(BuildCommand), - #[clap(subcommand, alias = "plugin")] - Plugins(PluginCommands), - #[clap(subcommand, hide = true)] - Trigger(TriggerCommands), - #[clap(external_subcommand)] - External(Vec), - Watch(WatchCommand), - Doctor(DoctorCommand), -} - -#[derive(Subcommand)] -enum TriggerCommands { - Http(TriggerExecutorCommand), - Redis(TriggerExecutorCommand), - #[clap(name = spin_cli::HELP_ARGS_ONLY_TRIGGER_TYPE, hide = true)] - HelpArgsOnly(TriggerExecutorCommand), -} - -impl SpinApp { - /// The main entry point to Spin. - pub async fn run(self, app: clap::Command<'_>) -> Result<(), Error> { - match self { - Self::Templates(cmd) => cmd.run().await, - Self::Up(cmd) => cmd.run().await, - Self::New(cmd) => cmd.run().await, - Self::Add(cmd) => cmd.run().await, - Self::Deploy(cmd) => cmd.run(SpinApp::command()).await, - Self::Login(cmd) => cmd.run(SpinApp::command()).await, - Self::Registry(cmd) => cmd.run().await, - Self::Build(cmd) => cmd.run().await, - Self::Trigger(TriggerCommands::Http(cmd)) => cmd.run().await, - Self::Trigger(TriggerCommands::Redis(cmd)) => cmd.run().await, - Self::Trigger(TriggerCommands::HelpArgsOnly(cmd)) => cmd.run().await, - Self::Plugins(cmd) => cmd.run().await, - Self::External(cmd) => execute_external_subcommand(cmd, app).await, - Self::Watch(cmd) => cmd.run().await, - Self::Doctor(cmd) => cmd.run().await, - } - } -} - -/// Returns build information, similar to: 0.1.0 (2be4034 2022-03-31). -fn build_info() -> String { - format!("{SPIN_VERSION} ({SPIN_COMMIT_SHA} {SPIN_COMMIT_DATE})") -} - struct PluginHelpEntry { name: String, about: String, diff --git a/src/commands/build.rs b/src/commands/build.rs index 32bb7b8e1..eb0518ea5 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -19,6 +19,7 @@ pub struct BuildCommand { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::FilePath, default_value = DEFAULT_MANIFEST_FILE )] pub app_source: PathBuf, diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 46506138f..c41777ed0 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -19,6 +19,7 @@ pub struct DoctorCommand { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::FilePath, default_value = DEFAULT_MANIFEST_FILE )] pub app_source: PathBuf, diff --git a/src/commands/new.rs b/src/commands/new.rs index 1d058a5ed..2dd439074 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -44,7 +44,7 @@ pub struct TemplateNewCommandCore { /// A TOML file which contains parameter values in name = "value" format. /// Parameters passed as CLI option overwrite parameters specified in the /// file. - #[clap(long = "values-file")] + #[clap(long = "values-file", value_hint = clap::ValueHint::FilePath)] pub values_file: Option, /// An optional argument that allows to skip prompts for the manifest file diff --git a/src/commands/plugins.rs b/src/commands/plugins.rs index cca9c9c9d..8c8584b5a 100644 --- a/src/commands/plugins.rs +++ b/src/commands/plugins.rs @@ -72,6 +72,7 @@ pub struct Install { name = PLUGIN_LOCAL_PLUGIN_MANIFEST_OPT, short = 'f', long = "file", + value_hint = clap::ValueHint::FilePath, conflicts_with = PLUGIN_REMOTE_PLUGIN_MANIFEST_OPT, conflicts_with = PLUGIN_NAME_OPT, )] diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 2c18f43e9..9ea5fac55 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -36,6 +36,7 @@ pub struct Push { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::FilePath, default_value = DEFAULT_MANIFEST_FILE )] pub app_source: PathBuf, diff --git a/src/commands/templates.rs b/src/commands/templates.rs index ab0746e5d..6b7eb674f 100644 --- a/src/commands/templates.rs +++ b/src/commands/templates.rs @@ -74,6 +74,7 @@ pub struct Install { #[clap( name = INSTALL_FROM_DIR_OPT, long = "dir", + value_hint = clap::ValueHint::DirPath, conflicts_with = INSTALL_FROM_GIT_OPT, )] pub dir: Option, diff --git a/src/commands/up.rs b/src/commands/up.rs index 9b7488acf..6f9835e83 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -35,6 +35,7 @@ pub struct UpCommand { name = APPLICATION_OPT, short = 'f', long = "from", + value_hint = clap::ValueHint::FilePath, group = "source", )] pub app_source: Option, @@ -45,6 +46,7 @@ pub struct UpCommand { hide = true, name = APP_MANIFEST_FILE_OPT, long = "from-file", + value_hint = clap::ValueHint::FilePath, alias = "file", group = "source", )] @@ -74,7 +76,7 @@ pub struct UpCommand { pub env: Vec<(String, String)>, /// Temporary directory for the static assets of the components. - #[clap(long = "temp")] + #[clap(long = "temp", value_hint = clap::ValueHint::DirPath)] pub tmp: Option, /// For local apps with directory mounts and no excluded files, mount them directly instead of using a temporary diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 919614f10..ee378d510 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -40,6 +40,7 @@ pub struct WatchCommand { short = 'f', long = "from", alias = "file", + value_hint = clap::ValueHint::FilePath, default_value = DEFAULT_MANIFEST_FILE )] pub app_source: PathBuf, diff --git a/src/lib.rs b/src/lib.rs index efca501ba..00512ee73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,101 @@ +use crate::build_info::*; +use crate::commands::{ + build::BuildCommand, + cloud::{DeployCommand, LoginCommand}, + doctor::DoctorCommand, + external::execute_external_subcommand, + new::{AddCommand, NewCommand}, + plugins::PluginCommands, + registry::RegistryCommands, + templates::TemplateCommands, + up::UpCommand, + watch::WatchCommand, +}; +use anyhow::Error; +use clap::{CommandFactory, Parser, Subcommand}; +use lazy_static::lazy_static; +use spin_redis_engine::RedisTrigger; +use spin_trigger::cli::help::HelpArgsOnlyTrigger; +use spin_trigger::cli::TriggerExecutorCommand; +use spin_trigger_http::HttpTrigger; + pub mod build_info; pub mod commands; -pub(crate) mod opts; +pub mod opts; pub mod subprocess; pub use opts::HELP_ARGS_ONLY_TRIGGER_TYPE; + +/// The Spin CLI +#[derive(Parser)] +#[clap( + name = "spin", + version = version() +)] +pub enum SpinApp { + #[clap(subcommand, alias = "template")] + Templates(TemplateCommands), + New(NewCommand), + Add(AddCommand), + Up(UpCommand), + // acts as a cross-level subcommand shortcut -> `spin cloud deploy` + Deploy(DeployCommand), + // acts as a cross-level subcommand shortcut -> `spin cloud login` + Login(LoginCommand), + #[clap(subcommand, alias = "oci")] + Registry(RegistryCommands), + Build(BuildCommand), + #[clap(subcommand, alias = "plugin")] + Plugins(PluginCommands), + #[clap(subcommand, hide = true)] + Trigger(TriggerCommands), + #[clap(external_subcommand)] + External(Vec), + Watch(WatchCommand), + Doctor(DoctorCommand), +} + +#[derive(Subcommand)] +pub enum TriggerCommands { + Http(TriggerExecutorCommand), + Redis(TriggerExecutorCommand), + #[clap(name = HELP_ARGS_ONLY_TRIGGER_TYPE, hide = true)] + HelpArgsOnly(TriggerExecutorCommand), +} + +impl SpinApp { + /// The main entry point to Spin. + pub async fn run(self, app: clap::Command<'_>) -> Result<(), Error> { + match self { + Self::Templates(cmd) => cmd.run().await, + Self::Up(cmd) => cmd.run().await, + Self::New(cmd) => cmd.run().await, + Self::Add(cmd) => cmd.run().await, + Self::Deploy(cmd) => cmd.run(SpinApp::command()).await, + Self::Login(cmd) => cmd.run(SpinApp::command()).await, + Self::Registry(cmd) => cmd.run().await, + Self::Build(cmd) => cmd.run().await, + Self::Trigger(TriggerCommands::Http(cmd)) => cmd.run().await, + Self::Trigger(TriggerCommands::Redis(cmd)) => cmd.run().await, + Self::Trigger(TriggerCommands::HelpArgsOnly(cmd)) => cmd.run().await, + Self::Plugins(cmd) => cmd.run().await, + Self::External(cmd) => execute_external_subcommand(cmd, app).await, + Self::Watch(cmd) => cmd.run().await, + Self::Doctor(cmd) => cmd.run().await, + } + } +} + +lazy_static! { + pub static ref VERSION: String = build_info(); +} + +/// Helper for passing VERSION to structopt. +fn version() -> &'static str { + &VERSION +} + +/// Returns build information, similar to: 0.1.0 (2be4034 2022-03-31). +fn build_info() -> String { + format!("{SPIN_VERSION} ({SPIN_COMMIT_SHA} {SPIN_COMMIT_DATE})") +}