Skip to content

feat: typed loader gets interactive prompting for missing secrets, matching the untyped API - #375

Open
Ch4s3r wants to merge 1 commit into
cachix:mainfrom
Ch4s3r:feat/typed-interactive-load
Open

feat: typed loader gets interactive prompting for missing secrets, matching the untyped API#375
Ch4s3r wants to merge 1 commit into
cachix:mainfrom
Ch4s3r:feat/typed-interactive-load

Conversation

@Ch4s3r

@Ch4s3r Ch4s3r commented Aug 17, 2026

Copy link
Copy Markdown

What

declare_secrets!'s generated builder gets prompt_missing(bool), mirroring
Secrets::ensure_secrets on the untyped API: when a required secret is
missing and stdin is a real terminal, it prompts and stores the value
instead of failing with RequiredSecretMissing. It delegates to the exact
same ensure_secrets call the untyped API already uses — no new prompt or
storage logic. Off by default (false), so existing callers are unaffected.

secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resolved = SecretSpec::builder()
        .with_provider("keyring://")
        .with_reason("start application")
        .prompt_missing(true)
        .load()?;

    println!("Database: {}", resolved.secrets.database_url);
    Ok(())
}

First run with nothing stored yet:

$ cargo run --example prompt_missing
1 secret is missing in profile default with provider keyring:

  - DATABASE_URL - PostgreSQL connection string

[1/1] Enter value for DATABASE_URL: ****
✓ Secret 'DATABASE_URL' saved to keyring (profile: default)

All required secrets have been set.
Database: postgres://localhost/mydb

Second run: resolves from the keychain, no prompt. Non-interactive (CI,
piped stdin): same RequiredSecretMissing as prompt_missing(false), never
hangs.

Same behavior on the untyped API, for comparison

This is not new — it's what the untyped Secrets API already does today.
prompt_missing(true) above is a thin pass-through to the same call
(verified running, not just compiling):

use secretspec::{Secret, Secrets, Spec};
use secrecy::ExposeSecret;

fn main() -> secretspec::Result<()> {
    let spec = Spec::builder("checkout")
        .provider("keyring", "keyring://")
        .secret(
            "DATABASE_URL",
            Secret::required("PostgreSQL connection string"),
        )
        .build()?;

    let mut secrets = Secrets::from_spec(spec)?.with_reason("checkout");
    secrets.set_provider("keyring");
    let resolved = secrets.ensure_secrets(None, None, true)?;

    println!("Database: {}", resolved.resolved.secrets["DATABASE_URL"].expose_secret());
    Ok(())
}

Same prompt, same keychain write, same RequiredSecretMissing on a
non-terminal stdin — only the surface differs: a builder method that returns
typed struct fields vs. a function parameter that returns a name-keyed map.

Why

The typed and untyped APIs otherwise behave identically; this was the one
capability only the untyped Secrets API had.

Changes

  • secretspec-derive: SecretSpecBuilder::prompt_missing, threaded through
    load() and load_profile().
  • Tests: success-path parity, default-unset regression, non-terminal
    fallback, constraint-violation-vs-missing-secret guard, and load_profile()
    wiring (separate from load()'s).
  • New secretspec-derive/examples/prompt_missing.rs, referenced from the
    Rust SDK docs.

`declare_secrets!`'s generated builder gets a `prompt_missing(bool)` method
mirroring `Secrets::ensure_secrets` on the untyped API: when a required
secret is missing and stdin is a real terminal, it prompts and stores the
value instead of failing with `RequiredSecretMissing`. Off by default, no
behavior change for existing callers.
@Ch4s3r Ch4s3r changed the title feat: interactive prompt-and-store for the typed loader feat: typed loader gets interactive prompting for missing secrets, matching the untyped API Aug 17, 2026
@Ch4s3r
Ch4s3r marked this pull request as ready for review August 17, 2026 16:26
@Ch4s3r

Ch4s3r commented Aug 17, 2026

Copy link
Copy Markdown
Author

@domenkozar: is the opt-in prompt_missing(bool) flag the right call here, or should the typed loader just always prompt-and-store missing required secrets by default, matching the untyped Secrets API's behavior without needing the caller to set anything?

I would recommend to default to prompting. It matches the untyped API's own default and the typed loader already only prompts when stdin is a real terminal, so CI/non-interactive callers are unaffected either way. I only made it opt-in here to avoid a behavior change for existing load()/load_profile() callers without discussion — happy to flip it and drop the flag if that's preferred.

@domenkozar

Copy link
Copy Markdown
Member

Hmmmm, I need to think about this.

@Ch4s3r

Ch4s3r commented Aug 18, 2026

Copy link
Copy Markdown
Author

My goal it to just have the same experience with the declare macro, that it asks the user for missing variables if on a terminal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants