Skip to content

Commit 5e97558

Browse files
committed
feat(hm-core): add EnvVarProvider, read env through AppCtx::env()
Snapshot the process environment once at startup behind EnvVarProvider (get/is_set/is_present/parse, redacted Debug since env can hold secrets), exposed as AppCtx::env(). Term::detect now reads SSH/DISPLAY/NO_COLOR from it instead of hitting std::env inline. Also drop the dead env threading through the cloud dispatch: verbs all ignored the BTreeMap (the run verb that used it moved to hm run --cloud), so remove the _env params, the dispatch env argument, and the std::env::vars() collect.
1 parent d58f4ae commit 5e97558

13 files changed

Lines changed: 106 additions & 73 deletions

File tree

‎crates/hm-core/src/app_ctx.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use hm_common::python::Python;
1111
use crate::config::domain::ConfigLoadingError;
1212
use crate::config::user::UserConfig;
1313
use crate::creds::{CredsInitError, CredsProvider};
14+
use crate::env::EnvVarProvider;
1415
use crate::term::Term;
1516

1617
/// Failure to initialize the [`AppCtx`].
@@ -42,6 +43,7 @@ pub struct AppCtx {
4243
dirs: DirProvider,
4344
user_config: Option<UserConfig>,
4445
creds: CredsProvider,
46+
env: EnvVarProvider,
4547
term: Term,
4648
}
4749

@@ -70,14 +72,18 @@ impl AppCtx {
7072
let user_config = user_config.map_err(InitError::UserConfig)?;
7173
let creds = creds?;
7274

75+
let env = EnvVarProvider::init();
76+
let term = Term::detect(&env);
77+
7378
Ok(Self {
7479
git,
7580
python3,
7681
cwd,
7782
dirs,
7883
user_config,
7984
creds,
80-
term: Term::detect(),
85+
env,
86+
term,
8187
})
8288
}
8389

@@ -117,6 +123,12 @@ impl AppCtx {
117123
self.term
118124
}
119125

126+
/// The environment variables captured at initialization.
127+
#[must_use]
128+
pub const fn env(&self) -> &EnvVarProvider {
129+
&self.env
130+
}
131+
120132
/// The user config, or `None` when no user config file is present.
121133
#[must_use]
122134
pub const fn user_config(&self) -> Option<&UserConfig> {

‎crates/hm-core/src/config/domain.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ impl BackendDomain {
5656
/// The API base URL (`https://api.<domain>`), without a trailing slash.
5757
#[must_use]
5858
pub fn api_url(&self) -> String {
59-
self.subdomain("api").as_str().trim_end_matches('/').to_owned()
59+
self.subdomain("api")
60+
.as_str()
61+
.trim_end_matches('/')
62+
.to_owned()
6063
}
6164

6265
/// The dashboard base URL (`https://app.<domain>`), without a trailing slash.

‎crates/hm-core/src/env.rs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! Process environment: a snapshot of the environment variables the CLI reads.
2+
3+
use std::collections::HashMap;
4+
5+
/// A snapshot of the process environment, captured once at startup so the CLI
6+
/// reads variables from one place rather than hitting `std::env` ad hoc.
7+
pub struct EnvVarProvider {
8+
vars: HashMap<String, String>,
9+
}
10+
11+
impl EnvVarProvider {
12+
/// Capture the current environment. Variables whose name or value is not
13+
/// valid UTF-8 are skipped.
14+
#[must_use]
15+
pub fn init() -> Self {
16+
let vars = std::env::vars_os()
17+
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
18+
.collect();
19+
Self { vars }
20+
}
21+
22+
/// The value of `name`, if present — which may be an empty string.
23+
#[must_use]
24+
pub fn get(&self, name: &str) -> Option<&str> {
25+
self.vars.get(name).map(String::as_str)
26+
}
27+
28+
/// Whether `name` is present at all, even set to an empty string.
29+
#[must_use]
30+
pub fn is_present(&self, name: &str) -> bool {
31+
self.vars.contains_key(name)
32+
}
33+
34+
/// Whether `name` is present and non-empty.
35+
#[must_use]
36+
pub fn is_set(&self, name: &str) -> bool {
37+
self.get(name).is_some_and(|value| !value.is_empty())
38+
}
39+
40+
/// Parse `name`'s value into `T`, if it is present and parses.
41+
#[must_use]
42+
pub fn parse<T: std::str::FromStr>(&self, name: &str) -> Option<T> {
43+
self.get(name)?.parse().ok()
44+
}
45+
}
46+
47+
impl std::fmt::Debug for EnvVarProvider {
48+
/// Redacted: environment variables can hold secrets, so only the count of
49+
/// captured variables is shown.
50+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51+
f.debug_struct("EnvVarProvider")
52+
.field("vars", &format_args!("<{} variables>", self.vars.len()))
53+
.finish()
54+
}
55+
}

‎crates/hm-core/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
pub mod app_ctx;
1010
pub mod config;
1111
pub mod creds;
12+
pub mod env;
1213
pub mod exec;
1314
pub mod project_ctx;
1415
pub mod term;

‎crates/hm-core/src/term.rs‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
44
use std::io::IsTerminal as _;
55

6+
use crate::env::EnvVarProvider;
7+
68
/// The runtime environment facts, captured once at startup.
79
#[derive(Debug, Clone, Copy)]
810
#[allow(
@@ -20,20 +22,18 @@ pub struct Term {
2022
}
2123

2224
impl Term {
23-
/// Capture the environment from the standard streams, CI signals, the SSH
24-
/// session variables, and the display variables.
25+
/// Capture terminal state from the standard streams and CI signals, and the
26+
/// session/display facts from `env`.
2527
#[must_use]
26-
pub fn detect() -> Self {
28+
pub fn detect(env: &EnvVarProvider) -> Self {
2729
Self {
2830
stdin: std::io::stdin().is_terminal(),
2931
stdout: std::io::stdout().is_terminal(),
3032
stderr: std::io::stderr().is_terminal(),
3133
ci: is_ci::cached(),
32-
ssh: env_present("SSH_CONNECTION")
33-
|| env_present("SSH_TTY")
34-
|| env_present("SSH_CLIENT"),
35-
display: env_present("DISPLAY") || env_present("WAYLAND_DISPLAY"),
36-
no_color: std::env::var_os("NO_COLOR").is_some(),
34+
ssh: env.is_set("SSH_CONNECTION") || env.is_set("SSH_TTY") || env.is_set("SSH_CLIENT"),
35+
display: env.is_set("DISPLAY") || env.is_set("WAYLAND_DISPLAY"),
36+
no_color: env.is_present("NO_COLOR"),
3737
}
3838
}
3939

@@ -91,8 +91,3 @@ impl Term {
9191
}
9292
}
9393
}
94-
95-
/// Whether an environment variable is set to a non-empty value.
96-
fn env_present(name: &str) -> bool {
97-
std::env::var_os(name).is_some_and(|v| !v.is_empty())
98-
}

‎crates/hm/src/cli/mod.rs‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,6 @@ pub async fn dispatch(command: Command, ctx: RunContext<'_>) -> Result<i32> {
114114
},
115115
Command::Version => version::run().await.map(|()| 0),
116116
Command::Plugin(cmd) => plugin::run(cmd).await.map(|()| 0),
117-
Command::Cloud(cmd) => {
118-
let env = std::env::vars().collect();
119-
crate::commands::cloud::cli::dispatch_command(cmd, env, app).await
120-
}
117+
Command::Cloud(cmd) => crate::commands::cloud::cli::dispatch_command(cmd, app).await,
121118
}
122119
}

‎crates/hm/src/commands/cloud/cli.rs‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
//! Dispatch for `hm cloud` subcommands.
22
3-
use std::collections::BTreeMap;
4-
53
use anyhow::Result;
64
use hm_cloud::cli::{AuthCommand, CloudCommand};
75
use hm_core::app_ctx::AppCtx;
@@ -31,22 +29,18 @@ impl From<ExitCode> for i32 {
3129
///
3230
/// Returns an error only if dispatch itself fails; a verb's own runtime
3331
/// failure is logged and mapped to a non-zero exit code.
34-
pub async fn dispatch_command(
35-
command: CloudCommand,
36-
env: BTreeMap<String, String>,
37-
app: &AppCtx,
38-
) -> Result<i32> {
32+
pub async fn dispatch_command(command: CloudCommand, app: &AppCtx) -> Result<i32> {
3933
let result = match command {
4034
CloudCommand::Auth(cmd) => match cmd {
4135
AuthCommand::Login => auth::login::run(app).await,
4236
AuthCommand::Logout => auth::logout::run(app).await,
4337
AuthCommand::Whoami => auth::whoami::run(app).await,
4438
},
45-
CloudCommand::Org(cmd) => verbs::org::run(&env, cmd, app).await,
46-
CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd, app).await,
47-
CloudCommand::Build(cmd) => verbs::build::run(&env, cmd, app).await,
48-
CloudCommand::Job(cmd) => verbs::job::run(&env, cmd, app).await,
49-
CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd, app).await,
39+
CloudCommand::Org(cmd) => verbs::org::run(cmd, app).await,
40+
CloudCommand::Pipeline(cmd) => verbs::pipeline::run(cmd, app).await,
41+
CloudCommand::Build(cmd) => verbs::build::run(cmd, app).await,
42+
CloudCommand::Job(cmd) => verbs::job::run(cmd, app).await,
43+
CloudCommand::Billing(cmd) => verbs::billing::run(cmd, app).await,
5044
};
5145
match result {
5246
Ok(()) => Ok(ExitCode::Success.into()),

‎crates/hm/src/commands/cloud/settings.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use harmont_cloud::HarmontClient;
88
use hm_core::app_ctx::AppCtx;
99
use hm_core::config::ResolvedCloudConfig;
1010
use hm_core::config::domain::{BackendConfig, BackendDomain};
11-
use hm_core::term::Term;
1211
use hm_core::config::user::UserCloudConfig;
12+
use hm_core::term::Term;
1313
use secrecy::ExposeSecret as _;
1414

1515
/// Resolved cloud context for the `hm cloud` verbs.

‎crates/hm/src/commands/cloud/verbs/billing.rs‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
//! `hm cloud billing balance|transactions|usage|topup|redeem`.
22
3-
use std::collections::BTreeMap;
4-
53
use anyhow::Result;
64
use harmont_cloud::HarmontClient;
75

8-
use hm_cloud::cli::BillingCommand;
96
use crate::commands::cloud::settings;
7+
use hm_cloud::cli::BillingCommand;
108
use hm_core::app_ctx::AppCtx;
119

1210
/// Convert an integer cent amount to dollars for display.
@@ -18,11 +16,7 @@ fn cents_to_dollars(cents: i64) -> f64 {
1816
cents as f64 / 100.0
1917
}
2018

21-
pub(crate) async fn run(
22-
_env: &BTreeMap<String, String>,
23-
cmd: BillingCommand,
24-
app: &AppCtx,
25-
) -> Result<()> {
19+
pub(crate) async fn run(cmd: BillingCommand, app: &AppCtx) -> Result<()> {
2620
let (client, ctx) = settings::client(app).await?;
2721
let org = ctx.org()?;
2822

‎crates/hm/src/commands/cloud/verbs/build.rs‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
//! `hm cloud build list|show|cancel|watch`.
22
3-
use std::collections::BTreeMap;
4-
53
use anyhow::Result;
64
use harmont_cloud::HarmontClient;
75

8-
use hm_cloud::cli::BuildCommand;
96
use crate::commands::cloud::settings;
7+
use hm_cloud::cli::BuildCommand;
108
use hm_core::app_ctx::AppCtx;
119
use hm_core::exec::cloud::watch::watch_build;
1210
use hm_core::term::Term;
1311

14-
pub(crate) async fn run(
15-
_env: &BTreeMap<String, String>,
16-
cmd: BuildCommand,
17-
app: &AppCtx,
18-
) -> Result<()> {
12+
pub(crate) async fn run(cmd: BuildCommand, app: &AppCtx) -> Result<()> {
1913
let (client, ctx) = settings::client(app).await?;
2014
let org = ctx.org()?;
2115

@@ -70,7 +64,13 @@ async fn cancel(client: &HarmontClient, org: &str, pipe: &str, number: i64) -> R
7064
Ok(())
7165
}
7266

73-
async fn watch(client: &HarmontClient, org: &str, pipe: &str, number: i64, term: Term) -> Result<()> {
67+
async fn watch(
68+
client: &HarmontClient,
69+
org: &str,
70+
pipe: &str,
71+
number: i64,
72+
term: Term,
73+
) -> Result<()> {
7474
// Render the live build through the shared `hm-render` renderers (the same
7575
// ones a local `hm run` uses), driven by the `BuildEvent`s `watch_build`
7676
// emits over an mpsc channel.

0 commit comments

Comments
 (0)