Skip to content

Commit 3812980

Browse files
fix(config): migrate ~/.harmont config forward, plumb HARMONT_* env, hint on legacy .harmont/ project dir (#133)
1 parent da6e679 commit 3812980

19 files changed

Lines changed: 141 additions & 76 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ and stores the token in `~/.config/hm/credentials.toml` (mode 0600). No browser?
271271
Use `hm cloud login --paste`. In CI, set a token instead:
272272

273273
```sh
274-
export HARMONT_API_TOKEN=hm_live_... # takes precedence over the file
274+
export HM_API_TOKEN=hm_live_... # takes precedence over the file
275275
hm run --cloud --org acme
276276
```
277277

@@ -284,7 +284,7 @@ hm run --cloud --org acme
284284

285285
Settings layer **defaults → user config → project `.hm/config.toml` → env**, so
286286
you can commit per-repo defaults and still override them locally. Env overrides:
287-
`HARMONT_API_URL`, `HARMONT_API_TOKEN`.
287+
`HM_API_URL`, `HM_API_TOKEN`.
288288

289289
### Managing builds from the CLI
290290

crates/hm-config/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ anyhow = "1"
1717
[dev-dependencies]
1818
tempfile = "3"
1919
tokio = { workspace = true }
20+
# `test` brings in `figment::Jail` for isolated env-var/cwd testing.
21+
figment = { workspace = true, features = ["test"] }
2022

2123
[lints]
2224
workspace = true

crates/hm-config/src/creds.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ pub const CLOUD_SERVICE: &str = "harmont-cloud";
6666

6767
/// Resolve the cloud bearer token for `api_base`.
6868
///
69-
/// Priority: `HARMONT_API_TOKEN` env (non-empty) first, then the stored
69+
/// Priority: `HM_API_TOKEN` env (non-empty) first, then the stored
7070
/// credential keyed by `(CLOUD_SERVICE, api_base)`. Returns `None` when
7171
/// neither is present, so the caller can produce a clear "not logged in" error.
7272
#[must_use]
7373
pub fn cloud_token(api_base: &str) -> Option<String> {
74-
if let Ok(t) = std::env::var("HARMONT_API_TOKEN")
74+
if let Ok(t) = std::env::var("HM_API_TOKEN")
7575
&& !t.is_empty()
7676
{
7777
return Some(t);

crates/hm-config/src/lib.rs

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub enum Backend {
4343
/// the host first.
4444
///
4545
/// Priority:
46-
/// 1. `override_url` (e.g. the `HARMONT_APP_URL` env override) when non-empty,
46+
/// 1. `override_url` (e.g. the `HM_APP_URL` env override) when non-empty,
4747
/// 2. heuristic mapping of `api.` → `app.` on the API host,
4848
/// 3. the API base itself (last-resort dev fallback for hosts like
4949
/// `localhost` that have no `api.`/`app.` split).
@@ -139,6 +139,14 @@ impl Config {
139139

140140
/// Testable core: build a `Config` from explicit file paths.
141141
///
142+
/// Layering, lowest to highest precedence: defaults -> user file ->
143+
/// project file -> env.
144+
///
145+
/// Env precedence (highest): both the `HM_`-prefixed split form
146+
/// (`HM_CLOUD__ORG`, `HM_CLOUD__API_URL`) and the documented
147+
/// `HM_ORG` / `HM_API_URL` are honored; the latter map onto
148+
/// `cloud.org` / `cloud.api_url`.
149+
///
142150
/// # Errors
143151
///
144152
/// Returns an error if figment extraction fails (malformed TOML, type mismatches).
@@ -152,7 +160,9 @@ impl Config {
152160
figment = figment.merge(Toml::file(p));
153161
}
154162

155-
figment = figment.merge(Env::prefixed("HM_").split("__"));
163+
figment = figment
164+
.merge(Env::prefixed("HM_").split("__"))
165+
.merge(hm_alias_env());
156166

157167
Ok(figment.extract()?)
158168
}
@@ -183,11 +193,45 @@ impl Config {
183193
}
184194
}
185195

196+
/// Figment env provider mapping the friendly `HM_ORG` / `HM_API_URL`
197+
/// variables onto the nested `cloud` config keys.
198+
///
199+
/// The cloud settings docs and `hm`'s error messages tell users to
200+
/// `set HM_ORG=<slug>` / `HM_API_URL=<url>`, so those flat names must feed
201+
/// the config. This binds them to `cloud.org` / `cloud.api_url` alongside the
202+
/// generic `HM_`-prefixed split layer (`HM_CLOUD__ORG`, …).
203+
fn hm_alias_env() -> Env {
204+
Env::raw()
205+
.only(&["HM_ORG", "HM_API_URL"])
206+
.map(|key| match key.as_str() {
207+
"HM_ORG" => "cloud.org".into(),
208+
"HM_API_URL" => "cloud.api_url".into(),
209+
other => other.into(),
210+
})
211+
.split(".")
212+
}
213+
186214
#[cfg(test)]
187215
#[allow(clippy::unwrap_used)]
188216
mod tests {
189217
use super::*;
190218
use std::io::Write as _;
219+
use std::sync::{Mutex, MutexGuard};
220+
221+
/// Serializes every test that resolves config through `load_*`.
222+
///
223+
/// All `load_*` paths merge the process environment as their top layer, so
224+
/// a test that sets `HM_*` (via `figment::Jail`, which mutates the
225+
/// real process env for the duration of its closure) would otherwise leak
226+
/// into a concurrently-running file-layering test. Holding this lock for
227+
/// the whole body of any env-or-load test makes them mutually exclusive.
228+
static ENV_LOCK: Mutex<()> = Mutex::new(());
229+
230+
fn env_guard() -> MutexGuard<'static, ()> {
231+
ENV_LOCK
232+
.lock()
233+
.unwrap_or_else(std::sync::PoisonError::into_inner)
234+
}
191235

192236
#[test]
193237
fn app_url_maps_prod_api_to_app() {
@@ -250,6 +294,7 @@ auto_watch = true
250294

251295
#[test]
252296
fn deserialize_sparse_toml() {
297+
let _g = env_guard();
253298
let toml_str = r#"
254299
[cloud]
255300
org = "sparse-co"
@@ -266,6 +311,7 @@ org = "sparse-co"
266311

267312
#[test]
268313
fn deserialize_empty_toml() {
314+
let _g = env_guard();
269315
let mut f = tempfile::NamedTempFile::new().unwrap();
270316
f.write_all(b"").unwrap();
271317

@@ -278,6 +324,7 @@ org = "sparse-co"
278324

279325
#[test]
280326
fn figment_project_overrides_user() {
327+
let _g = env_guard();
281328
let user_toml = r#"
282329
[cloud]
283330
org = "user-org"
@@ -313,6 +360,7 @@ org = "project-org"
313360

314361
#[test]
315362
fn backend_defaults_docker_and_parses_and_layers() {
363+
let _g = env_guard();
316364
// default
317365
assert_eq!(Config::default().backend, Backend::Docker);
318366

@@ -334,6 +382,7 @@ org = "project-org"
334382

335383
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
336384
async fn save_and_reload_roundtrip() {
385+
let _g = env_guard();
337386
let tmp = tempfile::tempdir().unwrap();
338387
let path = tmp.path().join("config.toml");
339388
let cfg = Config {
@@ -351,8 +400,47 @@ org = "project-org"
351400
assert_eq!(loaded.preferences.format, "human");
352401
}
353402

403+
#[test]
404+
#[allow(clippy::result_large_err)] // figment::Error is the Jail closure's error type.
405+
fn hm_env_overrides_cloud_keys() {
406+
let _g = env_guard();
407+
// `Jail` isolates env mutation from concurrently-running tests.
408+
figment::Jail::expect_with(|jail| {
409+
jail.set_env("HM_ORG", "env-org");
410+
jail.set_env("HM_API_URL", "https://env.api");
411+
412+
let cfg = Config::load_from_paths(None, None).unwrap();
413+
assert_eq!(cfg.cloud.org.as_deref(), Some("env-org"));
414+
assert_eq!(cfg.cloud.api_url, "https://env.api");
415+
Ok(())
416+
});
417+
}
418+
419+
#[test]
420+
#[allow(clippy::result_large_err)] // figment::Error is the Jail closure's error type.
421+
fn hm_env_overrides_user_file() {
422+
let _g = env_guard();
423+
// Env is the highest-precedence layer: it wins over a user file.
424+
figment::Jail::expect_with(|jail| {
425+
jail.set_env("HM_ORG", "env-org");
426+
427+
jail.create_file(
428+
"config.toml",
429+
"[cloud]\norg = \"file-org\"\napi_url = \"https://file.api\"\n",
430+
)?;
431+
let user = jail.directory().join("config.toml");
432+
433+
let cfg = Config::load_from_paths(Some(&user), None).unwrap();
434+
assert_eq!(cfg.cloud.org.as_deref(), Some("env-org"));
435+
// Unset env keys still come from the file.
436+
assert_eq!(cfg.cloud.api_url, "https://file.api");
437+
Ok(())
438+
});
439+
}
440+
354441
#[test]
355442
fn figment_missing_files_still_resolve() {
443+
let _g = env_guard();
356444
let nonexistent_user = Path::new("/tmp/harmont-test-nonexistent-user/config.toml");
357445
let nonexistent_project = Path::new("/tmp/harmont-test-nonexistent-project/config.toml");
358446

crates/hm-dsl-engine/harmont-py/harmont/_envelope.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def dump_registry_json(
6868
"""Emit the schema_version=1 envelope JSON.
6969
7070
Defaults mirror ``pipeline_to_json``:
71-
``pipeline_org`` <- ``env["HARMONT_PIPELINE_ORG"]`` or ``"default"``
71+
``pipeline_org`` <- ``env["HM_PIPELINE_ORG"]`` or ``"default"``
7272
``now`` <- ``int(time.time())``
7373
``base_path`` <- ``Path.cwd()`` (resolves ``on_change`` cache paths)
7474
``env`` <- ``os.environ``
@@ -81,11 +81,7 @@ def dump_registry_json(
8181
"""
8282
clear_target_memo()
8383
env_map: Mapping[str, str] = env if env is not None else os.environ
84-
org = (
85-
pipeline_org
86-
if pipeline_org is not None
87-
else env_map.get("HARMONT_PIPELINE_ORG", "default")
88-
)
84+
org = pipeline_org if pipeline_org is not None else env_map.get("HM_PIPELINE_ORG", "default")
8985
render_now = now if now is not None else int(time.time())
9086
bp = base_path if base_path is not None else Path.cwd()
9187
return json.dumps(

crates/hm-dsl-engine/harmont-py/harmont/json_emit.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,16 @@ def pipeline_to_json(
3737
3838
Resolves cache keys before serialization. Defaults mirror the
3939
environment hooks of the old Scheme renderer:
40-
pipeline_org <- env["HARMONT_PIPELINE_ORG"] or "default"
41-
pipeline_slug <- env["HARMONT_PIPELINE_SLUG"] or "default"
40+
pipeline_org <- env["HM_PIPELINE_ORG"] or "default"
41+
pipeline_slug <- env["HM_PIPELINE_SLUG"] or "default"
4242
now <- int(time.time())
4343
base_path <- Path.cwd()
4444
env <- os.environ
4545
"""
4646
env_map: Mapping[str, str] = env if env is not None else os.environ
47-
org = (
48-
pipeline_org
49-
if pipeline_org is not None
50-
else env_map.get("HARMONT_PIPELINE_ORG", "default")
51-
)
47+
org = pipeline_org if pipeline_org is not None else env_map.get("HM_PIPELINE_ORG", "default")
5248
slug = (
53-
pipeline_slug
54-
if pipeline_slug is not None
55-
else env_map.get("HARMONT_PIPELINE_SLUG", "default")
49+
pipeline_slug if pipeline_slug is not None else env_map.get("HM_PIPELINE_SLUG", "default")
5650
)
5751
render_now = now if now is not None else int(time.time())
5852
bp = base_path if base_path is not None else Path.cwd()

crates/hm-dsl-engine/harmont-py/tests/examples_render_conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Shared helpers for rendering external example pipelines.
22
33
These tests render the pipeline definitions in harmont-cli/examples/
4-
to v0 IR JSON. They are gated behind HARMONT_CLI_PATH so they only
4+
to v0 IR JSON. They are gated behind HM_CLI_PATH so they only
55
run when a sibling harmont-cli checkout is available.
66
"""
77

@@ -19,7 +19,7 @@
1919

2020

2121
def harmont_cli_examples_root() -> pathlib.Path | None:
22-
raw = os.environ.get("HARMONT_CLI_PATH")
22+
raw = os.environ.get("HM_CLI_PATH")
2323
if not raw:
2424
return None
2525
p = pathlib.Path(raw) / "examples"

crates/hm-dsl-engine/harmont-py/tests/test_examples_render.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""End-to-end render checks against harmont-cli example pipelines.
22
3-
Gated: skipped when HARMONT_CLI_PATH is unset. CI sets it after
3+
Gated: skipped when HM_CLI_PATH is unset. CI sets it after
44
cloning harmont-cli.
55
"""
66

@@ -24,7 +24,7 @@
2424

2525
pytestmark = pytest.mark.skipif(
2626
EXAMPLES_ROOT is None,
27-
reason="HARMONT_CLI_PATH not set or examples/ missing",
27+
reason="HM_CLI_PATH not set or examples/ missing",
2828
)
2929

3030

crates/hm-dsl-engine/harmont-ts/src/envelope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function renderEnvelope(
3737
const pipelineOrg =
3838
opts?.pipelineOrg ??
3939
(typeof process !== "undefined"
40-
? process.env.HARMONT_PIPELINE_ORG
40+
? process.env.HM_PIPELINE_ORG
4141
: undefined) ??
4242
"default";
4343
const now = opts?.now ?? Math.floor(Date.now() / 1000);

0 commit comments

Comments
 (0)