Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3bbdf06
chore: baseline — per-env OAuth, consistent User-Agent, token timeout…
jpage-godaddy Jun 17, 2026
c7e0b8d
feat(environments): add OAuthConfig and Environment core types
jpage-godaddy Jun 17, 2026
ec270d2
feat(environments): add EnvironmentDef and Environments builder
jpage-godaddy Jun 17, 2026
98a7f1d
feat(environments): layered resolve with env-var overrides, file laye…
jpage-godaddy Jun 17, 2026
1d03e06
refactor(environments): address review — non_exhaustive, partial-oaut…
jpage-godaddy Jun 17, 2026
b19e175
feat(environments): thread environments through middleware
jpage-godaddy Jun 17, 2026
0d86f60
feat(environments): add CliConfig::with_environments
jpage-godaddy Jun 17, 2026
4f3468a
feat(environments): add CommandContext::environment accessor
jpage-godaddy Jun 17, 2026
36bf86e
feat(environments): register --env and seed active env into middleware
jpage-godaddy Jun 17, 2026
2fd09c6
feat(environments): add built-in env command group
jpage-godaddy Jun 17, 2026
d8c1a02
feat(environments)!: source PkceAuthProvider OAuth from Environments
jpage-godaddy Jun 17, 2026
888bb68
fix(environments): guard --env read so CLIs without environments don'…
jpage-godaddy Jun 17, 2026
fcb328a
refactor(environments): address review minors — as_ref, --env help, e…
jpage-godaddy Jun 17, 2026
3540417
docs(environments): document first-class environments
jpage-godaddy Jun 17, 2026
56da50b
fix(environments)!: share one app_id-stamped Environments Arc between…
jpage-godaddy Jun 17, 2026
13b63a5
fix(docs): avoid intra-doc links to feature-gated pkce from non-gated…
jpage-godaddy Jun 17, 2026
784172d
docs(environments): correct env-var layer semantics (override, not de…
jpage-godaddy Jun 17, 2026
4c7cd87
refactor: panic-safe UA reset in tests + document resolve() blocking I/O
jpage-godaddy Jun 17, 2026
c5906f6
docs(environments): accurate list() error wording + blocking note on …
jpage-godaddy Jun 17, 2026
1078495
refactor(environments): clear persist_active error, explicit env syst…
jpage-godaddy Jun 17, 2026
29bfcbb
docs(environments): clarify extra bag values must be TOML strings
jpage-godaddy Jun 17, 2026
aa60716
docs(environments): fix stale design status + resolver log level
jpage-godaddy Jun 17, 2026
76cddc2
test(foundation): pin default UA under lock in raw-body UA assertions
jpage-godaddy Jun 17, 2026
9f2475b
docs(environments): note list() performs blocking file I/O
jpage-godaddy Jun 17, 2026
2a6a80d
refactor(pkce): resolve OAuth config once per token flow
jpage-godaddy Jun 17, 2026
9beae6f
docs: drop internal implementation plan from the PR
jpage-godaddy Jun 17, 2026
6195697
refactor(env): add value_name/help to env set arg; drop drifting desi…
jpage-godaddy Jun 17, 2026
54889d1
test(foundation): apply RestoreDefaultUserAgent guard to all UA lock …
jpage-godaddy Jun 17, 2026
8a82432
fix(environments): readable unknown-env error when no envs defined
jpage-godaddy Jun 17, 2026
66f3d1e
perf(pkce): resolve default scopes lazily off the cached-token hot path
jpage-godaddy Jun 17, 2026
a083886
docs(transport): set_default_user_agent also governs token traffic
jpage-godaddy Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,7 @@ path.

## Environments

Environment selection is application-defined. If an application needs an environment flag, it can
register one through `CliConfig::register_flags` and store the parsed value on middleware through
`CliConfig::apply_flags`. Auth providers receive the environment value during credential operations.
`cli_engine` provides a first-class environment system with layered resolution, a config-file layer, env-var overrides, sticky active-env persistence, and per-environment OAuth for `PkceAuthProvider`; see [Environments](environments.md) for the full reference.

## Risk Tiers

Expand Down
171 changes: 171 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Environments

`cli_engine` provides a first-class environment system that lets CLIs target named deployment environments — `prod`, `ote`, `dev`, and any others the application defines — without consumers having to wire flags, config lookups, or OAuth overrides by hand.

When `CliConfig::with_environments` is called, the engine:

- Registers a global `--env` flag on every command.
- Seeds the active environment into middleware at startup.
- Exposes the resolved environment to handlers via `CommandContext::environment`.
- Mounts the built-in `env list / get / set / info` commands under the admin help category.

## Resolution Layers

`Environments::resolve(name)` builds a fully-merged `Environment` by applying three layers in order.
Later layers win over earlier ones.

1. **Compiled-in defaults** — `EnvironmentDef` values registered with `Environments::with_environment` in the application source code.
2. **`environments.toml`** — the file at `<config-dir>/<app-id>/environments.toml`, when enabled with `Environments::with_config_file(true)`.
3. **Environment-variable overrides** — `<ENV>_OAUTH_CLIENT_ID`, `<ENV>_OAUTH_AUTH_URL`, `<ENV>_OAUTH_TOKEN_URL`, and `<ENV>_<KEY>` for each bag key already present in the merged record.

A name that is unknown to all three layers — not in compiled defaults, not in the file, and not resolvable — returns an error listing the known names.

## environments.toml Schema

The file uses one top-level TOML table per environment name:

```toml
[prod]
client_id = "prod-client-id"
auth_url = "https://api.example.com/v2/oauth2/authorize"
token_url = "https://api.example.com/v2/oauth2/token"
scopes = ["openid", "profile"]
api_url = "https://api.example.com"

[ote]
client_id = "ote-client-id"
auth_url = "https://api.ote.example.com/v2/oauth2/authorize"
token_url = "https://api.ote.example.com/v2/oauth2/token"
api_url = "https://api.ote.example.com"
```

The recognized OAuth keys — `client_id`, `auth_url`, `token_url`, and `scopes` (an array of strings) — are parsed into the typed `OAuthConfig` slice of the resolved `Environment`.
Every other key is captured as a free-form field in `Environment::extra`, which is a `BTreeMap<String, String>` — so these values **must be TOML strings** (for example `api_url` above).
A non-OAuth key whose value is a number, boolean, or array fails to parse; quote it as a string instead.
The `extra` bag is printed verbatim by `env info`, so it must not hold secrets.

## Environment-Variable Overrides

The prefix is the environment name uppercased with `-` replaced by `_` (`ote` → `OTE`, `prod-us` → `PROD_US`).
Names that differ only by `-` vs `_` map to the same prefix and will collide; avoid such names.

The three OAuth fields are always overridable:

| Variable | Field overridden |
| --- | --- |
| `<ENV>_OAUTH_CLIENT_ID` | `oauth.client_id` |
| `<ENV>_OAUTH_AUTH_URL` | `oauth.auth_url` |
| `<ENV>_OAUTH_TOKEN_URL` | `oauth.token_url` |

Scopes are **not** env-var overridable; set them in the compiled-in layer or `environments.toml`.

Bag keys in `Environment::extra` are overridable via `<ENV>_<KEY>` only when the key is already present in the merged record after layers 1 and 2.
For example, `api_url` must exist in either the compiled defaults or the file before `PROD_API_URL` has any effect.

## Active Environment

The active environment controls which environment is targeted when no `--env` flag is passed.

**Precedence** (highest first):

1. `--env <name>` on the command line.
2. The `environment.active` key in the per-application config file (persisted by `env set`).
3. The default set in `Environments::new(default_env)`.

`env set <name>` validates that the environment is defined — by a compiled default or `environments.toml` — and then writes `environment.active` to the config file.
Environment variables override fields of a defined environment but cannot define a new, selectable environment on their own, so a name known only through `<ENV>_*` variables is rejected.
The next invocation (without `--env`) picks it up from layer 2.

The built-in commands are:

| Command | Description |
| --- | --- |
| `env list` | Lists all known environments (compiled + file), marking the active one. |
| `env get` | Prints the active environment name. |
| `env set <name>` | Validates and persists `name` as the active environment. |
| `env info` | Prints the fully resolved active environment including OAuth and extra fields. |

`env set` is marked `Tier::Mutate` so `--dry-run` short-circuits the config-file write.

## Per-Environment OAuth via PkceAuthProvider

`PkceAuthProvider::with_environments(Arc<Environments>)` wires the provider to the same `Environments` instance, making it the single source of truth for per-environment OAuth config.
Build one `Arc<Environments>` — with `Environments::with_app_id(<same app_id as CliConfig>)` set on it — and share that same `Arc` between the provider and `CliConfig::with_environments`.
If the two receive different instances, a file-defined environment (or a file override of a compiled environment's `client_id`) is visible to `env info` yet invisible to the actual OAuth login.

When the provider resolves a credential for `env`, it calls `Environments::resolve(env)` and uses the resulting `OAuthConfig`.
Each field falls through to the next source when empty:

1. The resolved environment's field (when non-empty).
2. The legacy provider-prefixed env var (`<PROVIDER_PREFIX>_OAUTH_CLIENT_ID`, `_AUTH_URL`, `_TOKEN_URL`), where `<PROVIDER_PREFIX>` is the provider name uppercased with `-` → `_`.
3. The base configuration supplied to `PkceAuthProvider::new`.

Scopes follow the same pattern: the resolved environment's scopes when non-empty, otherwise the provider's base scopes.

If `Environments::resolve` fails — because the name is unknown or the environments file cannot be parsed — the provider logs the error at `DEBUG` level and falls back to the next source.
No token or secret is included in the log; only the environment name and error message appear.

## Example

```rust,no_run
use std::sync::Arc;
use cli_engine::{
BuildInfo, Cli, CliConfig,
auth::pkce::PkceAuthProvider,
environments::{EnvironmentDef, Environments},
};

// Build one Arc<Environments> and share it. `with_app_id` must match the
// CliConfig app_id ("my-cli") so the environments.toml file path resolves.
let environments = Arc::new(
Environments::new("prod")
.with_app_id("my-cli")
.with_environment(
"prod",
EnvironmentDef::new()
.with_client_id("prod-client-id")
.with_auth_url("https://api.example.com/v2/oauth2/authorize")
.with_token_url("https://api.example.com/v2/oauth2/token")
.with_scopes(&["openid", "profile"])
.with_field("api_url", "https://api.example.com"),
)
.with_environment(
"ote",
EnvironmentDef::new()
.with_client_id("ote-client-id")
.with_auth_url("https://api.ote.example.com/v2/oauth2/authorize")
.with_token_url("https://api.ote.example.com/v2/oauth2/token")
.with_field("api_url", "https://api.ote.example.com"),
)
.with_config_file(true),
);

let provider = Arc::new(
PkceAuthProvider::new(
"primary",
"https://api.example.com/v2/oauth2/authorize",
"https://api.example.com/v2/oauth2/token",
"fallback-client-id",
&["openid"],
)
.with_environments(Arc::clone(&environments)),
);

let cli = Cli::new(
CliConfig::new("my-cli", "My CLI", "my-cli")
.with_build(BuildInfo::new(env!("CARGO_PKG_VERSION")))
.with_default_auth_provider("primary")
.with_auth_provider(provider)
// The same Arc the provider was wired with — not a separate copy — so the
// file layer and active-env persistence resolve identically for both.
.with_environments(environments),
);
```

With this setup:

- Running `my-cli env list` prints `ote` and `prod`, marking whichever is active.
- Running `my-cli env set ote` persists `ote` as active; subsequent invocations target OTE.
- Running `my-cli --env prod <command>` overrides the active environment for that invocation only.
- `PROD_OAUTH_CLIENT_ID=override my-cli --env prod auth login` injects the override at the env-var layer.
- A user-supplied `environments.toml` in the config directory can add new environments or override fields without recompiling.
Loading