Repository navigation
feat(cli): pick a missing organization, environment, or wallet - #22
thebrandonlucas wants to merge 1 commit into
Conversation
9540228 to
6c29564
Compare
6c29564 to
33efa27
Compare
33efa27 to
069b2d5
Compare
1a065cb to
37e9bc5
Compare
37e9bc5 to
f5b085e
Compare
f5b085e to
7e7814c
Compare
| } | ||
| let child = command.spawn().unwrap(); | ||
| let (prompted, shown_prompt) = tokio::sync::oneshot::channel(); | ||
| let drain = tokio::task::spawn_blocking(move || { |
There was a problem hiding this comment.
nice, love seeing some chunking
tee8z
left a comment
There was a problem hiding this comment.
Review against docs/quality.md, with the CRAP report (scripts/crap-report.sh) run on this head.
ask fails the CRAP budget: 28.30 (cyclomatic 13, 55.1% coverage). It is the only function over 20 in the crate. The branching is fine: a straight sequence of early returns with nesting depth 1. The problem is that no test runs the organization and environment pickers with a browser login (src/pick.rs:123-151). The quality guide says to add meaningful tests rather than split functions, so these suggestions add tests:
- Login, organization then environment:
profiles createpicks both.environments listpicks only the organization. - API key with
--orgbut no--env: shows the environments notice. - Empty wallet list: "No wallets to choose from."
To drive two pickers in one run, on_a_terminal now takes (prompt, keys) steps instead of Option<prompt> plus keys. The suggestions also drop the empty-list check in Terminal::pick, which can never run because choose returns first.
Apply them all as one batch (Files changed → "Add suggestion to batch" on each). The call-site suggestions only compile with the new on_a_terminal signature.
With all suggestions applied, I ran:
cargo fmt --checkandcargo clippy --locked --all-targets -- -D warnings: clean.cargo test --locked: 92 unit and 65 integration tests pass.- CRAP report:
askis 13.01 (95.9% coverage),fill11.00 (100%), and no function violates the budget. - PR diff against
docs/help-examples-execute: 876 changed lines, under the 1,000-line limit.
Not suggested (outside this diff): on_a_terminal repeats most of interrupt_the_hidden_key_prompt (tests/cli_integration.rs:1734): the terminal setup, the unsafe pre_exec block, the output reader, and the kill-on-timeout cleanup. GitHub can't suggest changes to lines this PR doesn't touch. A follow-up could move that helper onto the same spawn and cleanup code so there is one unsafe block.
| /// Ask the person to pick one choice, filtering as they type. `None` means there was | ||
| /// nothing to pick or they pressed Esc. Raw mode turns a typed Ctrl-C into a key, so the | ||
| /// picker reports Ctrl-C as an interruption. | ||
| pub async fn pick(self, question: &'static str, choices: Vec<Choice>) -> Result<Option<Uuid>> { | ||
| if choices.is_empty() { | ||
| return Ok(None); | ||
| } |
There was a problem hiding this comment.
choose in pick.rs already returns before calling pick with an empty list, so this check can never run.
| /// Ask the person to pick one choice, filtering as they type. `None` means there was | |
| /// nothing to pick or they pressed Esc. Raw mode turns a typed Ctrl-C into a key, so the | |
| /// picker reports Ctrl-C as an interruption. | |
| pub async fn pick(self, question: &'static str, choices: Vec<Choice>) -> Result<Option<Uuid>> { | |
| if choices.is_empty() { | |
| return Ok(None); | |
| } | |
| /// Ask the person to pick one of a non-empty list of choices, filtering as they type. | |
| /// `None` means they pressed Esc. Raw mode turns a typed Ctrl-C into a key, so the picker | |
| /// reports Ctrl-C as an interruption. |
| /// Run a command with stdin and stderr on a pseudo-terminal and stdout piped. Once the | ||
| /// terminal shows `prompt`, type `keys`; without a prompt, nothing is typed. | ||
| #[cfg(unix)] | ||
| async fn on_a_terminal( | ||
| dir: &Path, | ||
| server: &MockServer, | ||
| args: &[&str], | ||
| prompt: Option<&'static str>, | ||
| keys: &'static [u8], |
There was a problem hiding this comment.
Take a list of (prompt, keys) steps instead of prompt: Option<_> plus keys. Today keys is silently ignored when prompt is None, and the org-then-env test below needs two prompts in one run. An empty list means nothing is typed. Apply this together with the next suggestion.
| /// Run a command with stdin and stderr on a pseudo-terminal and stdout piped. Once the | |
| /// terminal shows `prompt`, type `keys`; without a prompt, nothing is typed. | |
| #[cfg(unix)] | |
| async fn on_a_terminal( | |
| dir: &Path, | |
| server: &MockServer, | |
| args: &[&str], | |
| prompt: Option<&'static str>, | |
| keys: &'static [u8], | |
| /// Run a command with stdin and stderr on a pseudo-terminal and stdout piped. For each step, | |
| /// once the terminal shows its prompt after the previous step's, type its keys. | |
| #[cfg(unix)] | |
| async fn on_a_terminal( | |
| dir: &Path, | |
| server: &MockServer, | |
| args: &[&str], | |
| steps: &[(&'static str, &'static [u8])], |
| let (prompted, shown_prompt) = tokio::sync::oneshot::channel(); | ||
| let drain = tokio::task::spawn_blocking(move || { | ||
| let mut prompted = Some(prompted); | ||
| let mut shown = Vec::new(); | ||
| let mut chunk = [0; 4096]; | ||
| while let Ok(read @ 1..) = controller.read(&mut chunk) { | ||
| shown.extend_from_slice(&chunk[..read]); | ||
| if prompt.is_some_and(|prompt| String::from_utf8_lossy(&shown).contains(prompt)) { | ||
| prompted.take().map(|prompted| prompted.send(())); | ||
| } | ||
| } | ||
| String::from_utf8_lossy(&shown).into_owned() | ||
| }); | ||
| if prompt.is_some() { | ||
| tokio::time::timeout(Duration::from_secs(10), shown_prompt) | ||
| .await | ||
| .unwrap() | ||
| .unwrap(); | ||
| (&observer).write_all(keys).unwrap(); | ||
| } |
There was a problem hiding this comment.
Each step's prompt has to appear after the previous one before its keys are typed. That way a redraw of the first picker can't trigger the second step's keys.
| let (prompted, shown_prompt) = tokio::sync::oneshot::channel(); | |
| let drain = tokio::task::spawn_blocking(move || { | |
| let mut prompted = Some(prompted); | |
| let mut shown = Vec::new(); | |
| let mut chunk = [0; 4096]; | |
| while let Ok(read @ 1..) = controller.read(&mut chunk) { | |
| shown.extend_from_slice(&chunk[..read]); | |
| if prompt.is_some_and(|prompt| String::from_utf8_lossy(&shown).contains(prompt)) { | |
| prompted.take().map(|prompted| prompted.send(())); | |
| } | |
| } | |
| String::from_utf8_lossy(&shown).into_owned() | |
| }); | |
| if prompt.is_some() { | |
| tokio::time::timeout(Duration::from_secs(10), shown_prompt) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| (&observer).write_all(keys).unwrap(); | |
| } | |
| let mut prompts = steps | |
| .iter() | |
| .map(|&(prompt, _)| prompt.as_bytes()) | |
| .collect::<Vec<_>>(); | |
| prompts.reverse(); | |
| let (prompted, mut shown_prompt) = tokio::sync::mpsc::unbounded_channel(); | |
| let drain = tokio::task::spawn_blocking(move || { | |
| let (mut shown, mut seen) = (Vec::new(), 0); | |
| let mut chunk = [0; 4096]; | |
| while let Ok(read @ 1..) = controller.read(&mut chunk) { | |
| shown.extend_from_slice(&chunk[..read]); | |
| while let Some(prompt) = prompts.last() { | |
| let Some(at) = shown[seen..] | |
| .windows(prompt.len()) | |
| .position(|w| w == *prompt) | |
| else { | |
| break; | |
| }; | |
| seen += at + prompt.len(); | |
| prompts.pop(); | |
| let _ = prompted.send(()); | |
| } | |
| } | |
| String::from_utf8_lossy(&shown).into_owned() | |
| }); | |
| for &(_, keys) in steps { | |
| tokio::time::timeout(Duration::from_secs(10), shown_prompt.recv()) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| (&observer).write_all(keys).unwrap(); | |
| } |
| }; | ||
|
|
||
| // Typing filters the list, and Enter picks; the change is still only described. | ||
| let picked = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"Pay\r").await; |
There was a problem hiding this comment.
Call site for the new steps signature.
| let picked = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"Pay\r").await; | |
| let picked = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"Pay\r")]).await; |
| assert_eq!(picked.modes.0, picked.modes.1); | ||
|
|
||
| // Esc leaves the scope unset, so the command fails as it did before pickers. | ||
| let escaped = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"\x1b").await; |
There was a problem hiding this comment.
Call site for the new steps signature.
| let escaped = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"\x1b").await; | |
| let escaped = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"\x1b")]).await; |
| None, | ||
| b"", |
There was a problem hiding this comment.
Call site for the new steps signature.
| None, | |
| b"", | |
| &[], |
| "--output", | ||
| "table", | ||
| ]; | ||
| let run = on_a_terminal(dir.path(), &server, &receive, None, b"").await; |
There was a problem hiding this comment.
Call site for the new steps signature.
| let run = on_a_terminal(dir.path(), &server, &receive, None, b"").await; | |
| let run = on_a_terminal(dir.path(), &server, &receive, &[]).await; |
| .contains("--wallet is required for payment creation"), | ||
| "{}", | ||
| run.shown | ||
| ); |
There was a problem hiding this comment.
Covers the API-key notice for environments (pick.rs:143).
| ); | |
| ); | |
| // With an organization, the key still cannot list environments, and says so. | |
| let mut args = receive.to_vec(); | |
| args.extend(["--org", ORG]); | |
| let run = on_a_terminal(dir.path(), &server, &args, &[]).await; | |
| assert_eq!(run.code, Some(2), "{}", run.shown); | |
| assert!( | |
| run.shown | |
| .contains("An API key cannot list environments, so there is nothing to pick."), | |
| "{}", | |
| run.shown | |
| ); |
| None, | ||
| b"", |
There was a problem hiding this comment.
Call site for the new steps signature.
| None, | |
| b"", | |
| &[], |
| assert!(!run.shown.contains("--wallet is required"), "{}", run.shown); | ||
| assert!(server.received_requests().await.unwrap().is_empty()); | ||
| } | ||
|
|
There was a problem hiding this comment.
Runs the organization and environment pickers with a browser login (pick.rs:123-151), the return after only an organization (pick.rs:138), and the profile-hint branch in fill (pick.rs:98). Before this, no test ran them, which is why ask scored CRAP 28.30 (cyclomatic 13, 55.1% coverage).
| #[cfg(unix)] | |
| #[tokio::test(flavor = "multi_thread", worker_threads = 4)] | |
| async fn a_login_picks_an_organization_then_an_environment() { | |
| let auth = MockServer::start().await; | |
| let (api, dir) = fixture().await; | |
| let mut settings = seeded_login(dir.path(), &auth, false); | |
| let organizations = json!({"organizations": [{"id": ORG, "name": "Acme"}]}); | |
| respond(&auth, route("GET", "/users/current"), ok(organizations), 2).await; | |
| organization_exchange(&auth, LOGIN_ACCESS, ORG, "picker-access", 2).await; | |
| let environments = route("GET", format!("/organizations/{ORG}/environments")) | |
| .and(header("authorization", "Bearer picker-access")); | |
| respond( | |
| &auth, | |
| environments, | |
| ok(json!([{"id": ENV, "name": "prod"}])), | |
| 2, | |
| ) | |
| .await; | |
| // A profile needs both. Saving one is what the hint would suggest, so it is left out. | |
| let args = [ | |
| "profiles", | |
| "create", | |
| "prod", | |
| "--account", | |
| LOGIN_NAME, | |
| "--output", | |
| "table", | |
| ]; | |
| let steps: &[(&str, &[u8])] = &[("Organization:", b"\r"), ("Environment:", b"\r")]; | |
| let run = on_a_terminal(dir.path(), &api, &args, steps).await; | |
| assert_eq!(run.code, Some(0), "{}", run.shown); | |
| assert!( | |
| run.shown | |
| .contains(&format!("Next time, pass --org {ORG} --env {ENV}.")), | |
| "{}", | |
| run.shown | |
| ); | |
| settings.reload().unwrap(); | |
| let profile = &settings.config.profiles["prod"]; | |
| assert_eq!( | |
| (profile.organization_id, profile.environment_id), | |
| (uuid(ORG), uuid(ENV)) | |
| ); | |
| // Listing environments needs only the organization, so nothing more is asked. | |
| let args = [ | |
| "environments", | |
| "list", | |
| "--account", | |
| LOGIN_NAME, | |
| "--output", | |
| "table", | |
| ]; | |
| let run = on_a_terminal(dir.path(), &api, &args, &[("Organization:", b"\r")]).await; | |
| assert_eq!(run.code, Some(0), "{}", run.shown); | |
| assert!(!run.shown.contains("Environment:"), "{}", run.shown); | |
| auth.verify().await; | |
| } | |
On a terminal, a command that lacks the organization, environment, or wallet it needs now asks for it instead of failing. A picker, filtered by typing part of a name or ID, lists each choice as "name UUID" from the same sources as organizations list, environments list, and wallets list, in the environment when one is selected. Afterwards a notice names the flags that select the same scope. API commands ask for the scope their path needs. Friendly payment creation asks for a wallet, and friendly wallet creation for an environment. environments list asks for an organization, and profiles create for an organization and environment. A positional target is still typed. Nothing is asked when stdin or stderr is not a terminal, with --no-input, with JSON or NDJSON output, or with --dry-run, so scripts see the same errors as before. Esc leaves the scope unchanged and the command reports what is missing; Ctrl-C exits with code 130. An API key cannot list organizations or environments, so with a key only a wallet is offered and a missing organization fails as before. Picking never sends a change, which still needs --execute. API names are scrubbed before the picker draws them. The picker resolves the credential once and exchanges at most one organization token. inquire draws it through crossterm, which it now runs with its event features. Pseudo-terminal tests cover a pick, Esc, Ctrl-C, the no-prompt flags, an empty wallet list, an API key without an organization or environment, and a login that picks an organization and then an environment.
7e7814c to
2a34199
Compare
Stack: master ← #20 ← this PR ← #24 ← #19 ← #26 ← #27 ← #28 ← #29.
Summary
On a terminal, a command that lacks the organization, environment, or wallet it needs now asks for it instead of failing. Implements DEV-2903 (first part).
payments send/receivewallets createenvironments listprofiles createorganizations list,environments list, andwallets list(in the selected environment). Each shows asname UUID. Typing filters by any part of the name or ID.--no-input,--json,--output json|ndjson, or--dry-run. Those runs fail with the same error as before.--org is required(exit 2).Stack:
master← #15 ← #20 ← #21 ← this PR.Review exceptions
inquire =0.9.4, default features off pluscrossterm. It draws the picker through crossterm, which feat(cli): fit human tables to the terminal and scrub all API text #15 already added, so the CLI keeps one terminal backend. It enables crossterm'seventsfeature, which bringsmio,signal-hook,signal-hook-mio, andderive_more. New crates from inquire itself:inquireanddyn-clone. Thefuzzyfeature is left off: its default substring filter matches names and IDs, and droppingfuzzyavoidsfuzzy-matcherandthread_local.terminal::ChoiceandTerminal::pick(prompts belong toterminal),api::wallets(HTTP belongs toapi), andauth::exchangeandauth::list(split out ofresolve_organizationanddiscoverso the picker resolves once). Nothing is added to the library's public API.Choice::name(). AChoicescrubs its API-supplied name inChoice::new, and private fields keep unscrubbed text from reaching the picker.PickerScreenturns bracketed paste off and shows the cursor on drop. A signal that ends the command while the picker is open skips inquire's own cleanup.TerminalModesrestores the terminal attributes, as for the hidden key prompt.Deferred
wallets getwith no ID. This changes clap's required argument.auth import-key(organization and one environment) and webhook path scopes do not ask yet.payments send --unit satswithout--amountasks first, then fails.Verification
cargo fmt --check,cargo clippy --locked --all-targets -- -D warnings,cargo machete --with-metadata,cargo test --locked,python3 scripts/check-coverage.py--json,--no-input, and--dry-runneither ask nor list;--org is requiredwith its hint and sends no request.CRAP report
After tee8z's review suggestions (tests for the organization and environment pickers with a login, the API-key environment notice, and an empty wallet list;
on_a_terminaltakes(prompt, keys)steps):scripts/crap-report.shat2a34199finds no function at or above 20.pick::askwent from 28.30 to 13.01 (95.9% coverage), andpick::fillis 11.00. The highest score isapi::wait_for_paymentat 19.66, which this PR does not change.cargo test --locked: 92 unit and 65 integration tests pass.