TVC-66: Make non-interactive mode explicit in tvc#149
Conversation
Replace twin helpers (select_or_create_org_interactive / select_existing_org, get_or_generate_api_key_interactive / get_existing_api_key_non_interactive) with a plan-driven shape: endpoints build a LoginPlan (OrgPlan + ApiKeyPolicy), and a single execute_login(config, plan) consumes it. The ApiKeyPolicy enum (AllowGenerate vs RequireExisting) drives the only mode-influenced branch.
…ition Replace resolve_deploy_inputs_interactive / resolve_deploy_inputs_non_interactive with endpoint-level composition of leaves: read_config_file_bytes, parse_deploy_config, read_pivot_pull_secret, flag_only_template, apply_overrides. The non-interactive endpoint reads independent files concurrently via tokio::try_join!; interactive stays sequential and only reads the pull secret after prompts succeed. The in-file run_resolve test helper is retargeted to the new composition.
…lution Replace post_approval_to_api_interactive / _non_interactive and resolve_operator_id_interactive / _non_interactive with a PostApprovalPlan struct and a single post_approval_to_api(plan, approval). Operator-id resolution is inlined into each endpoint so the only structural difference (prompts::select for the multi-saved-IDs case) is visible at the endpoint. load_saved_operator_ids becomes a single leaf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace load_or_fill_app_config_interactive / load_app_config_non_interactive with endpoint-level composition of leaves: read_app_config_file_bytes, parse_app_config, offer_to_save_app_config. The interactive endpoint folds the !stdin_can_prompt() check into the "bail with all validation errors" branch to preserve the behavior of failing with the placeholder list when running interactive without a TTY instead of hanging on inquire.
Edit only the "after" mermaid diagrams: drop run(args, interaction_mode)
signatures and AMode{"mode"} branches in favor of two parallel endpoint
flows meeting at a shared executor subgraph (execute_login,
run_with_resolved_inputs, run_with_config, post_approval_to_api,
run(args) for inits). Delete the bottom unified mega-diagram that
re-introduced InteractionMode / can_prompt boxes. Refresh the Prompt
Inventory intro and Current Caveats list to remove obsolete entries.
Leave the "before" diagrams alone as historical context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
71a4c85 to
726f07f
Compare
daniilrrr
left a comment
There was a problem hiding this comment.
this is really nice, hard work that will make the tvc SDK much clearer to use and maintain. great job
i have some changes I would like to see throughout
| @@ -0,0 +1,608 @@ | |||
| # TVC CLI Prompt Flow | |||
There was a problem hiding this comment.
remember to delete this file
| p256 = { workspace = true } | ||
| serde = { workspace = true } | ||
| serde_json = { workspace = true } | ||
| thiserror = { workspace = true } |
There was a problem hiding this comment.
requires dep reviewer but thiserror is v commonly used, so I don't expect any issue
There was a problem hiding this comment.
Yeah, it's already a dep in the workspace so I'm not actually adding anything new, just exposing it to tvc.
| match args.command { | ||
| Commands::Deploy { command } => match command { | ||
| DeployCommands::Approve(args) => commands::deploy::approve::run(args).await, | ||
| DeployCommands::Approve(args) => { |
There was a problem hiding this comment.
for every command here that has an interactive and non-interactive version, let's do an explicit match
DeployCommands::Approve(args) => {
match non_interactive {
true => {commands::deploy::approve::run_non_interactive(args).await}
false => {commands::deploy::approve::run_interactive(args).await}
}
}
I think the current version is a little over-LLM'd and relies on the ordering of the commands to be correct at the expense of clarity.
a second point is that, at a glance, in order to make maintainability easier I believe all of these funcs with 2 versions should instead be
run_interactive(args) -> run(args: Args, is_non_interactive: bool)
to not have to maintain 2 code paths for each interactive command.
I plan to argue that on a separate comment as I read the rest of the PR. We will see if I retain this view
There was a problem hiding this comment.
having read the PR, you've done a nice job flattening the logic of each run() command into a (1) setup and config phase, then (2) post SDK API call phase. It is (1) that differs between interactive and non-interactive, while the rest is standard
To me, it's better for maintenance and discoverability to have a diamond shaped
- run()
2a. setup_non_interactive(), or
2b. setup_interactive() - post()
let me know what you think
There was a problem hiding this comment.
The goal here was to flatten everything out as much as possible. I even added the guards instead of the if-statements the LLM produced (matching on a bool will fail clippy 😉). I honestly prefer a single match statement with no nested logic, the nature if guards and wildcard matches means that order matters on every match statement.
With that said, I don't mind adding the if-statements back.
As far as your suggestion of setup_(non_)interavtive, I'm not sure I see how that changes things. I think this one is worth hopping on a call to talk about so I can understand the benefit of what you're suggesting
| params: &OperatorSetParams, | ||
| errors: &mut Vec<AppConfigValidationError>, | ||
| ) { | ||
| if params.name.starts_with("<FILL_IN") { |
There was a problem hiding this comment.
extract "<FILL_IN" as a constant to use everywhere and prevent fat finger bugs later
There was a problem hiding this comment.
I'll probably leave this one in this PR and do a single pass of "floating" numbers are strings in the code, everything that is a constant should be defined as such (effectively just named constants). There are a bunch more below too.
| } | ||
|
|
||
| Ok(()) | ||
| AppConfigValidationErrors::from_vec(errors) |
There was a problem hiding this comment.
this is clever but a little indirect. I understand why you did it.
personal opinion but I don't like how it implies that errors will always be produced as opposed to ending with Ok(()) in the happy path. This is a nit tho
i think for a little extra clarity you could rename here and in deploy.rs
AppConfigValidationErrors::ok_or_errors(errors)
There was a problem hiding this comment.
This is a nit tho
This one was hand-written and I definitely got the ick writing it haha. It was better than always having to check for is_empty every time though.
Your name gets rid of the ick haha, thanks!
| let mut errors = Vec::new(); | ||
| if self.app_id.starts_with("<FILL_IN") { | ||
| errors.push(DeployConfigValidationError::Placeholder { | ||
| field: "--app-id", |
There was a problem hiding this comment.
do the underscore version here (app_id) to be consistent with app.rs/validate() func, not the flag version
| Some(query) => query, | ||
| None => { | ||
| bail_required_in_non_interactive("--org")?; | ||
| unreachable!("bail_required_in_non_interactive always returns an error"); |
There was a problem hiding this comment.
icky unreachable
i have a way to kill this unreachable! that you may like
refactor the bail_ to use a public inner helper
pub fn bail_required_in_non_interactive(flag_hint: &str) -> Result<()> {
Err(error_required_in_non_interactive(flag_hint))
}
pub fn error_required_in_non_interactive(flag_hint: &str) -> Error {
anyhow!(
"{flag_hint} is required in non-interactive mode \
(set {flag_hint} or run in a TTY without --non-interactive \
/ {NON_INTERACTIVE_ENV}=true)"
)
}then call the inner helper here in a one-liner
let org_query = args.org.ok_or_else(|| error_required_in_non_interactive("--org"))?;There was a problem hiding this comment.
Now this is dumb LLM code. I can just use an else-return here.
| api_base_url_override: args.api_base_url, | ||
| api_key_policy: ApiKeyPolicy::RequireExisting, | ||
| }; | ||
| execute_login(config, plan).await |
There was a problem hiding this comment.
fabulous refactoring job, really nice to centralize everything into shared code
There was a problem hiding this comment.
not just here but this pattern of shared "core" logic in the other non/interactive commands is great
| /// Run the deploy init command. | ||
| pub async fn run(args: Args) -> Result<()> { | ||
| if args.interactive && prompts::non_interactive_forced() { | ||
| bail!( | ||
| "--interactive conflicts with {}=1", | ||
| prompts::NON_INTERACTIVE_ENV | ||
| ); | ||
| pub async fn run_interactive(args: Args) -> Result<()> { | ||
| if args.interactive { | ||
| ensure_stdin_is_tty()?; | ||
| } | ||
| run(args).await | ||
| } | ||
|
|
||
| pub async fn run_non_interactive(args: Args) -> Result<()> { | ||
| if args.interactive { | ||
| bail_interactive_conflicts_with_non_interactive()?; | ||
| } | ||
| run(args).await | ||
| } |
There was a problem hiding this comment.
at the risk of overengineering, should we refactor these checks into the prompts package and do them at the beginning of every run_interactive() and run_non_interactive() version of EVERY command for safety? They're already duplicated between deploy/init.rs here and app/init.rs. Something like
/// In interactive mode, the `--interactive` fill flag only requires a real TTY.
pub fn require_tty_for_interactive(interactive: bool) -> Result<()> {
if interactive {
ensure_stdin_is_tty()?;
}
Ok(())
}
/// In non-interactive mode, the `--interactive` fill flag is contradictory.
pub fn reject_interactive_flag(interactive: bool) -> Result<()> {
if interactive {
bail_interactive_conflicts_with_non_interactive()?;
}
Ok(())
}
Then both app/init.rs and deploy/init.rs collapse to:
pub async fn run_interactive(args: Args) -> Result<()> {
prompts::require_tty_for_interactive(args.interactive)?;
run(args).await
}
pub async fn run_non_interactive(args: Args) -> Result<()> {
prompts::reject_interactive_flag(args.interactive)?;
run(args).await
}There was a problem hiding this comment.
🤔, I don't think this is overengineering, this is a good callout so we don't have to remember to add the checks to future commands
|
|
||
| let approval = sign_and_output(&args, &manifest).await?; | ||
|
|
||
| if !args.skip_post { |
There was a problem hiding this comment.
nit - reduce this long conditional and just return early instead.
if args.skip_post { return Ok(()); }
makes it easier for me to read and IMO, usually, any set of nested braces you can kill is a win
There was a problem hiding this comment.
once you return early, the shape is clearer to me and can be refactored to a flatter procedure without conditionals
- try to resolve the
operator_id, if not configured - construct
PostApprovalPlan post_approval_to_api()
which reads nicely
There was a problem hiding this comment.
can also collapse operator resolution logic between here and the run_non_interactive() part
There was a problem hiding this comment.
LLM code below. because resolve_operator_id() is used by both paths we need to pass it a bool indicating the origin of the call, which is against the overall spirit of your PR, but it's preeeetty clean
pub async fn run_interactive(args: Args) -> anyhow::Result<()> {
let do_prompt_user = !args.dangerous_skip_interactive;
// Guard: bail fast before fetching the manifest if review prompts are
// required but the caller has no TTY to answer them.
if do_prompt_user && !stdin_can_prompt() {
bail_required_in_non_interactive("--dangerous-skip-interactive")?;
}
let (manifest, fetched_manifest_id) = load_manifest(&args).await?;
if do_prompt_user {
interactive_approve(&manifest)?;
}
if args.dry_run {
println!("Dry run complete. No approval generated.");
return Ok(());
}
let approval = sign_and_output(&args, &manifest).await?;
if args.skip_post {
return Ok(());
}
post_approval(
&args,
&approval,
fetched_manifest_id.as_deref(),
OperatorPrompt::Allow,
)
.await
}
pub async fn run_non_interactive(args: Args) -> anyhow::Result<()> {
if !args.dangerous_skip_interactive {
bail_required_in_non_interactive("--dangerous-skip-interactive")?;
}
let (manifest, fetched_manifest_id) = load_manifest(&args).await?;
if args.dry_run {
println!("Dry run complete. No approval generated.");
return Ok(());
}
let approval = sign_and_output(&args, &manifest).await?;
if args.skip_post {
return Ok(());
}
post_approval(
&args,
&approval,
fetched_manifest_id.as_deref(),
OperatorPrompt::Disallow,
)
.await
}
And the helpers they rely on (place near PostApprovalPlan):
/// Whether operator-id disambiguation may fall back to an interactive prompt.
enum OperatorPrompt {
Allow,
Disallow,
}
async fn post_approval(
args: &Args,
approval: &Approval,
fetched_manifest_id: Option<&str>,
prompt: OperatorPrompt,
) -> anyhow::Result<()> {
let manifest_id = resolve_manifest_id(args, fetched_manifest_id)?;
let operator_id = resolve_operator_id(args, prompt).await?;
let plan = PostApprovalPlan {
manifest_id: &manifest_id,
operator_id: &operator_id,
};
post_approval_to_api(plan, approval).await
}
async fn resolve_operator_id(args: &Args, prompt: OperatorPrompt) -> anyhow::Result<String> {
if let Some(id) = &args.operator_id {
return Ok(id.clone());
}
let saved_ids = load_saved_operator_ids().await?;
match saved_ids.as_slice() {
[] => bail!(
"--operator-id is required to post approval to API. \
No saved operator IDs found. \
Use --skip-post to only generate the approval locally."
),
[id] => Ok(id.clone()),
_ if matches!(prompt, OperatorPrompt::Allow) && stdin_can_prompt() => Ok(prompts::select(
"Select approving operator",
saved_ids.iter().map(String::as_str).collect::<Vec<_>>(),
)?
.to_string()),
_ => bail!(
"--operator-id is required to post approval to API when multiple saved operator IDs are available"
),
}
}There was a problem hiding this comment.
@daniilrrr FYI, you can use ```rust to get syntax highlighting in comments
There was a problem hiding this comment.
Part of the point of the whole PR is to remove the bool "can" prompt logic. I think it's a lot harder to maintain and I base that on my first pass at implementing this feature. There's conditional logic everywhere. That means that every helper function needs 2x the amount of tests to get full coverage. Instead, the ethos here is to flatten conditional logic as much as possible at the small cost of some duplication. IMO, the maintenance cost of the duplication is much much lower.
With all that said, you've made me realize that I'm missing a bunch of unit tests that would clearly emphasize my point a little better.
There was a problem hiding this comment.
if let Some(id) = &args.operator_id {
return Ok(id.clone());
}This is an interesting suggestion. Do I go overboard avoiding allocations? Absolutely. But I find it easier to write code where I minimize allocation by default. It makes code a lot easier to change when you do discover a hot-loop that's needlessly allocating. In a cli, optimizing for fewer allocations is almost certainly never going to happen, but if you generally avoid needlessly cloning, you never have to ask that question. I dont' think the cost is very high here (it's calling post_approval_to_api in each branch instead of once).
If the function was more than 100 lines or something, some of these things could be factored out differently, but I think that's only worth it when the function starts getting longer.
There was a problem hiding this comment.
Instead, the ethos here is to flatten conditional logic as much as possible at the small cost of some duplication.
fair tradeoff. add this sentence to the PR to make it crystal clear what your goal is :)
But I find it easier to write code where I minimize allocation by default. It makes code a lot easier to change when you do discover a hot-loop that's needlessly allocating.
that's a good pointer that I will try and incorporate in my future code, thanks
What I'm getting at is that in the other commands you have a super nice flattening of the commands into interactive / non interactive funcs to call and have removed the conditional logic, except here where we still hinge on if let Some(id) = &args.operator_id { ... } else {...}. I think it could be cleaner and more testable to to have resolve_operator_id_interactive() and resolve_operator_id_noninteractive() here. The LLM code did not correctly illustrate my example. I'll paste something better in a few min, hopefully
There was a problem hiding this comment.
pretty flat logic example, curious what you think
LLM code below:
pub async fn run_interactive(args: Args) -> anyhow::Result<()> {
let do_prompt_user = !args.dangerous_skip_interactive;
// Guard: bail fast before fetching the manifest if review prompts are
// required but the caller has no TTY to answer them.
if do_prompt_user && !stdin_can_prompt() {
bail_required_in_non_interactive("--dangerous-skip-interactive")?;
}
let (manifest, fetched_manifest_id) = load_manifest(&args).await?;
if do_prompt_user {
interactive_approve(&manifest)?;
}
if args.dry_run {
println!("Dry run complete. No approval generated.");
return Ok(());
}
let approval = sign_and_output(&args, &manifest).await?;
if args.skip_post { return Ok(()); }
let manifest_id = resolve_manifest_id(&args, fetched_manifest_id.as_deref())?;
let operator_id = resolve_operator_id_interactive().await?;
let plan = PostApprovalPlan {
manifest_id: manifest_id.as_str(),
operator_id: &operator_id,
};
post_approval_to_api(plan, &approval).await?;
Ok(())
}
pub async fn run_non_interactive(args: Args) -> anyhow::Result<()> {
if !args.dangerous_skip_interactive {
bail_required_in_non_interactive("--dangerous-skip-interactive")?;
}
let (manifest, fetched_manifest_id) = load_manifest(&args).await?;
if args.dry_run {
println!("Dry run complete. No approval generated.");
return Ok(());
}
let approval = sign_and_output(&args, &manifest).await?;
if args.skip_post { return Ok(()); }
let manifest_id = resolve_manifest_id(&args, fetched_manifest_id.as_deref())?;
let operator_id = resolve_operator_id_noninteractive().await?;
let plan = PostApprovalPlan {
manifest_id: manifest_id.as_str(),
operator_id: &operator_id,
};
post_approval_to_api(plan, &approval).await?;
Ok(())
}...
and the 2 versions
async fn resolve_operator_id_interactive() -> anyhow::Result<String> {
let saved_ids = load_saved_operator_ids().await?;
match &*saved_ids {
[] => bail!(
"--operator-id is required to post approval to API. \
No saved operator IDs found. \
Use --skip-post to only generate the approval locally."
),
[id] => Ok(id.clone()),
_ if stdin_can_prompt() => Ok(prompts::select(
"Select approving operator",
saved_ids.iter().map(String::as_str).collect::<Vec<_>>(),
)?
.to_string()),
_ => bail!(
"--operator-id is required to post approval to API when multiple saved operator IDs are available"
),
}
}
async fn resolve_operator_id_noninteractive() -> anyhow::Result<String> {
let saved_ids = load_saved_operator_ids().await?;
match &*saved_ids {
[] => bail!(
"--operator-id is required to post approval to API. \
No saved operator IDs found. \
Use --skip-post to only generate the approval locally."
),
[id] => Ok(id.clone()),
_ => bail!(
"--operator-id is required to post approval to API when multiple saved operator IDs are available"
),
}
}|
PR description out of date, |
Linear: TVC-66
Summary:
--non-interactiveflag backed byTVC_NON_INTERACTIVE, using clap boolean parsing so falsey values do not force non-interactive mode.InteractionModeplumbing for commands that may prompt, keeping prompt helpers as TTY UI primitives.Tests:
cargo fmt -p tvc -- --checkcargo test -p tvc logincargo test -p tvc non_interactivecargo test -p tvc deploy_create_help@tkhq/dependency-reviewers, I added
thiserroras a dependency totvc. That's the only change, it was already in the dependency tree.