diff --git a/rust/crates/sift_cli/CHANGELOG.md b/rust/crates/sift_cli/CHANGELOG.md index 9faaae3a8a..9f45a123ec 100644 --- a/rust/crates/sift_cli/CHANGELOG.md +++ b/rust/crates/sift_cli/CHANGELOG.md @@ -7,6 +7,31 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### What's New +- `config update` without field flags no longer prints `Nothing to update.` + and exits 0. In a terminal, it now opens an interactive editor. Outside a + terminal, it exits with status 2. Scripts that call it with no flags now + fail. +- `config update` stops with a pointer to `config create` when the config file + is missing. When `--profile` names a profile that does not exist, the + interactive editor stops the same way. Field flags still create a new named + profile. +- `config create` now prompts for the profile values in a terminal. It also + creates new profiles: `sift-cli --profile staging config create`, or, without + `--profile`, it asks for a name when the default profile is already set up. + Outside a terminal, it still creates only an empty file. +- The interactive editor lists named profiles to pick from and shows the + current value of each field. Type a new value to change a field, or leave it + blank to keep the current value. The API key is not + shown while you type it. Before it saves, the editor lists only the changed + fields, with the key masked to its last four characters. It writes nothing + when no field changed. The confirmation now defaults to yes. +- `--profile default` now selects the top-level profile in every command. Before, + it looked for a `[default]` table, so `sift-cli --profile default ping` + failed on a file with only top-level keys. A file that has a `[default]` + table still uses that table. +- `config update` with field flags prints the fields that changed. It reports + `No changes` and does not write the file when the values are already set. + ## [v0.7.6] - October 26, 2026 ### What's New diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md b/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md index 1aca6f9fab..da91f697d9 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md @@ -7,7 +7,7 @@ Each CLI profile needs four values: | `grpc_uri` | Base gRPC endpoint for Sift | `https://grpc-api.siftstack.com` | | `rest_uri` | Base REST endpoint for Sift | `https://api.siftstack.com` | | `app_uri` | Required web app origin from your browser | `https://app.siftstack.com` | -| `apikey` | Your Sift API key | `sift_...` | +| `api_key` | Your Sift API key | `sift_...` | For Sift Cloud, use the gRPC and REST values in the table. For other environments, use the endpoints from your administrator. See @@ -43,24 +43,73 @@ sift-cli config where ## Bootstrapping your credentials -The fastest way to get set up is the interactive flow. First create the file, -then fill it in: +Run `config create` in a terminal. It creates the config file and prompts for +the values of the `default` profile: ```sh sift-cli config create -sift-cli config update --interactive ``` -The interactive prompt asks for the profile, API URLs, API key, and app origin. -The prompt shows the result before it writes the file. A blank profile selects -the `default` profile. +```text +Creating a config file at '~/Library/Application Support/sift.toml'. + +Editing profile 'default'. Enter a value for each field. + + gRPC URL: https://grpc-api.siftstack.com + REST URL: https://api.siftstack.com + API key: + + App URL + suggested: https://app.siftstack.com (leave blank to use it) + new: + +Changes to 'default': + grpc_uri (unset) -> https://grpc-api.siftstack.com + rest_uri (unset) -> https://api.siftstack.com + api_key (unset) -> ****a91f + app_uri (unset) -> https://app.siftstack.com +Apply? [Y/n]: +Saved profile 'default'. +Run 'sift-cli ping' to check the connection. +``` + +The terminal does not show the API key while you type it. The change list shows +only its last four characters. The command writes nothing until you confirm. + +### Editing a profile + +Run `config update` without field flags to edit a profile interactively: + +```sh +sift-cli config update +``` + +If the file has named profiles, the command first asks which profile to edit. +Use `--profile ` to skip that question. Each field shows its current +value: + +```text +Editing profile 'staging'. +To change a field, type a new value and press Enter. To keep the current value, leave it blank and press Enter. + + gRPC URL + current: https://grpc-api.staging.example.net + new: +``` + +The command lists only the fields you changed. If nothing changed, it writes +nothing. + +`config update` edits existing profiles only. If the config file or the named +profile does not exist, the command stops and tells you to run `config create`. ### Non-interactive setup -To configure everything in one command (useful for scripts and CI), pass the -values as flags: +Outside a terminal, `config create` only creates an empty config file. To set +the values in one command (for scripts and CI), pass them as flags: ```sh +sift-cli config create sift-cli config update \ --grpc-uri https://grpc-api.siftstack.com \ --rest-uri https://api.siftstack.com \ @@ -69,12 +118,16 @@ sift-cli config update \ ``` Short forms are `-g` for gRPC, `-r` for REST, and `-k` for the API key. -Omitted fields stay unchanged. You can update one value at a time: +Omitted fields stay unchanged. The command prints the fields that changed. You +can update one value at a time: ```sh sift-cli config update --api-key "$SIFT_API_KEY" ``` +Without field flags outside a terminal, `config update` exits with status 2, +because it cannot prompt. + ## Inspecting the config ```sh @@ -88,7 +141,7 @@ A configured `default` profile looks like this: grpc_uri = "https://grpc-api.siftstack.com" rest_uri = "https://api.siftstack.com" app_uri = "https://app.siftstack.com" -apikey = "sift_..." +api_key = "sift_..." ``` ## Disabling TLS diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md b/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md index 6eed55652e..71ca84b4b8 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md @@ -7,23 +7,35 @@ re-editing your config. ## The default profile When you do not name a profile, settings are read from and written to the top -level of the config file. This is the `default` profile: +level of the config file. This is the `default` profile. `--profile default` +also selects it: ```toml grpc_uri = "https://grpc-api.siftstack.com" rest_uri = "https://api.siftstack.com" app_uri = "https://app.siftstack.com" -apikey = "sift_prod_..." +api_key = "sift_prod_..." ``` ## Adding a named profile -Pass `--profile ` to `config update`. The profile is created as its own -TOML table: +In a terminal, pass the new name to `config create`. It prompts for each value +of the new profile: ```sh -sift-cli config update --profile mission \ - --grpc-uri https://api.staging.example.com \ +sift-cli --profile staging config create +``` + +Without `--profile`, `config create` asks for a name when the `default` profile +is already set up. If the profile already exists, the command tells you to use +`config update` instead. + +For scripts, pass `--profile ` and field flags to `config update`. A name +that does not exist yet is created: + +```sh +sift-cli --profile staging config update \ + --grpc-uri https://grpc-api.staging.example.com \ --rest-uri https://api.staging.example.com \ --app-uri https://sift.staging.example.com \ --api-key "$SIFT_STAGING_API_KEY" @@ -35,20 +47,33 @@ The resulting file: grpc_uri = "https://grpc-api.siftstack.com" rest_uri = "https://api.siftstack.com" app_uri = "https://app.siftstack.com" -apikey = "sift_prod_..." +api_key = "sift_prod_..." [staging] grpc_uri = "https://grpc-api.staging.example.com" rest_uri = "https://api.staging.example.com" app_uri = "https://sift.staging.example.com" -apikey = "sift_staging_..." +api_key = "sift_staging_..." +``` + +Each profile is stored as its own TOML table. + +## Editing a named profile + +Run `config update` without field flags. The command lists the profiles and +asks which one to edit: + +```text +Select a profile to edit: + 1) default + 2) staging +Profile [1]: 2 ``` -The interactive flow also supports profiles. When prompted for a profile name, -enter `staging` instead of leaving it blank: +Enter a number or a profile name. To skip the list, name the profile: ```sh -sift-cli config update --interactive +sift-cli --profile staging config update ``` ## Using a profile @@ -56,8 +81,8 @@ sift-cli config update --interactive `--profile` is a global flag, so it goes on any command that talks to Sift: ```sh -sift-cli --profile mission ping -sift-cli --profile mission import csv ./telemetry.csv --asset rover-1 +sift-cli --profile staging ping +sift-cli --profile staging import csv ./telemetry.csv --asset rover-1 ``` Omit `--profile` to use the default profile. diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md b/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md index 4456d64be5..409dcc489d 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md @@ -26,7 +26,7 @@ sift-cli --disable-tls ping | Symptom | Likely cause and fix | | ----------------------------------------- | ------------------------------------------------------------------------------------ | -| `expected to find 'sift.toml'` | No config file yet. Run `sift-cli config create`, then `config update`. | +| `expected to find 'sift.toml'` | No config file yet. Run `sift-cli config create`. | | `Expected value of 'apikey' to be present`| The profile is missing a field. Re-run `sift-cli config update` with the missing value. | | `Profile '' not found` | The named profile does not exist. Check `sift-cli config show`. | | Authentication or permission errors | The API key is wrong or lacks access. Generate a new key in the Sift web app. | diff --git a/rust/crates/sift_cli/assets/docs/src/reference/commands.md b/rust/crates/sift_cli/assets/docs/src/reference/commands.md index 47729465a6..41f36595e7 100644 --- a/rust/crates/sift_cli/assets/docs/src/reference/commands.md +++ b/rust/crates/sift_cli/assets/docs/src/reference/commands.md @@ -18,10 +18,10 @@ Manage CLI configuration. See [Configuration](../getting-started/configuration.m | Command | Description | | ----------------------- | ------------------------------------------------- | -| `config create` | Create a new (empty) config file. | +| `config create` | Create the config file or a new profile, then prompt for its values. | | `config show` | Print the current config file. | | `config where` | Print the path to the config file. | -| `config update` | Set connection, app, or key fields. Use `--interactive` for prompts. | +| `config update` | Edit an existing profile. Prompts when run without field flags. | ## `import` diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index dbe3431eee..3862eea6e1 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -370,16 +370,25 @@ pub enum ConfigCmd { /// Show the path to the current config file Where, - /// Create a new config file (fails if one already exists) + /// Create the config file or a new profile, then prompt for its values. + /// + /// Without --profile, sets up the default profile, or asks for a new + /// profile name when the default is already set up. Outside a terminal, + /// only creates an empty config file. Create, - /// Update fields in the existing config file + /// Update fields of an existing profile. + /// + /// With field flags, sets those fields without prompting. A new --profile + /// name is created. Without field flags in a terminal, prompts for each + /// value and shows the current one. Without field flags outside a + /// terminal, exits with status 2. Update(ConfigUpdateArgs), } #[derive(clap::Args)] pub struct ConfigUpdateArgs { - /// Edit or create a profile interactively (ignores other flags) + /// Prompt for each value even when field flags are given (ignores them) #[arg(short, long)] pub interactive: bool, diff --git a/rust/crates/sift_cli/src/cmd/config/mod.rs b/rust/crates/sift_cli/src/cmd/config/mod.rs index 378fb917cf..2e2cf48f0d 100644 --- a/rust/crates/sift_cli/src/cmd/config/mod.rs +++ b/rust/crates/sift_cli/src/cmd/config/mod.rs @@ -5,24 +5,26 @@ use crate::BIN_NAME; use anyhow::{Context, Result, anyhow}; use crossterm::style::Stylize; use std::{ - fs::{File, OpenOptions, create_dir_all, metadata, read_to_string}, - io::Write, - path::PathBuf, + fs::{create_dir_all, metadata, read_to_string, write}, + path::{Path, PathBuf}, process::ExitCode, }; use toml::{Table, Value}; use crate::{ cli::ConfigUpdateArgs, - cmd::{API_KEY_KEY, API_KEY_KEY_LEGACY}, + cmd::{API_KEY_KEY, API_KEY_KEY_LEGACY, profile_api_key}, util::{ app_uri::{infer_app_uri, normalize_app_uri}, - tty::{Output, PromptUser}, + tty::{Output, is_interactive, read_hidden, read_line}, }, }; pub const CONFIG_FILE_NAME: &str = "sift.toml"; +/// The name shown for the top-level profile. +const DEFAULT_PROFILE: &str = "default"; + #[derive(Debug, Clone, Eq, PartialEq)] pub(super) enum AppUriState { Configured(String), @@ -38,118 +40,459 @@ pub fn show() -> Result { Ok(ExitCode::SUCCESS) } -pub fn create() -> Result { +/// The connection fields of one profile, as stored in the config file. +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub(super) struct ProfileFields { + grpc_uri: Option, + rest_uri: Option, + api_key: Option, + app_uri: Option, +} + +impl ProfileFields { + /// The fields of `profile`. All unset when the profile does not exist. + fn of(config: &Table, profile: Option<&str>) -> Self { + let Ok(table) = profile_table(config, profile) else { + return Self::default(); + }; + let field = |key: &str| table.get(key).and_then(Value::as_str).map(str::to_string); + Self { + grpc_uri: field("grpc_uri"), + rest_uri: field("rest_uri"), + api_key: profile_api_key(table).and_then(|v| v.as_str().map(str::to_string)), + app_uri: field("app_uri"), + } + } + + fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +pub fn create(profile: Option) -> Result { let path = get_config_file_path()?; let p = path.display().to_string(); - - if metadata(&path).is_ok() { + let exists = metadata(&path).is_ok(); + + if !is_interactive() { + if exists { + Output::new() + .line(format!("A config file already exists at '{}'.", p.yellow())) + .tip(format!( + "Use '{}' to set profile values.", + format!("{BIN_NAME} config update").green() + )) + .print(); + return Ok(ExitCode::SUCCESS); + } + write_config(&path, &Table::new())?; Output::new() - .line(format!("A config file already exists at '{}'.", p.yellow())) + .line(format!( + "An empty config file has been created at '{}'.", + p.yellow() + )) .tip(format!( - "Use '{}' to view the contents.", - format!("{BIN_NAME} config show").green() + "Use '{}' with --grpc-uri, --rest-uri, --api-key, and --app-uri to configure it.", + format!("{BIN_NAME} config update").green() )) .print(); return Ok(ExitCode::SUCCESS); } - create_config_file()?; - - Output::new() - .line(format!( - "An empty config file has been created at '{}'.", - p.yellow() - )) - .tip(format!( - "Use '{}' to configure it.", - format!("{BIN_NAME} config update").green() - )) - .print(); + let config = if exists { + read_config(&path)? + } else { + Table::new() + }; + let explicit_default = profile.as_deref() == Some(DEFAULT_PROFILE); + + let profile = match resolve_profile(&config, profile) { + None if ProfileFields::of(&config, None).is_empty() => None, + None if explicit_default => { + Output::new() + .line(format!( + "Profile '{}' already exists.", + DEFAULT_PROFILE.yellow() + )) + .tip(format!( + "Use '{}' to edit it.", + format!("{BIN_NAME} config update").green() + )) + .print(); + return Ok(ExitCode::SUCCESS); + } + Some(name) => match config.get(&name) { + Some(Value::Table(_)) => { + Output::new() + .line(format!( + "Profile '{}' already exists.", + name.as_str().yellow() + )) + .tip(format!( + "Use '{}' to edit it.", + format!("{BIN_NAME} --profile {name} config update").green() + )) + .print(); + return Ok(ExitCode::SUCCESS); + } + Some(_) => { + Output::new() + .line(format!( + "'{name}' is a config field, not a profile. Choose another name." + )) + .eprint(); + return Ok(ExitCode::FAILURE); + } + None => Some(name), + }, + None => match prompt_new_profile_name(&config)? { + Some(name) => Some(name), + None => return aborted(), + }, + }; - Ok(ExitCode::SUCCESS) + if !exists { + println!("Creating a config file at '{}'.\n", p.yellow()); + } + edit_profile(&path, config, profile) } pub fn update(profile: Option, args: ConfigUpdateArgs) -> Result { - let mut configured_profile = profile.clone(); + let path = get_config_file_path()?; + if metadata(&path).is_err() { + Output::new() + .line(format!( + "'{}' not found.", + path.display().to_string().yellow() + )) + .tip(format!( + "Run '{}' to set up your first profile.", + format!("{BIN_NAME} config create").green() + )) + .eprint(); + return Ok(ExitCode::FAILURE); + } + let config = read_config(&path)?; + let profile = resolve_profile(&config, profile); + + let non_empty = |value: Option| value.filter(|v| !v.is_empty()); + let flags = ProfileFields { + grpc_uri: non_empty(args.grpc_uri), + rest_uri: non_empty(args.rest_uri), + api_key: non_empty(args.api_key), + app_uri: non_empty(args.app_uri), + }; + if !args.interactive && !flags.is_empty() { + let Some(updated) = updated_config(&config, profile.as_deref(), flags)? else { + return Ok(ExitCode::SUCCESS); + }; + save(&path, &updated, profile.as_deref())?; + return Ok(ExitCode::SUCCESS); + } - let updated_config = { - if !args.interactive { - if is_update_empty(&args) { - Output::new().line("Nothing to update.").print(); - return Ok(ExitCode::SUCCESS); - } - get_updated_config( - profile.clone(), - args.grpc_uri, - args.rest_uri, - args.api_key, - args.app_uri, - )? - } else { - let [prof, grpc, rest, key]: [Option; 4] = PromptUser::new() - .header("Any blank values will be ignored preserving the original.") - .prompt(" Specify the profile to configure (leave blank for default profile): ") - .prompt(" Specify the gRPC API base URL: ") - .prompt(" Specify the REST API base URL: ") - .prompt(" Provide your Sift API key: ") - .run()? - .try_into() - .unwrap(); - - configured_profile = prof.clone(); - let suggested_app_uri = rest.as_deref().and_then(infer_app_uri); - let app_prompt = suggested_app_uri.map_or_else( - || { - " Open your Sift web app and copy its URL origin. Keep the scheme and host. \ - Specify that origin here (for example, https://sift.example.net): " - .to_string() - }, - |uri| format!(" Specify the Sift web app URL [{uri}]: "), - ); - let [app]: [Option; 1] = PromptUser::new() - .prompt(app_prompt) - .run()? - .try_into() - .unwrap(); - let app = app.or_else(|| suggested_app_uri.map(str::to_string)); - - let updated = get_updated_config(prof, grpc, rest, key, app)?; - let divider = "-".repeat(40); - - let [confirmation]: [Option; 1] = PromptUser::new() - .prompt(format!( - "\n{divider}\n{updated}\n{divider}\nDoes this look correct? [y/n]: " - )) - .run()? - .try_into() - .unwrap(); + if !is_interactive() { + Output::new() + .line("No fields to update, and no terminal to prompt in.") + .tip(format!( + "Pass one or more of --grpc-uri, --rest-uri, --api-key, or --app-uri. \ + Or run '{}' in a terminal to edit a profile interactively.", + format!("{BIN_NAME} config update").green() + )) + .eprint(); + return Ok(ExitCode::from(2)); + } - if confirmation.is_none_or(|c| c != "y") { - Output::new().line("Operation aborted.").print(); - return Ok(ExitCode::SUCCESS); - } - updated + let profile = match profile { + Some(name) if profile_table(&config, Some(&name)).is_err() => { + Output::new() + .line(format!("Profile '{}' not found.", name.as_str().yellow())) + .tip(format!( + "Run '{}' to create it.", + format!("{BIN_NAME} --profile {name} config create").green() + )) + .eprint(); + return Ok(ExitCode::FAILURE); } + Some(name) => Some(name), + None => select_profile(&config)?, }; + edit_profile(&path, config, profile) +} - let config_toml = updated_config - .parse::() - .context("updated config is invalid TOML")?; - let app_uri_state = app_uri_state(&config_toml, configured_profile.as_deref())?; - update_config_file(updated_config)?; +/// Prompt for each field of `profile`, show the changes, and save them after +/// the user confirms. A blank answer keeps the current value. +fn edit_profile(path: &Path, config: Table, profile: Option) -> Result { + let name = profile_name(profile.as_deref()); + let current = ProfileFields::of(&config, profile.as_deref()); + + if current.is_empty() { + println!( + "Editing profile '{}'. Enter a value for each field.\n", + name.yellow() + ); + } else { + println!( + "Editing profile '{}'.\nTo change a field, type a new value and press Enter. To \ + keep the current value, leave it blank and press Enter.", + name.yellow() + ); + } + let grpc_uri = prompt_field( + "gRPC URL", + Shown::current(current.grpc_uri.as_deref()), + false, + )?; + let rest_uri = prompt_field( + "REST URL", + Shown::current(current.rest_uri.as_deref()), + false, + )?; + let masked_key = current.api_key.as_deref().map(mask_key); + let api_key = prompt_field("API key", Shown::current(masked_key.as_deref()), true)?; - let target = configured_profile + let current_app_uri = current.app_uri.as_deref().and_then(normalize_app_uri); + let inferred_app_uri = rest_uri .as_deref() - .unwrap_or("default") - .to_string(); - Output::new() - .line(format!( - "Successfully configured the '{}' profile.", - target.yellow() - )) - .print(); - print_app_uri_guidance(configured_profile.as_deref(), &app_uri_state); + .or(current.rest_uri.as_deref()) + .and_then(infer_app_uri); + let app_uri = match (current_app_uri, inferred_app_uri) { + (Some(current), _) => prompt_field("App URL", Shown::Current(current), false)?, + (None, Some(inferred)) => prompt_field("App URL", Shown::Suggested(inferred), false)? + .or_else(|| Some(inferred.to_string())), + (None, None) => { + println!( + " Open your Sift web app and copy its URL origin. Keep the scheme and host \ + (for example, https://sift.example.net)." + ); + prompt_field("App URL", Shown::Nothing, false)? + } + }; + + println!(); + let edits = ProfileFields { + grpc_uri, + rest_uri, + api_key, + app_uri, + }; + let Some(updated) = updated_config(&config, profile.as_deref(), edits)? else { + return Ok(ExitCode::SUCCESS); + }; + + let confirmation = read_line("Apply? [Y/n]: ")?; + if confirmation.is_some_and(|c| !c.eq_ignore_ascii_case("y") && !c.eq_ignore_ascii_case("yes")) + { + return aborted(); + } + + save(path, &updated, profile.as_deref())?; + println!( + "Run '{}' to check the connection.", + format!("{BIN_NAME} {}ping", profile_flag(profile.as_deref())).green() + ); + Ok(ExitCode::SUCCESS) +} + +/// Ask which existing profile to edit. None selects the default profile. +fn select_profile(config: &Table) -> Result> { + let named = named_profiles(config); + if named.is_empty() { + return Ok(None); + } + + println!("Select a profile to edit:"); + println!(" 1) {DEFAULT_PROFILE}"); + for (i, name) in named.iter().enumerate() { + println!(" {}) {name}", i + 2); + } + loop { + let Some(answer) = read_line("Profile [1]: ")? else { + return Ok(None); + }; + if answer == "1" || answer == DEFAULT_PROFILE { + return Ok(None); + } + let by_number = answer + .parse::() + .ok() + .and_then(|n| n.checked_sub(2)) + .and_then(|i| named.get(i)); + if let Some(name) = by_number.or_else(|| named.iter().find(|n| **n == answer)) { + println!(); + return Ok(Some(name.clone())); + } + println!(" '{answer}' is not in the list. Enter a number or a profile name."); + } +} + +/// Ask for the name of a profile that does not exist yet. None if the user +/// leaves it blank. +fn prompt_new_profile_name(config: &Table) -> Result> { + let existing = std::iter::once(DEFAULT_PROFILE.to_string()) + .chain(named_profiles(config)) + .collect::>(); + println!("Existing profiles: {}.", existing.join(", ")); + loop { + let Some(name) = read_line("Name for the new profile: ")? else { + return Ok(None); + }; + if name == DEFAULT_PROFILE || config.contains_key(&name) { + println!( + " '{name}' is already in use. Enter another name, or run '{}' to edit it.", + format!("{BIN_NAME} --profile {name} config update").green() + ); + continue; + } + println!(); + return Ok(Some(name)); + } +} + +/// The value a field prompt shows above the input. +enum Shown<'a> { + Nothing, + /// Enter keeps it. + Current(&'a str), + /// Enter uses it. + Suggested(&'a str), +} + +impl<'a> Shown<'a> { + fn current(value: Option<&'a str>) -> Self { + value.map_or(Self::Nothing, Self::Current) + } +} + +/// Prompt for one field. None when the user presses Enter without typing. +fn prompt_field(label: &str, shown: Shown, hidden: bool) -> Result> { + let read = if hidden { read_hidden } else { read_line }; + match shown { + Shown::Nothing => return read(&format!(" {label}: ")), + Shown::Current(value) => println!("\n {}\n current: {value}", label.bold()), + Shown::Suggested(value) => println!( + "\n {}\n suggested: {value} (leave blank to use it)", + label.bold() + ), + } + read(" new: ") +} + +/// Show only the last four characters of an API key. +pub(super) fn mask_key(key: &str) -> String { + let len = key.chars().count(); + if len <= 8 { + return "****".to_string(); + } + let tail = key.chars().skip(len - 4).collect::(); + format!("****{tail}") +} + +/// The names of the named profiles, sorted. +pub(super) fn named_profiles(config: &Table) -> Vec { + config + .iter() + .filter(|(_, value)| value.is_table()) + .map(|(name, _)| name.clone()) + .collect() +} + +/// One changed field, as display strings. An API key is masked. +#[derive(Debug, Eq, PartialEq)] +pub(super) struct Change { + field: &'static str, + old: String, + new: String, +} + +/// Apply `edits` to a copy of `config` and print the changed fields of +/// `profile`. None, after a "No changes" notice, when no field changed. +fn updated_config( + config: &Table, + profile: Option<&str>, + edits: ProfileFields, +) -> Result> { + let mut updated = config.clone(); + apply_profile_updates( + &mut updated, + profile.map(str::to_string), + edits.grpc_uri, + edits.rest_uri, + edits.api_key, + edits.app_uri, + )?; + let changes = profile_changes(config, &updated, profile); + if changes.is_empty() { + println!( + "No changes to '{}'. Nothing was written.", + profile_name(profile).yellow() + ); + return Ok(None); + } + + println!("Changes to '{}':", profile_name(profile).yellow()); + let old_width = changes.iter().map(|c| c.old.len()).max().unwrap_or(0); + for Change { field, old, new } in &changes { + println!( + " {field:<8} {old: {}", + new.as_str().green() + ); + } + Ok(Some(updated)) +} + +/// The fields of `profile` that differ between `before` and `after`. +pub(super) fn profile_changes(before: &Table, after: &Table, profile: Option<&str>) -> Vec { + let (before, after) = ( + ProfileFields::of(before, profile), + ProfileFields::of(after, profile), + ); + let show = |value: &Option, secret: bool| match value { + None => "(unset)".to_string(), + Some(v) if secret => mask_key(v), + Some(v) => v.clone(), + }; + [ + ("grpc_uri", &before.grpc_uri, &after.grpc_uri, false), + ("rest_uri", &before.rest_uri, &after.rest_uri, false), + (API_KEY_KEY, &before.api_key, &after.api_key, true), + ("app_uri", &before.app_uri, &after.app_uri, false), + ] + .into_iter() + .filter(|(_, old, new, _)| old != new) + .map(|(field, old, new, secret)| Change { + field, + old: show(old, secret), + new: show(new, secret), + }) + .collect() +} + +fn save(path: &Path, config: &Table, profile: Option<&str>) -> Result<()> { + let app_uri_state = app_uri_state(config, profile)?; + write_config(path, config)?; + println!("Saved profile '{}'.", profile_name(profile).yellow()); + print_app_uri_guidance(profile, &app_uri_state); + Ok(()) +} + +/// Resolve `--profile default` to the top-level profile, unless the file has a +/// `[default]` table. +pub(super) fn resolve_profile(config: &Table, profile: Option) -> Option { + profile.filter(|name| name != DEFAULT_PROFILE || config.contains_key(name)) +} + +fn profile_name(profile: Option<&str>) -> &str { + profile.unwrap_or(DEFAULT_PROFILE) +} +/// The `--profile ` argument for a suggested command. Empty for the +/// default profile. +pub(super) fn profile_flag(profile: Option<&str>) -> String { + profile.map_or_else(String::new, |profile| format!("--profile {profile} ")) +} + +fn aborted() -> Result { + Output::new().line("Operation aborted.").print(); Ok(ExitCode::SUCCESS) } @@ -177,44 +520,11 @@ pub(super) fn get_config_file_path() -> Result { .ok_or(anyhow!("user config directory not found")) } -fn create_config_file() -> Result<(File, PathBuf)> { - let path = get_config_file_path()?; - - // Create the parent directories if they don't exist. - if let Some(parent_dir) = path.parent() { - create_dir_all(parent_dir).context("failed to create parent directories")?; - } - - let config_file = File::create_new(&path).context("failed to create config file")?; - - Ok((config_file, path)) -} - -fn get_updated_config( - profile: Option, - grpc_uri: Option, - rest_uri: Option, - api_key: Option, - app_uri: Option, -) -> Result { - let path = get_config_file_path()?; - - let contents = read_to_string(path).context("failed to read config file")?; - - let mut config_toml = contents +fn read_config(path: &Path) -> Result
{ + read_to_string(path) + .context("failed to read config file")? .parse::
() - .context("config file is invalid TOML")?; - - apply_profile_updates( - &mut config_toml, - profile, - grpc_uri, - rest_uri, - api_key, - app_uri, - )?; - - Ok(config_toml.to_string()) + .context("config file is invalid TOML") } fn apply_profile_updates( @@ -271,12 +581,7 @@ fn apply_profile_updates( } pub(super) fn inspect_app_uri(profile: Option<&str>) -> Result { - let path = get_config_file_path()?; - let contents = read_to_string(path).context("failed to read config file")?; - let config_toml = contents - .parse::
() - .context("config file is invalid TOML")?; - app_uri_state(&config_toml, profile) + app_uri_state(&read_config(&get_config_file_path()?)?, profile) } fn app_uri_state(config_toml: &Table, profile: Option<&str>) -> Result { @@ -310,7 +615,7 @@ fn profile_table<'a>(config_toml: &'a Table, profile: Option<&str>) -> Result<&' } fn print_app_uri_guidance(profile: Option<&str>, state: &AppUriState) { - let profile_flag = profile.map_or_else(String::new, |profile| format!("--profile {profile} ")); + let profile_flag = profile_flag(profile); match state { AppUriState::Configured(_) => {} AppUriState::MissingKnown(app_uri) => println!( @@ -333,28 +638,9 @@ fn print_app_uri_guidance(profile: Option<&str>, state: &AppUriState) { } } -fn update_config_file(updated: String) -> Result<()> { - let path = get_config_file_path()?; - - let mut config = OpenOptions::new() - .write(true) - .truncate(true) - .open(path) - .context("failed to open config file")?; - - write!(config, "{updated}").context("failed to update config file") -} - -fn is_update_empty(args: &ConfigUpdateArgs) -> bool { - let ConfigUpdateArgs { - grpc_uri, - rest_uri, - api_key, - app_uri, - .. - } = args; - grpc_uri.as_ref().is_none_or(|s| s.is_empty()) - && rest_uri.as_ref().is_none_or(|s| s.is_empty()) - && api_key.as_ref().is_none_or(|s| s.is_empty()) - && app_uri.as_ref().is_none_or(|s| s.is_empty()) +fn write_config(path: &Path, config: &Table) -> Result<()> { + if let Some(parent_dir) = path.parent() { + create_dir_all(parent_dir).context("failed to create parent directories")?; + } + write(path, config.to_string()).context("failed to write config file") } diff --git a/rust/crates/sift_cli/src/cmd/config/tests.rs b/rust/crates/sift_cli/src/cmd/config/tests.rs index 797885e1d2..7076fd4c2d 100644 --- a/rust/crates/sift_cli/src/cmd/config/tests.rs +++ b/rust/crates/sift_cli/src/cmd/config/tests.rs @@ -1,56 +1,3 @@ -mod test_is_update_empty { - use super::super::is_update_empty; - use crate::cli::ConfigUpdateArgs; - - fn args( - grpc_uri: Option<&str>, - rest_uri: Option<&str>, - api_key: Option<&str>, - app_uri: Option<&str>, - ) -> ConfigUpdateArgs { - ConfigUpdateArgs { - interactive: false, - grpc_uri: grpc_uri.map(String::from), - rest_uri: rest_uri.map(String::from), - api_key: api_key.map(String::from), - app_uri: app_uri.map(String::from), - } - } - - #[test] - fn no_flags_is_empty() { - assert!(is_update_empty(&args(None, None, None, None))); - } - - #[test] - fn all_empty_strings_is_empty() { - assert!(is_update_empty(&args( - Some(""), - Some(""), - Some(""), - Some("") - ))); - } - - #[test] - fn any_single_flag_is_not_empty() { - assert!(!is_update_empty(&args(Some("g"), None, None, None))); - assert!(!is_update_empty(&args(None, Some("r"), None, None))); - assert!(!is_update_empty(&args(None, None, Some("k"), None))); - assert!(!is_update_empty(&args(None, None, None, Some("a")))); - } - - #[test] - fn all_flags_set_is_not_empty() { - assert!(!is_update_empty(&args( - Some("g"), - Some("r"), - Some("k"), - Some("a") - ))); - } -} - mod app_uri { use super::super::{AppUriState, app_uri_state, apply_profile_updates}; use toml::{Table, Value}; @@ -249,3 +196,119 @@ mod api_key_key { assert_eq!(config[API_KEY_KEY_LEGACY].as_str(), Some("top")); } } + +mod profile_edits { + use super::super::{ + Change, apply_profile_updates, mask_key, named_profiles, profile_changes, resolve_profile, + }; + use toml::Table; + + fn config(input: &str) -> Table { + input.parse().unwrap() + } + + fn change(field: &'static str, old: &str, new: &str) -> Change { + Change { + field, + old: old.to_string(), + new: new.to_string(), + } + } + + #[test] + fn masks_all_but_the_last_four_characters() { + assert_eq!(mask_key("sift_abcdefgh1234"), "****1234"); + assert_eq!(mask_key("short"), "****"); + assert_eq!(mask_key("12345678"), "****"); + } + + #[test] + fn lists_named_profiles_sorted() { + let config = config( + r#" +rest_uri = "https://api.siftstack.com" + +[staging] +rest_uri = "https://api.staging.example.net" + +[prod] +rest_uri = "https://api.example.net" +"#, + ); + assert_eq!(named_profiles(&config), ["prod", "staging"]); + assert!(named_profiles(&Table::new()).is_empty()); + } + + #[test] + fn reports_only_changed_fields_with_the_key_masked() { + let before = config( + r#" +rest_uri = "https://api.example.net" +apikey = "sift_old_key_a91f" +app_uri = "https://sift.example.net" +"#, + ); + let mut after = before.clone(); + apply_profile_updates( + &mut after, + None, + Some("grpc.example.net:443".to_string()), + Some("https://api.example.net".to_string()), + Some("sift_new_key_7c02".to_string()), + None, + ) + .unwrap(); + + assert_eq!( + profile_changes(&before, &after, None), + [ + change("grpc_uri", "(unset)", "grpc.example.net:443"), + change("api_key", "****a91f", "****7c02"), + ] + ); + } + + #[test] + fn a_new_named_profile_starts_unset() { + let before = config("rest_uri = \"https://api.siftstack.com\"\n"); + let mut after = before.clone(); + apply_profile_updates( + &mut after, + Some("staging".to_string()), + None, + Some("https://api.staging.example.net".to_string()), + None, + None, + ) + .unwrap(); + + assert_eq!( + profile_changes(&before, &after, Some("staging")), + [change( + "rest_uri", + "(unset)", + "https://api.staging.example.net" + )] + ); + assert!(profile_changes(&before, &after, None).is_empty()); + } + + #[test] + fn default_profile_name_means_the_top_level_profile() { + let top_level = config("rest_uri = \"https://api.siftstack.com\"\n"); + assert_eq!( + resolve_profile(&top_level, Some("default".to_string())), + None + ); + assert_eq!( + resolve_profile(&top_level, Some("staging".to_string())).as_deref(), + Some("staging") + ); + + let table = config("[default]\nrest_uri = \"https://api.siftstack.com\"\n"); + assert_eq!( + resolve_profile(&table, Some("default".to_string())).as_deref(), + Some("default") + ); + } +} diff --git a/rust/crates/sift_cli/src/cmd/mod.rs b/rust/crates/sift_cli/src/cmd/mod.rs index c11e8b055f..48428fe89c 100644 --- a/rust/crates/sift_cli/src/cmd/mod.rs +++ b/rust/crates/sift_cli/src/cmd/mod.rs @@ -71,6 +71,7 @@ impl Context { .parse::
() .context("failed to parse config file")?; + let profile = config::resolve_profile(&config_toml, profile); let target_profile = match profile.as_deref() { Some(prof) => { let Some(Value::Table(target)) = config_toml.get(prof) else { @@ -147,7 +148,7 @@ impl Context { fn app_uri_guidance(profile: Option<&str>, rest_uri: &str) -> String { let profile_name = profile.unwrap_or("default"); - let profile_flag = profile.map_or_else(String::new, |profile| format!("--profile {profile} ")); + let profile_flag = config::profile_flag(profile); match infer_app_uri(rest_uri) { Some(app_uri) => format!( "The Sift MCP server cannot start because profile '{profile_name}' has no usable \ @@ -207,6 +208,19 @@ apikey = "mission-key" assert_eq!(mission.api_key, "mission-key"); } + #[test] + fn default_profile_name_selects_the_top_level_profile() { + let default = context(COMPLETE_CONFIG, Some("default")).unwrap(); + assert_eq!(default.rest_uri, "https://api.siftstack.com"); + + let config = format!( + "{COMPLETE_CONFIG}\n[default]\ngrpc_uri = \"https://grpc.table.example.net\"\n\ + rest_uri = \"https://api.table.example.net\"\napi_key = \"table-key\"\n" + ); + let table = context(&config, Some("default")).unwrap(); + assert_eq!(table.rest_uri, "https://api.table.example.net"); + } + #[test] fn rejects_each_missing_required_connection_field() { for (field, config) in [ diff --git a/rust/crates/sift_cli/src/main.rs b/rust/crates/sift_cli/src/main.rs index e091d686a2..289f52504c 100644 --- a/rust/crates/sift_cli/src/main.rs +++ b/rust/crates/sift_cli/src/main.rs @@ -68,7 +68,7 @@ fn run(clargs: cli::Args) -> Result { match cmd { Cmd::Config(cmd) => match cmd { ConfigCmd::Show => return cmd::config::show(), - ConfigCmd::Create => return cmd::config::create(), + ConfigCmd::Create => return cmd::config::create(clargs.profile), ConfigCmd::Where => return cmd::config::config_where(), ConfigCmd::Update(args) => return cmd::config::update(clargs.profile, args), }, diff --git a/rust/crates/sift_cli/src/util/tty.rs b/rust/crates/sift_cli/src/util/tty.rs index d877918500..f54e792169 100644 --- a/rust/crates/sift_cli/src/util/tty.rs +++ b/rust/crates/sift_cli/src/util/tty.rs @@ -1,62 +1,93 @@ use std::{ fmt::Display, - io::{self, IsTerminal, Write}, + io::{self, IsTerminal, Read, Write}, }; -use anyhow::Result; -use crossterm::style::Stylize; +use anyhow::{Result, bail}; +use crossterm::{style::Stylize, terminal}; -#[derive(Default)] -pub struct PromptUser { - header: Option, - prompts: Vec, +/// Print `prompt` and read one line. None for a blank line or end of input. +pub fn read_line(prompt: &str) -> Result> { + write_prompt(prompt)?; + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + Ok(non_blank(&buf)) } -impl PromptUser { - pub fn new() -> Self { - Self::default() - } - - pub fn header>(&mut self, header: S) -> &mut Self { - self.header.replace(header.into()); - self +/// Like [`read_line`], but the terminal does not echo the input. +/// +/// Errors when the user presses Ctrl-C, since raw mode stops the terminal from +/// sending SIGINT. +pub fn read_hidden(prompt: &str) -> Result> { + struct RawMode; + impl Drop for RawMode { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + } } - pub fn prompt>(&mut self, prompt: S) -> &mut Self { - self.prompts.push(prompt.into()); - self - } + write_prompt(prompt)?; + let raw_mode = { + terminal::enable_raw_mode()?; + RawMode + }; + // Read through the same buffered stdin as `read_line`, so input typed or + // pasted ahead of this prompt is not skipped. + let secret = read_secret(&mut io::stdin().lock())?; + drop(raw_mode); + println!(); + + let Some(secret) = secret else { + bail!("Operation aborted."); + }; + Ok(non_blank(&secret)) +} - pub fn run(&self) -> Result>> { - if self.prompts.is_empty() { - return Ok(Vec::new()); +/// Read raw-mode input up to Enter or end of input. None on Ctrl-C. +/// +/// Handles Backspace and Ctrl-U. Drops other control keys and escape +/// sequences, such as arrow keys. +fn read_secret(input: &mut impl Read) -> io::Result> { + let mut next = || -> io::Result> { + let mut byte = [0]; + Ok((input.read(&mut byte)? == 1).then_some(byte[0])) + }; + let mut secret = Vec::new(); + while let Some(byte) = next()? { + match byte { + b'\r' | b'\n' => break, + 0x03 => return Ok(None), + 0x15 => secret.clear(), + 0x08 | 0x7f => { + // Remove one UTF-8 character: its continuation bytes, then its lead byte. + while secret.pop().is_some_and(|b| b & 0xc0 == 0x80) {} + } + 0x1b => { + if next()? == Some(b'[') { + while next()?.is_some_and(|b| !(0x40..=0x7e).contains(&b)) {} + } + } + b if b < 0x20 => {} + b => secret.push(b), } - let mut user_inputs = Vec::with_capacity(self.prompts.len()); + } + Ok(Some(String::from_utf8_lossy(&secret).into_owned())) +} - let stdin = io::stdin(); - let mut stdout = io::stdout(); +fn write_prompt(prompt: &str) -> Result<()> { + let mut stdout = io::stdout(); + write!(stdout, "{prompt}")?; + Ok(stdout.flush()?) +} - if let Some(header) = self.header.as_ref() { - writeln!(stdout, "{header}")?; - } +fn non_blank(input: &str) -> Option { + let input = input.trim(); + (!input.is_empty()).then(|| input.to_string()) +} - for prompt in &self.prompts { - write!(stdout, "{prompt}")?; - stdout.flush()?; - - let user_input = { - let mut buf = String::new(); - stdin.read_line(&mut buf)?; - buf.trim().to_string() - }; - if user_input.is_empty() { - user_inputs.push(None); - } else { - user_inputs.push(Some(user_input)); - } - } - Ok(user_inputs) - } +/// Whether both stdin and stdout are a terminal, so the CLI can prompt. +pub fn is_interactive() -> bool { + io::stdin().is_terminal() && io::stdout().is_terminal() } pub fn stdout_is_tty() -> bool { @@ -119,7 +150,32 @@ impl Output { #[cfg(test)] mod tests { - use super::hyperlink; + use super::{hyperlink, read_secret}; + + fn secret(input: &[u8]) -> Option { + read_secret(&mut &input[..]).unwrap() + } + + #[test] + fn read_secret_stops_at_enter_and_keeps_the_rest_unread() { + let mut input = &b"key1\rnext line\n"[..]; + assert_eq!(read_secret(&mut input).unwrap().as_deref(), Some("key1")); + assert_eq!(input, b"next line\n"); + } + + #[test] + fn read_secret_handles_editing_keys() { + assert_eq!(secret(b"abcd\x7f\x7fx\r").as_deref(), Some("abx")); + assert_eq!(secret(b"wrong\x15right\r").as_deref(), Some("right")); + assert_eq!(secret(b"ke\x17y\r").as_deref(), Some("key")); + assert_eq!(secret(b"k\x1b[Dey\r").as_deref(), Some("key")); + assert_eq!(secret("k\u{e9}\x7fey\r".as_bytes()).as_deref(), Some("key")); + } + + #[test] + fn read_secret_aborts_on_ctrl_c() { + assert_eq!(secret(b"key\x03"), None); + } #[test] fn hyperlink_wraps_text_in_an_osc_8_sequence() {